setPlayingEpisode(null)}
+ onPlayStateChange={setIsAudioPlaying}
/>
)}
>
diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx
new file mode 100644
index 0000000000..0922f230f9
--- /dev/null
+++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx
@@ -0,0 +1,308 @@
+import React from "react"
+import { factories, setMockResponse, urls } from "api/test-utils"
+import { ResourceTypeEnum } from "api/v1"
+import type { LearningResource, PodcastEpisodeResource } from "api/v1"
+import { renderWithProviders, screen, user } from "@/test-utils"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
+import { PodcastEpisodeDetailPage } from "./PodcastEpisodeDetailPage"
+
+jest.mock("posthog-js/react")
+jest.mock("@/common/useFeatureFlagsLoaded")
+
+const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
+const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded)
+
+jest.mock("./PodcastPlayer", () => ({
+ __esModule: true,
+ PLAYER_HEIGHT: { desktop: 104, mobile: 220 },
+ default: jest.fn(
+ ({ track }: { track: { title: string; podcastName: string } }) => (
+
+ {track.title}
+ {track.podcastName}
+
+ ),
+ ),
+}))
+
+const EPISODES_PAGE_SIZE = 5
+
+const makeItemsResponse = (episodes: LearningResource[]) => ({
+ count: episodes.length,
+ next: null,
+ previous: null,
+ results: episodes.map((resource, i) => ({
+ id: i + 1,
+ child: resource.id,
+ parent: 0,
+ position: i + 1,
+ resource,
+ })),
+})
+
+const makePodcastEpisode = (
+ overrides: Partial = {},
+): PodcastEpisodeResource =>
+ factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.PodcastEpisode,
+ ...overrides,
+ }) as PodcastEpisodeResource
+
+const makePodcast = (
+ overrides: Partial = {},
+): LearningResource =>
+ factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Podcast,
+ ...overrides,
+ })
+
+type SetupOptions = {
+ episodeOverrides?: Partial
+ podcastOverrides?: Partial
+ moreEpisodes?: LearningResource[]
+}
+
+const setupApis = ({
+ episodeOverrides = {},
+ podcastOverrides = {},
+ moreEpisodes,
+}: SetupOptions = {}) => {
+ const podcast = makePodcast(podcastOverrides)
+ const episode = makePodcastEpisode(episodeOverrides)
+
+ setMockResponse.get(
+ urls.learningResources.details({ id: episode.id }),
+ episode,
+ )
+ setMockResponse.get(
+ urls.learningResources.details({ id: podcast.id }),
+ podcast,
+ )
+
+ const episodeList = moreEpisodes ?? [episode]
+ setMockResponse.get(
+ `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
+ makeItemsResponse(episodeList),
+ )
+
+ return { episode, podcast }
+}
+
+describe("PodcastEpisodeDetailPage", () => {
+ beforeEach(() => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(true)
+ mockedUseFeatureFlagsLoaded.mockReturnValue(true)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test("renders episode title and podcast name on the page", async () => {
+ const { episode, podcast } = setupApis({ moreEpisodes: [] })
+
+ renderWithProviders(
+ ,
+ )
+
+ // Episode title appears in the breadcrumb current item and as the styled heading
+ const episodeTitles = await screen.findAllByText(episode.title!)
+ expect(episodeTitles.length).toBeGreaterThanOrEqual(1)
+
+ // Podcast title appears as the EpisodeLabel and in the breadcrumb link
+ const podcastTitles = screen.getAllByText(podcast.title!)
+ expect(podcastTitles.length).toBeGreaterThanOrEqual(2)
+ })
+
+ test("renders 'More from ' section header", async () => {
+ const moreEpisodes = [makePodcastEpisode(), makePodcastEpisode()]
+ const { episode, podcast } = setupApis({ moreEpisodes })
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(new RegExp(`More from ${podcast.title}`, "i"))
+ })
+
+ test("renders 'More from' episode list items", async () => {
+ const moreEpisodes = [makePodcastEpisode(), makePodcastEpisode()]
+ const { episode, podcast } = setupApis({ moreEpisodes })
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(moreEpisodes[0].title!)
+ expect(screen.getByText(moreEpisodes[1].title!)).toBeInTheDocument()
+ })
+
+ test("play button is present and enabled when episode has an audio URL", async () => {
+ const episode = makePodcastEpisode()
+ // Ensure audio_url is set (factories should set it, but be explicit)
+ episode.podcast_episode.audio_url = "https://example.com/ep.mp3"
+ const podcast = makePodcast()
+
+ setMockResponse.get(
+ urls.learningResources.details({ id: episode.id }),
+ episode,
+ )
+ setMockResponse.get(
+ urls.learningResources.details({ id: podcast.id }),
+ podcast,
+ )
+ setMockResponse.get(
+ `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
+ makeItemsResponse([episode]),
+ )
+
+ renderWithProviders(
+ ,
+ )
+
+ const playButton = await screen.findByRole("button", {
+ name: /play episode/i,
+ })
+ expect(playButton).not.toBeDisabled()
+ })
+
+ test("play button is disabled when episode has no audio source", async () => {
+ const episode = makePodcastEpisode()
+ episode.podcast_episode.audio_url = ""
+ episode.podcast_episode.episode_link = ""
+ const podcast = makePodcast()
+
+ setMockResponse.get(
+ urls.learningResources.details({ id: episode.id }),
+ episode,
+ )
+ setMockResponse.get(
+ urls.learningResources.details({ id: podcast.id }),
+ podcast,
+ )
+ setMockResponse.get(
+ `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
+ makeItemsResponse([episode]),
+ )
+
+ renderWithProviders(
+ ,
+ )
+
+ const playButton = await screen.findByRole("button", {
+ name: /play episode/i,
+ })
+ expect(playButton).toBeDisabled()
+ })
+
+ test("clicking play renders the PodcastPlayer with correct track data", async () => {
+ const episode = makePodcastEpisode()
+ episode.podcast_episode.audio_url = "https://example.com/ep.mp3"
+ const podcast = makePodcast()
+
+ setMockResponse.get(
+ urls.learningResources.details({ id: episode.id }),
+ episode,
+ )
+ setMockResponse.get(
+ urls.learningResources.details({ id: podcast.id }),
+ podcast,
+ )
+ setMockResponse.get(
+ `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
+ makeItemsResponse([episode]),
+ )
+
+ renderWithProviders(
+ ,
+ )
+
+ expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument()
+
+ const playButton = await screen.findByRole("button", {
+ name: /play episode/i,
+ })
+ await user.click(playButton)
+
+ expect(screen.getByTestId("podcast-player")).toBeInTheDocument()
+ expect(screen.getByTestId("player-track-title")).toHaveTextContent(
+ episode.title!,
+ )
+ expect(screen.getByTestId("player-podcast-name")).toHaveTextContent(
+ podcast.title!,
+ )
+ })
+
+ test("clicking play in 'More from' list renders the player for that episode", async () => {
+ const moreEpisode = makePodcastEpisode()
+ moreEpisode.podcast_episode.audio_url = "https://example.com/more.mp3"
+ const { episode, podcast } = setupApis({ moreEpisodes: [moreEpisode] })
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(moreEpisode.title!)
+ const playButtons = await screen.findAllByRole("button", {
+ name: new RegExp(`Play ${moreEpisode.title}`),
+ })
+ await user.click(playButtons[0])
+
+ expect(screen.getByTestId("podcast-player")).toBeInTheDocument()
+ expect(screen.getByTestId("player-track-title")).toHaveTextContent(
+ moreEpisode.title!,
+ )
+ })
+
+ test("returns null (not found) when feature flag is not loaded yet", () => {
+ mockedUseFeatureFlagsLoaded.mockReturnValue(false)
+ mockedUseFeatureFlagEnabled.mockReturnValue(false)
+
+ const episode = makePodcastEpisode()
+ const podcast = makePodcast()
+
+ setMockResponse.get(
+ urls.learningResources.details({ id: episode.id }),
+ episode,
+ )
+ setMockResponse.get(
+ urls.learningResources.details({ id: podcast.id }),
+ podcast,
+ )
+ setMockResponse.get(
+ `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
+ makeItemsResponse([]),
+ )
+
+ const { view } = renderWithProviders(
+ ,
+ )
+
+ expect(view.container).toBeEmptyDOMElement()
+ })
+})
diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx
new file mode 100644
index 0000000000..36c9622a2f
--- /dev/null
+++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx
@@ -0,0 +1,392 @@
+"use client"
+
+import React, { useState, useEffect, useRef } from "react"
+import {
+ Breadcrumbs,
+ Typography,
+ Container,
+ styled,
+ useMediaQuery,
+} from "ol-components"
+import type { Theme } from "ol-components"
+import { Button } from "@mitodl/smoot-design"
+import { RiPlayFill, RiPauseFill } from "@remixicon/react"
+import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import { FeatureFlags } from "@/common/feature_flags"
+import PodcastPlayer, { PLAYER_HEIGHT } from "./PodcastPlayer"
+import type { PodcastTrack, PodcastPlayerHandle } from "./PodcastPlayer"
+import {
+ useLearningResourcesDetail,
+ useInfiniteLearningResourceItems,
+} from "api/hooks/learningResources"
+
+import { ResourceTypeEnum } from "api/v1"
+import type { LearningResource } from "api/v1"
+import moment from "moment"
+import { formatDate } from "ol-utilities"
+import { HOME, podcastPageView } from "@/common/urls"
+import DOMPurify from "isomorphic-dompurify"
+import { EpisodeItem } from "./PodcastDetailPage"
+import PodcastContainer from "./PodcastContainer"
+import { notFound } from "next/navigation"
+import Link from "next/link"
+
+/* ── Layout ── */
+
+const EpisodeContainer = styled(Container)(({ theme }) => ({
+ maxWidth: "624px !important",
+ padding: "0 !important",
+ [theme.breakpoints.down("sm")]: {
+ padding: "0 16px !important",
+ },
+}))
+
+const PageSection = styled.div(({ theme }) => ({
+ backgroundColor: theme.custom.colors.lightGray1,
+ minHeight: "100vh",
+}))
+
+const HeaderSection = styled.div(({ theme }) => ({
+ borderBottom: `1px solid ${theme.custom.colors.lightGray2}`,
+ marginBottom: "64px",
+ paddingBottom: "64px",
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "24px",
+ paddingBottom: "24px",
+ },
+}))
+
+const EpisodeLabel = styled(Link)(({ theme }) => ({
+ color: theme.custom.colors.darkRed,
+ textTransform: "uppercase" as const,
+ ...theme.typography.body2,
+ fontWeight: theme.typography.fontWeightBold,
+ marginBottom: "32px",
+ lineHeight: "150%" /* 21px */,
+ marginTop: "64px",
+ display: "block",
+ textDecoration: "none",
+ "&:hover": {
+ textDecoration: "underline",
+ },
+ [theme.breakpoints.down("sm")]: {
+ marginTop: "32px",
+ marginBottom: "8px",
+ },
+}))
+
+const EpisodeTitle = styled(Typography)(({ theme }) => ({
+ marginBottom: "32px",
+ display: "block",
+ [theme.breakpoints.down("sm")]: {
+ ...theme.typography.h2,
+ marginBottom: "18px",
+ },
+}))
+
+const MoreItemDescription = styled(Typography)(({ theme }) => ({
+ color: theme.custom.colors.black,
+ display: "block",
+ marginBottom: "24px",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: theme.typography.fontWeightBold,
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "24px",
+ marginTop: "64px",
+ },
+}))
+
+const MetaLine = styled(Typography)(({ theme }) => ({
+ color: theme.custom.colors.darkGray2,
+ marginBottom: "32px",
+ display: "block",
+ ...theme.typography.body1,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: "150%",
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "16px",
+ },
+}))
+
+const Topics = styled.span(({ theme }) => ({
+ color: theme.custom.colors.darkGray1,
+ ...theme.typography.body1,
+ lineHeight: "20px",
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "16px",
+ display: "block",
+ },
+}))
+
+const Description = styled(Typography)(({ theme }) => ({
+ color: theme.custom.colors.darkGray2,
+ display: "block",
+ marginBottom: "32px",
+ marginTop: "32px",
+ fontSize: "18px",
+ fontStyle: "normal",
+ lineHeight: "32px",
+ [theme.breakpoints.down("sm")]: {
+ ...theme.typography.body1,
+ lineHeight: "24px",
+ marginTop: "16px",
+ },
+}))
+
+const EpisodeList = styled.ul({
+ listStyle: "none",
+ margin: 0,
+ padding: 0,
+ display: "grid",
+ gridTemplateColumns: "1fr",
+})
+
+export const BreadcrumbBar = styled.div(({ theme }) => ({
+ padding: "20px 0 4px 0",
+ borderBottom: `2px solid ${theme.custom.colors.red}`,
+ backgroundColor: theme.custom.colors.white,
+ [theme.breakpoints.down("sm")]: {
+ padding: "16px 0 0 0",
+ },
+}))
+
+const ViewAllLink = styled.a(({ theme }) => ({
+ color: theme.custom.colors.darkRed,
+ ...theme.typography.body1,
+ fontWeight: theme.typography.fontWeightMedium,
+ lineHeight: "150%",
+ textDecoration: "none",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "4px",
+ marginTop: "40px",
+ marginBottom: "64px",
+ "&:hover": {
+ textDecoration: "underline",
+ },
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: "40px",
+ },
+}))
+
+const StyledButton = styled(Button)(({ theme }) => ({
+ marginBottom: "32px",
+ padding: "12px 24px 12px 20px",
+ minWidth: "175px",
+ ...theme.typography.body1,
+ [theme.breakpoints.down("sm")]: {
+ width: "100%",
+ marginBottom: "16px",
+ },
+}))
+
+/* ── Component ── */
+
+type PodcastEpisodeDetailPageProps = {
+ episodeId: string
+ podcastId: string | null
+}
+
+export const PodcastEpisodeDetailPage: React.FC<
+ PodcastEpisodeDetailPageProps
+> = ({ episodeId, podcastId }) => {
+ const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm"))
+ const [playingEpisode, setPlayingEpisode] = useState(
+ null,
+ )
+ const [isAudioPlaying, setIsAudioPlaying] = useState(false)
+ const playerRef = useRef(null)
+
+ const showPodcastDetailPage = useFeatureFlagEnabled(
+ FeatureFlags.PodcastDetailPage,
+ )
+ const flagsLoaded = useFeatureFlagsLoaded()
+ const { data: episode } = useLearningResourcesDetail(Number(episodeId))
+ const { data: podcast } = useLearningResourcesDetail(Number(podcastId))
+
+ const podcastEpisode =
+ episode?.resource_type === ResourceTypeEnum.PodcastEpisode
+ ? episode.podcast_episode
+ : null
+
+ const { data: episodesData } = useInfiniteLearningResourceItems(
+ Number(podcastId),
+ { learning_resource_id: Number(podcastId), limit: 5 },
+ { enabled: !!podcast },
+ )
+ const episodes =
+ episodesData?.pages.flatMap((page) =>
+ page.results
+ .map((rel) => rel.resource)
+ .filter(
+ (r) =>
+ r.resource_type === ResourceTypeEnum.PodcastEpisode &&
+ r.id !== Number(episodeId),
+ ),
+ ) ?? []
+ const duration = podcastEpisode?.duration
+ ? Math.round(moment.duration(podcastEpisode.duration).asMinutes())
+ : null
+
+ const date = episode?.last_modified
+ ? formatDate(episode.last_modified, "MMM D, YYYY")
+ : null
+
+ const topics = episode?.topics?.map((t) => t.name).filter(Boolean) ?? []
+ const topicString = topics?.join("\u00A0\u00A0\u00A0\u00A0")
+ const metaParts = [duration ? `${duration} min` : null, date].filter(Boolean)
+
+ const getAudioUrl = (ep: LearningResource): string | null => {
+ if (ep.resource_type !== ResourceTypeEnum.PodcastEpisode) return null
+ const candidate =
+ ep.podcast_episode?.audio_url ?? ep.podcast_episode?.episode_link
+ return candidate?.trim() ? candidate : null
+ }
+
+ const isCurrentEpisodePlaying =
+ !!episode && playingEpisode?.id === episode.id && isAudioPlaying
+
+ const handlePlay = () => {
+ if (!episode) return
+ if (playingEpisode?.id === episode.id) {
+ if (isAudioPlaying) {
+ playerRef.current?.pause()
+ } else {
+ playerRef.current?.resume()
+ }
+ } else if (getAudioUrl(episode)) {
+ setPlayingEpisode(episode)
+ }
+ }
+
+ const currentTrack: PodcastTrack | null = playingEpisode
+ ? (() => {
+ const audioUrl = getAudioUrl(playingEpisode)
+ if (!audioUrl) return null
+ return {
+ audioUrl,
+ title: playingEpisode.title || "Untitled Episode",
+ podcastName: podcast?.title || "Podcast",
+ }
+ })()
+ : null
+
+ useEffect(() => {
+ const root = document.documentElement
+ if (currentTrack) {
+ const height = isMobile ? PLAYER_HEIGHT.mobile : PLAYER_HEIGHT.desktop
+ root.style.setProperty("--mit-player-height", `${height}px`)
+ } else {
+ root.style.removeProperty("--mit-player-height")
+ }
+ return () => {
+ root.style.removeProperty("--mit-player-height")
+ }
+ }, [currentTrack, isMobile])
+
+ const podcastHref = podcastId ? podcastPageView(podcastId) : "/"
+
+ if (!showPodcastDetailPage) {
+ return flagsLoaded ? notFound() : null
+ }
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {podcast?.title && (
+ {podcast.title}
+ )}
+
+ {episode?.title ?? ""}
+
+ {metaParts.length > 0 && (
+
+ {metaParts.join(" . ")}
+ {!isMobile && . {topicString}}
+
+ )}
+ {isMobile && {topicString}}
+ {episode && (
+ :
+ }
+ disabled={!episode || !getAudioUrl(episode)}
+ >
+ {isCurrentEpisodePlaying ? "Pause Episode" : "Play Episode"}
+