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 (
{filteredVodData.length === 0 ? ( - + props.after ? ( +

No older VODs.

+ ) : ( + + ) ) : ( filteredVodData.map((vod) => ( @@ -182,6 +203,19 @@ export const VODs = async (props: { username: string }) => { )) )} + {(olderVodsPath || props.after) && ( + + )}
); };