From f81bc0892ac26638cc2e2f2344b4c8fbaf2c3a32 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 17:24:25 -0700 Subject: [PATCH 1/2] Show older stream VODs on channel pages --- src/app/[slug]/page.tsx | 9 ++++++-- src/app/_components/vods.tsx | 43 ++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 4d00dee..661d6e2 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -9,14 +9,19 @@ 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]); return (
}> - +
); diff --git a/src/app/_components/vods.tsx b/src/app/_components/vods.tsx index 2877a85..a384e7f 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,20 @@ 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 ; + + const videoParams = new URLSearchParams({ + user_id: twitchUserId, + type: "archive", + first: "24", + }); + if (props.after) videoParams.set("after", props.after); // fetch vods from twitch api @@ -116,11 +124,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 +136,9 @@ export const VODs = async (props: { username: string }) => { throw new Error(vodResult.reason.message); } - if (streamResult.status === "rejected") { - throw new Error(streamResult.reason.message); - } - 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,6 +146,11 @@ export const VODs = async (props: { username: string }) => { const filteredVodData = vodData.filter( ({ stream_id }) => !streamMap.has(stream_id) ); + const nextCursor = vodResult.value.pagination?.cursor; + const channelPath = `/${encodeURIComponent(props.username)}`; + const olderVodsPath = nextCursor + ? `${channelPath}?${new URLSearchParams({ after: nextCursor })}` + : null; return (
@@ -182,6 +188,19 @@ export const VODs = async (props: { username: string }) => { )) )} + {(olderVodsPath || props.after) && ( + + )}
); }; From ad119bdbab35b22039e9c0c65c0a2797a625b013 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:11:40 -0700 Subject: [PATCH 2/2] Load 100 VODs per page and fix empty later pages and scroll on page change Twitch keeps past broadcasts for 60 days at most, so the max page size of 100 fits almost every channel on one page. An empty later page now says "No older VODs" instead of the "no VODs for this channel" empty state. Changing pages now resets the scroll and shows the loading state, because Next reuses the page div when only the search params change. A failed live-stream check is now logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/[slug]/page.tsx | 13 ++++++++----- src/app/_components/vods.tsx | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 661d6e2..ea78ec6 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -15,13 +15,16 @@ export default async function Home({ searchParams: Promise<{ after?: string | string[] }>; }) { 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 a384e7f..22b97d8 100644 --- a/src/app/_components/vods.tsx +++ b/src/app/_components/vods.tsx @@ -111,10 +111,12 @@ export const VODs = async (props: { username: string; after?: string }) => { 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: "24", + first: "100", }); if (props.after) videoParams.set("after", props.after); @@ -136,6 +138,12 @@ export const VODs = async (props: { username: string; after?: 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") { + console.error("Live stream check failed:", streamResult.reason); + } + const vodData = vodResult.value.data; const streamData = streamResult.status === "fulfilled" ? streamResult.value.data : []; @@ -146,7 +154,10 @@ export const VODs = async (props: { username: string; after?: string }) => { const filteredVodData = vodData.filter( ({ stream_id }) => !streamMap.has(stream_id) ); - const nextCursor = vodResult.value.pagination?.cursor; + // 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 })}` @@ -155,7 +166,11 @@ export const VODs = async (props: { username: string; after?: string }) => { return (
{filteredVodData.length === 0 ? ( - + props.after ? ( +

No older VODs.

+ ) : ( + + ) ) : ( filteredVodData.map((vod) => (