Skip to content

feat: video playlist page design implementation#3135

Merged
ahtesham-quraish merged 11 commits intomainfrom
ahtesham/video-page-design
Apr 13, 2026
Merged

feat: video playlist page design implementation#3135
ahtesham-quraish merged 11 commits intomainfrom
ahtesham/video-page-design

Conversation

@ahtesham-quraish
Copy link
Copy Markdown
Contributor

@ahtesham-quraish ahtesham-quraish commented Mar 31, 2026

What are the relevant tickets?

https://github.com/mitodl/hq/issues/10734

Description (What does it do?)

MIT Learn already has a video playlist object, but it does not exist as a meaningful user-facing thing.

Right now playlists live as backend metadata. Users mostly land on individual videos. There is no real page for the playlist itself, and no clear way to see or navigate the grouping. Even when the content is clearly meant to belong together, it shows up as isolated videos.

Screenshots (if appropriate):

Desktop:
image (2)

Mobile:
image

How can this be tested?

if you dont have playlist data locally then in frontend env file you should add the following variable NEXT_PUBLIC_MITOL_API_BASE_URL="https://api.rc.learn.mit.edu" it will connect with RC learn backend
then we need to enable the flag which is video-playlist-page and then visit the following url http://open.odl.local:8062/playlist/6831

Additional Context

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 31, 2026

OpenAPI Changes

27 changes: 0 error, 22 warning, 5 info

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 3bdf681 to 1905541 Compare March 31, 2026 11:15
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch 2 times, most recently from 8649d77 to 39e00af Compare March 31, 2026 11:43
@ahtesham-quraish ahtesham-quraish marked this pull request as ready for review March 31, 2026 11:44
Copilot AI review requested due to automatic review settings March 31, 2026 11:44
@ahtesham-quraish ahtesham-quraish marked this pull request as draft March 31, 2026 11:44
Comment thread frontends/main/src/app-pages/VideoPage/VideoPage.tsx
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an initial user-facing “video playlist” page in the Next.js App Router, backed by new API client/query support for video playlists and gated behind a PostHog feature flag.

Changes:

  • Added a new /playlist/[id] route that server-prefetches playlist + item data and hydrates a new client-side playlist page.
  • Implemented new playlist UI components (header, featured video, collection list, modal player) including a video.js-based player with YouTube support.
  • Extended the API client + react-query hooks to retrieve video playlist details, and added the new video-playlist-page feature flag.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
yarn.lock Locks new dependencies for video.js / YouTube tech and related transitive packages.
frontends/main/package.json Adds video.js and videojs-youtube dependencies for the new player.
frontends/main/src/common/feature_flags.ts Adds VideoPlaylistPage feature flag key.
frontends/main/src/app/playlist/[id]/page.tsx New App Router route for playlist detail with React Query SSR hydration.
frontends/main/src/app-pages/VideoPage/VideoPage.tsx Client playlist page: feature flag gating, queries, featured video selection, modal state.
frontends/main/src/app-pages/VideoPage/VideoPageHeader.tsx Playlist header UI (title/description).
frontends/main/src/app-pages/VideoPage/FeaturedVideo.tsx Featured video hero section with play interaction.
frontends/main/src/app-pages/VideoPage/VideoCollection.tsx Renders the remaining playlist videos as a list of cards.
frontends/main/src/app-pages/VideoPage/VideoCard.tsx Video list item card with thumbnail, duration, and play affordance.
frontends/main/src/app-pages/VideoPage/VideoPlayerModal.tsx Modal wrapper for playback and “no playable source” fallback.
frontends/main/src/app-pages/VideoPage/VideoJsPlayer.tsx Video.js wrapper + source resolution for HLS/DASH/MP4/YouTube.
frontends/api/src/clients.ts Adds and exports videoPlaylistsApi client.
frontends/api/src/hooks/learningResources/queries.ts Adds videoPlaylistQueries.detail and query keys.
frontends/api/src/hooks/learningResources/index.ts Re-exports videoPlaylistQueries for consumers.

Comment thread frontends/main/src/app/playlist/[id]/page.tsx Outdated
Comment on lines +25 to +33
await Promise.all([
queryClient.prefetchQuery(videoPlaylistQueries.detail(playlistId)),
queryClient.prefetchQuery(
learningResourceQueries.items(playlistId, {
learning_resource_id: playlistId,
}),
),
])

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page prefetches playlist data with prefetchQuery, but doesn't ensure a missing playlist returns a real 404 response. Consider using queryClient.fetchQueryOr404(videoPlaylistQueries.detail(playlistId)) for the playlist detail (similar to src/app/news/[slugOrId]/page.tsx) so a 404 from the API becomes a Next.js 404 page on initial load.

Suggested change
await Promise.all([
queryClient.prefetchQuery(videoPlaylistQueries.detail(playlistId)),
queryClient.prefetchQuery(
learningResourceQueries.items(playlistId, {
learning_resource_id: playlistId,
}),
),
])
// Ensure a missing playlist results in a real Next.js 404 on initial load
await queryClient.fetchQueryOr404(videoPlaylistQueries.detail(playlistId))
await queryClient.prefetchQuery(
learningResourceQueries.items(playlistId, {
learning_resource_id: playlistId,
}),
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about it need to think more on it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good idea

Comment thread frontends/main/src/app/playlist/[id]/page.tsx Outdated
notFound()
}
const isLoading = playlistLoading || itemsLoading
const videos = (items ?? []) as VideoResource[]
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

items is cast to VideoResource[] without checking the discriminator (e.g. resource_type). Since learningResourcesItemsList returns a generic learning resource union, this can break at runtime if a playlist contains a non-video item (or the API evolves). Prefer narrowing/filtering by resource_type === "video" (or using a video-playlist-specific endpoint/type) instead of a blanket cast.

Suggested change
const videos = (items ?? []) as VideoResource[]
const videos = (items ?? []).filter(
(item: any): item is VideoResource => item?.resource_type === "video",
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved

Comment thread frontends/main/src/app-pages/VideoPage/FeaturedVideo.tsx Outdated
Comment on lines +84 to +101
return (
<ModalBackdrop
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<ModalContent>
<ModalHeader>
<ModalTitle>{video.title}</ModalTitle>
<ModalCloseButton
type="button"
aria-label="Close video"
onClick={onClose}
>
<RiCloseLine />
</ModalCloseButton>
</ModalHeader>
<PlayerWrapper>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal markup is missing common dialog accessibility attributes/behavior (e.g., role="dialog", aria-modal="true", and an aria-labelledby pointing at the title). Also consider handling Escape to close and trapping/restoring focus while the modal is open to keep keyboard navigation usable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

Comment thread frontends/main/package.json Outdated
"sharp": "0.34.4",
"slick-carousel": "^1.8.1",
"tiny-invariant": "^1.3.3",
"video.js": "8.23.7",
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinning video.js to 8.23.7 causes two versions to be installed (8.23.7 and 8.23.8 via videojs-youtube), increasing bundle size. Consider aligning on a single version (e.g., bump to 8.23.8 or use a compatible range) and/or adding a Yarn resolutions entry to dedupe.

Suggested change
"video.js": "8.23.7",
"video.js": "^8.23.8",

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

Comment on lines +28 to +37
const VideoPage: React.FC<VideoPageProps> = ({ playlistId }) => {
const [activeVideo, setActiveVideo] = useState<VideoResource | null>(null)

const openVideo = useCallback((resource: VideoResource) => {
setActiveVideo(resource)
}, [])

const closeVideo = useCallback(() => {
setActiveVideo(null)
}, [])
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new page introduces several behaviors that would benefit from automated tests (feature-flag gating without 404 flicker, rendering playlist header/content based on fetched data, and opening/closing the player modal). There are existing page/component tests under src/app-pages/ProductPages/*.test.tsx; adding similar tests for VideoPage would help prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved

Comment on lines +5 to +6
import VideoJsPlayer, { resolveVideoSources } from "./VideoJsPlayer"

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

video.js is a fairly large dependency; importing VideoJsPlayer eagerly means the playlist page bundle will include video.js even if the user never opens the modal. Consider lazy-loading the player with next/dynamic (or React.lazy) when the modal opens to keep initial JS/CSS payload smaller (similar to dynamic imports in app-pages/ChannelPage/ChannelPage.tsx).

Suggested change
import VideoJsPlayer, { resolveVideoSources } from "./VideoJsPlayer"
import dynamic from "next/dynamic"
import { resolveVideoSources } from "./VideoJsPlayer"
const VideoJsPlayer = dynamic(() => import("./VideoJsPlayer"), {
ssr: false,
loading: () => null,
})

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

@ahtesham-quraish ahtesham-quraish marked this pull request as ready for review March 31, 2026 12:40
@ahtesham-quraish ahtesham-quraish added the Needs Review An open Pull Request that is ready for review label Mar 31, 2026
@ahtesham-quraish ahtesham-quraish changed the title WIP feat: video playlist page design implementation Mar 31, 2026
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 39e00af to e2114b3 Compare April 1, 2026 08:32
Comment thread frontends/main/src/app-pages/VideoPage/FeaturedVideo.tsx Outdated
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from e2114b3 to f2d37ca Compare April 1, 2026 11:09
Comment thread frontends/main/src/app-pages/VideoPage/VideoDetailPage.tsx Outdated
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from f2d37ca to d25941c Compare April 1, 2026 11:13
Comment thread frontends/main/src/app-pages/VideoPage/VideoDetailPage.tsx Outdated
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from d25941c to a6282ce Compare April 1, 2026 11:27
Comment thread frontends/main/src/app-pages/VideoPage/VideoPage.tsx Outdated
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 2ff7f4c to 5194a58 Compare April 6, 2026 12:47
Comment thread frontends/main/src/app-pages/VideoPage/VideoDetailPage.tsx Outdated
@ahtesham-quraish ahtesham-quraish removed the Needs Review An open Pull Request that is ready for review label Apr 7, 2026
@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 4cc560c to f4d366b Compare April 8, 2026 11:51
Copy link
Copy Markdown
Contributor

@zamanafzal zamanafzal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left a few comments and the UI doesn't look good on two screens.

Here are the screenshots of the screens.

Image Image

variant="light"
ancestors={[
{ href: "/", label: "Home" },
{ href: "/videos", label: "Videos" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breadcrumb points to /videos, but there is no matching route in the app router. Clicking "Videos" leads to a dead page. Can we update this to a valid destination (or remove it) to avoid broken navigation?

Copy link
Copy Markdown
Contributor Author

@ahtesham-quraish ahtesham-quraish Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zamanafzal Ferdi has provided me the design in which video is part of breadcrumb. I already asked him about it but he did not reply me let me ask him again where it should point to.

<Container>
<StyledBreadcrumbs
variant="light"
ancestors={[{ href: `/playlist/${playlistId}`, label: "Playlist" }]}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If playlistId is missing from search params, this renders /playlist/null in the breadcrumb link. Repro: open /playlist/detail/ without ?playlist=. Suggest guarding this link when playlistId is null or falling back to a safe route.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently I have removed the detail page from this PR and moved it here #3173 so when we click on the video then for now we are showing 404 page which obviously will available in new PR

<VideoTitle>{video?.title}</VideoTitle>
)}

{/* Description */}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think we should remove these comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the videoDetailPage which will be available in new PR #3173

},
[theme.breakpoints.down("sm")]: {
padding: "16px 0 0",
letteSpacing: "inherit",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: letteSpacing should be letterSpacing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

<Section>
<VideoContainer>
<FeaturedGrid>
<ImageWrapper onClick={() => onPlay(video)}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we switch to semantic button/link behaviour (or add role/tabIndex + Enter/Space handlers) for accessibility parity?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantic elements would be great!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

</ImageWrapper>

<TextSide>
<FeaturedTitle onClick={() => onPlay(video)}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

import videojs from "video.js"
import Player from "video.js/dist/types/player"
import "video.js/dist/video-js.css"
// Register YouTube tech so video.js can play youtube:// sources
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is a bit misleading. Code passes normal YouTube URLs (e.g. https://youtube.com/...), not necessarily youtube://

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed this comment

// Helper: extract instructor info
// ---------------------------------------------------------------------------

function getInstructorInfo(video: VideoResource): {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about renaming it to getPrimaryInstructorInfo for better clarity?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the videoDetailPage from this PR

}

const FeaturedVideo: React.FC<FeaturedVideoProps> = ({ videos, onPlay }) => {
const [currentIndex] = useState(0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: currentIndex is always 0, so useState here adds cognitive overhead. Consider replacing with const video = videos[0] for clearer intent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request: similar to Zaman's comment, change the videos prop to video. We are only using one. No need to pass the whole array. Confusing for "FeaturedVideo" to take an array, IMO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be a great feature. I left some comments on naming, existing utility funcs that might help, queries, and questions about files that seem to be unused.

const { data: playlistData, isLoading: playlistLoading } = useQuery(
videoPlaylistQueries.detail(playlistId),
)
const playlist = playlistData as VideoPlaylistResource | undefined
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request: Let's remove this. The type is already VideoPlaylistResource | undefined, the cast does nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +74 to +76
const { data: similarData, isLoading: similarLoading } = useQuery(
learningResourceQueries.vectorSimilar(playlistId),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that:

  • below you did otherCollections.slice(0,6)
  • and there is filtering on the results of vectorSimilar

This has the downside that you're getting an unpredictable number of results. (E.g., if vectorSimilar returns mostly courses or podcasts or whatever, they are filtered out by your filter)

It would be better to let the APIs handle the trimming and filtering. You can do that by changing the vectorSimilar query...for some reason right now it only supports a single id param, but the API supports much more.

Suggest changing to:

--- a/frontends/api/src/hooks/learningResources/queries.ts
+++ b/frontends/api/src/hooks/learningResources/queries.ts
@@ -20,6 +20,7 @@ import type {
   LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
   LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest,
   VideoPlaylistResource,
+  LearningResourcesApiLearningResourcesVectorSimilarListRequest,
 } from "../../generated/v1"
 import type { VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest as VectorLearningResourcesSearchRetrieveRequest } from "../../generated/v0"
 import { queryOptions } from "@tanstack/react-query"
@@ -42,10 +43,9 @@ const learningResourceKeys = {
   detailsRoot: () => [...learningResourceKeys.root, "detail"],
   detail: (id: number) => [...learningResourceKeys.detailsRoot(), id],
   similar: (id: number) => [...learningResourceKeys.detail(id), "similar"],
-  vectorSimilar: (id: number) => [
-    ...learningResourceKeys.detail(id),
-    "vector_similar",
-  ],
+  vectorSimilar: (
+    params: LearningResourcesApiLearningResourcesVectorSimilarListRequest,
+  ) => [...learningResourceKeys.detail(params.id), "vector_similar", params],
   itemsRoot: (id: number) => [...learningResourceKeys.detail(id), "items"],
   items: (id: number, params: ItemsListRequest) => [
     ...learningResourceKeys.itemsRoot(id),
@@ -128,12 +128,14 @@ const learningResourceQueries = {
           .learningResourcesSimilarList({ id })
           .then((res) => res.data),
     }),
-  vectorSimilar: (id: number) =>
+  vectorSimilar: (
+    params: LearningResourcesApiLearningResourcesVectorSimilarListRequest,
+  ) =>
     queryOptions({
-      queryKey: learningResourceKeys.vectorSimilar(id),
+      queryKey: learningResourceKeys.vectorSimilar(params),
       queryFn: () =>
         learningResourcesApi
-          .learningResourcesVectorSimilarList({ id })
+          .learningResourcesVectorSimilarList(params)
           .then((res) => res.data),
     }),
   list: (params: LearningResourcesListRequest) =>

then using

  const { data: similarData, isLoading: similarLoading } = useQuery(
    learningResourceQueries.vectorSimilar({
      id: playlistId,
      resource_type: [ResourceTypeEnum.VideoPlaylist],
      limit: 6,
    }),
  )

to get exactly the data you want.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please check I have changed the implementation and let me know in case i have done wrong

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Good: Well, these changes look great. Exactly what I was hoping for.

  • Disappointing: Thank you for making this change. Unfortunately, it seems that the OpenAPI spec is wrong, and the resource_type filter doesn't actually work. I've opened https://github.com/mitodl/hq/issues/10896 about this.

My recommendation for now: would be to leave the change as-is for the moment BUT restore a resource_type="video_playlist" filter to prevent crashing. (⚠️ ⚠️ it's currently crashing for some playlists for me, like playlist 6382 from prod):

  const { data: similarData, isLoading: similarLoading } = useQuery({
    ...learningResourceQueries.vectorSimilar({
      id: playlistId,
      resource_type: [ResourceTypeEnum.VideoPlaylist],
      limit: 6,
    }),
    select: (resources) =>
      resources.filter(
        (r) => r.resource_type === ResourceTypeEnum.VideoPlaylist,
      ),
  })

A selector like this wouldn't be bad to leave in even once the backend filtering is in place. It should be a no-op, but would also give you the correct TS type without typecasting.

Then we can coordinate on Monday about supporting resource_type filters on the similarity endpoint.

}

const FeaturedVideo: React.FC<FeaturedVideoProps> = ({ videos, onPlay }) => {
const [currentIndex] = useState(0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request: similar to Zaman's comment, change the videos prop to video. We are only using one. No need to pass the whole array. Confusing for "FeaturedVideo" to take an array, IMO.

Comment on lines +48 to +51
<PageDescription>
{playlist?.description ??
"Conversations with MIT faculty on the future of science, technology, and society."}
</PageDescription>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this resolved? I still see the hard-coded description. (Maybe forgot a push?)


const otherCollections = getResults(similarData).filter(
(resource): resource is VideoPlaylistResource =>
resource.resource_type === ResourceTypeEnum.VideoPlaylist &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://github.com/mitodl/mit-learn/pull/3135/changes#r3053866744 ... would be better to filter at API level.

minHeight: "100vh",
}))

const getResults = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only current usage of getResults is getResults(similarData), and similarData is already a plain array, LearningResource[]. I'd suggest we remove this for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the code can you please check

Comment on lines +125 to +128
const truncatedTitle =
video.title.length > FEATURED_TITLE_MAX_CHARS
? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...`
: video.title
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to use CSS line clamping (you do that elsewhere in this PR). We actually have a helper defined for this,

const truncateText = (lines?: number | "none") =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ferdi wants to have this title truncate on the basis of characters, the approach you are talking is based on lines that is why I have created this function to show only 30 characters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See

OK. We really shouldn't do it this way:

  • CSS is more robust visually, see screenshot below ... awkward partially filled line.
  • True truncation (not CSS based) has accessibility implications, e.g., screenreaders now get incomplete headings.
Screenshot 2026-04-10 at 5 25 26 PM

I posted something in the slack thread about it.

Note

It also seems to me that slack thread is mostly about the description not the title?

<Section>
<VideoContainer>
<FeaturedGrid>
<ImageWrapper onClick={() => onPlay(video)}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantic elements would be great!

lineHeight: "16px" /* 133.333% */,
})

const parseDurationToHoursAndMinutes = (duration?: string): string | null => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a utility function formatDurationClockTime that is used to do this elsewhere.

Could we use that instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created new util which will display the date time in human form like in the design
image

Comment on lines +146 to +149
const OtherCollections: React.FC<OtherCollectionsProps> = ({
collections,
isLoading,
}) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the Figma, I'm guessing the component naming was chosen to match Figma. In theory I think that is a good idea, but it kind of just moves the burden of picking good names from us to Bilal. And naming is hard.

Looking at the two playlist pages (Series and Collection) I see they both use this component, and this component displays both types of playlists:

Image

IMO, "RelatedPlaylist" would be a better name for this component. cc @mbilalmughal in case you want to update yours. (Doesn't need to be that name in particular, but it seems this isn't particular to collections).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from cb62412 to f851da2 Compare April 9, 2026 09:57
@ahtesham-quraish ahtesham-quraish added Needs Review An open Pull Request that is ready for review labels Apr 9, 2026
Copy link
Copy Markdown
Contributor

@zamanafzal zamanafzal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left some minor suggestions.

const videoRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<Player | null>(null)

useEffect(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect has unstable dependencies that could cause the video player to be recreated unnecessarily, leading to memory leaks and performance issues.

Suggestion:

useEffect(() => {
  // Only initialise once
  if (playerRef.current) return

  const videoEl = document.createElement("video-js")
  videoEl.classList.add("vjs-big-play-centered")
  videoEl.style.width = "100%"
  videoEl.style.height = "100%"
  videoRef.current!.appendChild(videoEl)

  const player = videojs(
    videoEl,
    {
      autoplay,
      controls,
      fluid,
      fill: !fluid,
      responsive: true,
      poster: poster ?? undefined,
      sources,
      techOrder: ["youtube", "html5"],
    },
    function (this: Player) {
      onReady?.(this)
    },
  )

  playerRef.current = player
}, []) // Remove all dependencies to prevent re-initialization

// Handle updates in separate effects
useEffect(() => {
  const player = playerRef.current
  if (!player) return
  player.src(sources)
}, [sources])

useEffect(() => {
  const player = playerRef.current
  if (!player || !poster) return
  player.poster(poster)
}, [poster])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

: null
const description = video.description ?? ""
const FEATURED_TITLE_MAX_CHARS = 30
const truncatedTitle =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The title truncation is calculated on every render. How about memoized this for better performance?

const FEATURED_TITLE_MAX_CHARS = 30
const truncatedTitle = useMemo(() => 
  video.title.length > FEATURED_TITLE_MAX_CHARS
    ? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...`
    : video.title
, [video.title])

)
const flagsLoaded = useFeatureFlagsLoaded()

const { data: playlist, isLoading: playlistLoading } = useQuery(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to add error handling for API failures.

Suggestion:

const { data: playlist, isLoading: playlistLoading, error: playlistError } = useQuery(
  videoPlaylistQueries.detail(playlistId),
)

const { data: items, isLoading: itemsLoading, error: itemsError } = useQuery(
  learningResourceQueries.items(playlistId, {
    learning_resource_id: playlistId,
  }),
)

if (playlistError || itemsError) {
  return (
    <Page>
      <VideoContainer>
        <Typography variant="h4">Failed to load playlist</Typography>
        <Typography>Please try refreshing the page.</Typography>
      </VideoContainer>
    </Page>
  )
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChristopherChudzicki what do you think about it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably best to handle this on the nextjs server for proper 404s, etc.

https://github.com/mitodl/mit-learn/pull/3135/changes#r3015405136

@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from e122c02 to ad210f9 Compare April 10, 2026 12:34
@zamanafzal
Copy link
Copy Markdown
Contributor

@ahtesham-quraish On a 1024 screen size, the content(Title, BreadCrumbs) is not properly responsive.

Screenshot 2026-04-10 at 5 07 13 PM

Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good.. Added a few comemnts. Two things I'd highlight especially:

  1. Some files seem unused... VideoPlayerModal, videoSources, VideoJsPlayer. Let's remove these until they are actually used. (You could keep them on a different branch if you want to save them.)
  2. See https://github.com/mitodl/mit-learn/pull/3135/changes#r3066507911 ... important comment there, unfortunately. Page is crashing right now sometimes 😦

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahtesham-quraish This is a video playlist collection page, right? What do you think about a url like /video-collections/:id ? Ideally we'd use a slug, but I don't think we have the data in place for that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChristopherChudzicki i dont mind to change it but i think it should be more general because we will have video series as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahtesham-quraish But those will be a different URL, right?

From the issue:

This distinction carries through to the video pages.

On a series video page... On a collection video page...

...Do not collapse this into one template just because it is easier to implement. Components can be shared. Behavior should not.

Comment thread frontends/main/src/app/video-collection/[id]/page.tsx
Comment on lines +74 to +76
const { data: similarData, isLoading: similarLoading } = useQuery(
learningResourceQueries.vectorSimilar(playlistId),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Good: Well, these changes look great. Exactly what I was hoping for.

  • Disappointing: Thank you for making this change. Unfortunately, it seems that the OpenAPI spec is wrong, and the resource_type filter doesn't actually work. I've opened https://github.com/mitodl/hq/issues/10896 about this.

My recommendation for now: would be to leave the change as-is for the moment BUT restore a resource_type="video_playlist" filter to prevent crashing. (⚠️ ⚠️ it's currently crashing for some playlists for me, like playlist 6382 from prod):

  const { data: similarData, isLoading: similarLoading } = useQuery({
    ...learningResourceQueries.vectorSimilar({
      id: playlistId,
      resource_type: [ResourceTypeEnum.VideoPlaylist],
      limit: 6,
    }),
    select: (resources) =>
      resources.filter(
        (r) => r.resource_type === ResourceTypeEnum.VideoPlaylist,
      ),
  })

A selector like this wouldn't be bad to leave in even once the backend filtering is in place. It should be a no-op, but would also give you the correct TS type without typecasting.

Then we can coordinate on Monday about supporting resource_type filters on the similarity endpoint.

Comment thread frontends/ol-utilities/src/date/utils.ts
: video?.title,
[video?.title],
)
if (!video) return null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prop is required, so no need for this early exit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +125 to +128
const truncatedTitle =
video.title.length > FEATURED_TITLE_MAX_CHARS
? `${video.title.slice(0, FEATURED_TITLE_MAX_CHARS).trimEnd()}...`
: video.title
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See

OK. We really shouldn't do it this way:

  • CSS is more robust visually, see screenshot below ... awkward partially filled line.
  • True truncation (not CSS based) has accessibility implications, e.g., screenreaders now get incomplete headings.
Screenshot 2026-04-10 at 5 25 26 PM

I posted something in the slack thread about it.

Note

It also seems to me that slack thread is mostly about the description not the title?

Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes generally look good but I'm still seeing it crash. See https://github.com/mitodl/mit-learn/pull/3135/changes#r3073950679

In addition to that, I think a collection-specific URL would be good, as would the 404 handling.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahtesham-quraish But those will be a different URL, right?

From the issue:

This distinction carries through to the video pages.

On a series video page... On a collection video page...

...Do not collapse this into one template just because it is easier to implement. Components can be shared. Behavior should not.

)
const flagsLoaded = useFeatureFlagsLoaded()

const { data: playlist, isLoading: playlistLoading } = useQuery(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably best to handle this on the nextjs server for proper 404s, etc.

https://github.com/mitodl/mit-learn/pull/3135/changes#r3015405136

isLoading: boolean
}

const RelatedPlaylist: React.FC<OtherCollectionsProps> = ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should make OtherCollectionsProps match the component name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +65 to +67
const otherCollections = ((similarData ?? []) as VideoPlaylistResource[])
.filter((resource) => resource.id !== playlistId)
.slice(0, 6)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently this list can include things other than video playlists, and that causes the page to crash.

For example, using production base URL, it crashes for pretty consistently for http://open.odl.local:8062/playlist/6382

I'd suggest doing the resource_type filtering at the query call, as mentioned in https://github.com/mitodl/mit-learn/pull/3135/changes#r3066507911

That will make sure everywhere is getting playlists only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest doing the filtering when you make the query:

  const { data: similarData, isLoading: similarLoading } = useQuery({
    ...learningResourceQueries.vectorSimilar(playlistId),
    select: (data) =>
      data.filter(
        (resource) => resource.resource_type === ResourceTypeEnum.VideoPlaylist,
      ),
  })

This:

  1. Filters on the frontend—we should filter on backend once supported
  2. Refines the type.

(1) will be irrelevant once we improve the backend, (2) is still useful, though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +65 to +67
if (isError) {
return notFound()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be addressed in a followup PR, but this is best to do on the server side (See copilot's suggestion, #3135 (comment)).

Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 one suggestion for followup PR 62d2fdf#r3074389926

@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 62d2fdf to 7f2c916 Compare April 13, 2026 16:32
Copy link
Copy Markdown
Contributor

@zamanafzal zamanafzal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ahtesham-quraish ahtesham-quraish force-pushed the ahtesham/video-page-design branch from 7f2c916 to 8e0dcf3 Compare April 13, 2026 16:56
Comment on lines +33 to +34
const getVideoHref = (resource: VideoResource) =>
`/playlist/detail/${resource.id}?playlist=${playlistId}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Links to video detail pages point to a non-existent /playlist/detail/[id] route, which will cause a 404 error when users click them.
Severity: HIGH

Suggested Fix

Update the link generation logic to use the correct URL pattern for video detail pages. The investigation did not identify the correct route, but it should be changed from /playlist/detail/[id] to whichever route is configured to display individual video details.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx#L33-L34

Potential issue: The code in `VideoPlaylistCollectionPage.tsx` generates links for
featured and collection videos that point to the URL pattern `/playlist/detail/[id]`.
However, the application does not have a corresponding route handler for this path.
There is no `page.tsx` file under `app/playlist/detail/[id]` and no rewrites in
`next.config.js` to handle this URL. As a result, when a user clicks on a video from the
new video playlist page, they will be directed to a non-existent page, resulting in a
404 error.

@ahtesham-quraish ahtesham-quraish merged commit e9cd8b5 into main Apr 13, 2026
14 checks passed
@ahtesham-quraish ahtesham-quraish deleted the ahtesham/video-page-design branch April 13, 2026 17:05
This was referenced Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants