diff --git a/RELEASE.rst b/RELEASE.rst index f32eb1368a..314bdca9d7 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,17 @@ Release Notes ============= +Version 0.64.1 +-------------- + +- feat: track Stay Updated button click with PostHog cta_clicked event (#3225) +- feat: implementing the podcast detail page (#3184) +- remove default slide transition from dialogs (#3229) +- Revert "Update dependency opensearch-py to v3 (#2764)" (#3230) +- Update nginx Docker tag to v1.29.7 (#2986) +- Update dependency onnxruntime to v1.24.4 (#2742) +- Update dependency opensearch-py to v3 (#2764) + Version 0.64.0 (Released April 21, 2026) -------------- diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index f336d3e08f..48d9286a3b 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -138,4 +138,5 @@ export { videoShortsApi, videoPlaylistsApi, vectorLearningResourcesSearchApi, + BASE_PATH, } diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index e7e607ed59..fe697779a8 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, + useInfiniteQuery, useQueryClient, } from "@tanstack/react-query" import { learningResourcesApi } from "../../clients" @@ -14,6 +15,7 @@ import type { FeaturedApiFeaturedListRequest as FeaturedListParams, LearningResourcesApiLearningResourcesUserlistsPartialUpdateRequest, LearningResourcesApiLearningResourcesLearningPathsPartialUpdateRequest, + LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest, LearningResource, } from "../../generated/v1" // import learningResources from "./keyFactory" @@ -201,6 +203,17 @@ const useVectorSimilarLearningResources = ( }) } +const useInfiniteLearningResourceItems = ( + id: number, + params: Omit, + opts?: { enabled?: boolean }, +) => { + return useInfiniteQuery({ + ...learningResourceQueries.infiniteItems(id, params), + ...opts, + }) +} + export { useLearningResourcesList, useFeaturedLearningResourcesList, @@ -217,6 +230,7 @@ export { useSchoolsList, useSimilarLearningResources, useVectorSimilarLearningResources, + useInfiniteLearningResourceItems, learningResourceQueries, offerorQueries, schoolQueries, diff --git a/frontends/api/src/hooks/learningResources/queries.ts b/frontends/api/src/hooks/learningResources/queries.ts index 79ee847050..d6e38395d2 100644 --- a/frontends/api/src/hooks/learningResources/queries.ts +++ b/frontends/api/src/hooks/learningResources/queries.ts @@ -8,6 +8,7 @@ import { featuredApi, videoPlaylistsApi, vectorLearningResourcesSearchApi, + BASE_PATH, } from "../../clients" import type { @@ -19,10 +20,12 @@ import type { FeaturedApiFeaturedListRequest as FeaturedListParams, LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest, LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest, + PaginatedLearningResourceRelationshipList, VideoPlaylistResource, } from "../../generated/v1" import type { VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest as VectorLearningResourcesSearchRetrieveRequest } from "../../generated/v0" -import { queryOptions } from "@tanstack/react-query" +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query" +import axiosInstance from "../../axios" import { hasPosition, randomizeGroups } from "./util" const timedPromise = async ( @@ -60,6 +63,14 @@ const learningResourceKeys = { ...learningResourceKeys.itemsRoot(id), params, ], + infiniteItemsRoot: (id: number) => [ + ...learningResourceKeys.detail(id), + "infiniteItems", + ], + infiniteItems: (id: number, params: ItemsListRequest) => [ + ...learningResourceKeys.infiniteItemsRoot(id), + params, + ], // featured featuredRoot: () => [...learningResourceKeys.root, "featureds"], featured: (params: FeaturedListParams) => [ @@ -129,6 +140,32 @@ const learningResourceQueries = { .then((res) => res.data.results.map((rel) => rel.resource)) }, }), + infiniteItems: (id: number, params: Omit) => + infiniteQueryOptions({ + queryKey: learningResourceKeys.infiniteItems( + id, + params as ItemsListRequest, + ), + queryFn: async ({ pageParam }) => { + // We need to investigate why pageParam is always null and that make + // infinite query not working properly also the api call has port + // being add into the url for RC and PROD. + // https://github.com/mitodl/hq/issues/10999 + const request = pageParam + ? axiosInstance.request({ + method: "get", + url: + BASE_PATH + + new URL(pageParam, "https://x").pathname + + new URL(pageParam, "https://x").search, + }) + : learningResourcesApi.learningResourcesItemsList(params) + const { data } = await request + return data + }, + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.next ?? undefined, + }), similar: (id: number) => queryOptions({ queryKey: learningResourceKeys.similar(id), diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastContainer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastContainer.tsx new file mode 100644 index 0000000000..4d0fed43db --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastContainer.tsx @@ -0,0 +1,14 @@ +import { Container, styled } from "ol-components" + +const PodcastContainer = styled(Container)(({ theme }) => ({ + maxWidth: "1080px !important", + padding: "0 !important", + [theme.breakpoints.down("md")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "0 16px !important", + }, +})) + +export default PodcastContainer diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx new file mode 100644 index 0000000000..fa52961957 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx @@ -0,0 +1,217 @@ +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 { PodcastDetailPage } from "./PodcastDetailPage" + +jest.mock("posthog-js/react") +jest.mock("@/common/useFeatureFlagsLoaded") + +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) +const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) + +jest.mock( + "@/page-components/LearningResourceDrawer/LearningResourceDrawer", + () => ({ + __esModule: true, + default: jest.fn(() => null), + }), +) + +jest.mock("./PodcastPlayer", () => ({ + __esModule: true, + default: jest.fn( + ({ track }: { track: { title: string; podcastName: string } }) => ( +
+ {track.title} + {track.podcastName} +
+ ), + ), +})) + +const EPISODES_PAGE_SIZE = 5 + +const makeItemsResponse = ( + episodes: LearningResource[], + opts: { next?: string | null } = {}, +) => ({ + count: episodes.length, + next: opts.next ?? null, + previous: null, + results: episodes.map((resource, i) => ({ + id: i + 1, + child: resource.id, + parent: 0, + position: i + 1, + resource, + })), +}) + +const makePodcastEpisodes = (count: number): PodcastEpisodeResource[] => + Array.from({ length: count }, () => + factories.learningResources.resource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + }), + ) as PodcastEpisodeResource[] + +const setupApis = ({ + episodesPage1, + episodesPage2, +}: { + episodesPage1: LearningResource[] + episodesPage2?: LearningResource[] +}) => { + const podcast = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Podcast, + }) + + setMockResponse.get( + urls.learningResources.details({ id: podcast.id }), + podcast, + ) + + // The code normalises the next URL to BASE_PATH + path, where BASE_PATH is "" + // in tests, so both the next value and the page-2 mock use the plain path. + const page2Path = episodesPage2 + ? `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}&offset=${EPISODES_PAGE_SIZE}` + : null + + setMockResponse.get( + `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`, + makeItemsResponse(episodesPage1, { next: page2Path }), + ) + + if (episodesPage2 && page2Path) { + setMockResponse.get(page2Path, makeItemsResponse(episodesPage2)) + } + + return { podcast } +} + +describe("PodcastDetailPage", () => { + beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test("renders initial episode list", async () => { + const episodes = makePodcastEpisodes(3) + const { podcast } = setupApis({ episodesPage1: episodes }) + + renderWithProviders() + + await screen.findByText(episodes[0].title!) + for (const episode of episodes) { + expect(screen.getByText(episode.title!)).toBeInTheDocument() + } + }) + + test("does not show 'Load more' when there is no next page", async () => { + const episodes = makePodcastEpisodes(3) + const { podcast } = setupApis({ episodesPage1: episodes }) + + renderWithProviders() + + await screen.findByText(episodes[0].title!) + expect( + screen.queryByRole("button", { name: /load more episodes/i }), + ).not.toBeInTheDocument() + }) + + test("shows 'Load more' when API returns a next page URL", async () => { + const episodes = makePodcastEpisodes(EPISODES_PAGE_SIZE) + const { podcast } = setupApis({ + episodesPage1: episodes, + episodesPage2: [], + }) + + renderWithProviders() + + await screen.findByText(episodes[0].title!) + expect( + screen.getByRole("button", { name: /load more episodes/i }), + ).toBeInTheDocument() + }) + + test("loads next page when 'Load more' is clicked", async () => { + const page1 = makePodcastEpisodes(EPISODES_PAGE_SIZE) + const page2 = makePodcastEpisodes(2) + const { podcast } = setupApis({ + episodesPage1: page1, + episodesPage2: page2, + }) + + renderWithProviders() + + await screen.findByText(page1[0].title!) + await user.click( + screen.getByRole("button", { name: /load more episodes/i }), + ) + + for (const episode of page2) { + await screen.findByText(episode.title!) + } + + // No more pages — button should disappear + expect( + screen.queryByRole("button", { name: /load more episodes/i }), + ).not.toBeInTheDocument() + }) + + test("clicking play renders the player with correct track and podcast name", async () => { + const episodes = makePodcastEpisodes(2) + const { podcast } = setupApis({ episodesPage1: episodes }) + + renderWithProviders() + + await screen.findByText(episodes[0].title!) + expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument() + + await user.click( + screen.getByRole("button", { name: `Play ${episodes[0].title}` }), + ) + + expect(screen.getByTestId("podcast-player")).toBeInTheDocument() + expect(screen.getByTestId("player-track-title")).toHaveTextContent( + episodes[0].title!, + ) + expect(screen.getByTestId("player-podcast-name")).toHaveTextContent( + podcast.title!, + ) + }) + + test("shows 'No episodes found' when episode list is empty", async () => { + const { podcast } = setupApis({ episodesPage1: [] }) + + renderWithProviders() + + await screen.findByText(/no episodes found/i) + }) + + test("disables play button for episodes without audio source", async () => { + const [episodeWithoutAudio] = makePodcastEpisodes(1) + if (episodeWithoutAudio.podcast_episode) { + episodeWithoutAudio.podcast_episode.audio_url = "" + episodeWithoutAudio.podcast_episode.episode_link = "" + } + + const { podcast } = setupApis({ episodesPage1: [episodeWithoutAudio] }) + + renderWithProviders() + + const playButton = await screen.findByRole("button", { + name: `Play ${episodeWithoutAudio.title}`, + }) + + expect(playButton).toBeDisabled() + expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument() + }) +}) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx new file mode 100644 index 0000000000..713b87e218 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -0,0 +1,560 @@ +"use client" + +import React, { useState } from "react" +import { Breadcrumbs, Typography, styled } from "ol-components" +import { ButtonLink, Button, ActionButton } from "@mitodl/smoot-design" +import { RiPlayFill } from "@remixicon/react" +import PodcastPlayer from "./PodcastPlayer" +import type { PodcastTrack } 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 } from "@/common/urls" +import PodcastContainer from "./PodcastContainer" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { notFound } from "next/navigation" + +const HeaderSection = styled.div(({ theme }) => ({ + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + marginBottom: "56px", + overflow: "hidden", + [theme.breakpoints.down("sm")]: { + paddingBottom: "32px", + marginBottom: "0", + borderBottom: "none", + }, +})) + +const PodcastTitle = styled(Typography)(({ theme }) => ({ + marginBottom: "24px", + gridArea: "title", + + [theme.breakpoints.down("sm")]: { + ...theme.typography.h2, + }, +})) + +const StyledHeaderSection = styled.div(({ theme }) => ({ + padding: "64px 0", + [theme.breakpoints.down("sm")]: { + padding: "32px 0 0", + }, +})) + +const MetaLine = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, + marginBottom: "16px", + display: "block", + ...theme.typography.body2, + lineHeight: "26px", + [theme.breakpoints.down("sm")]: { + marginBottom: "8px", + }, +})) + +const Description = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "block", + marginBottom: "16px", + ...theme.typography.body1, + lineHeight: "26px", + [theme.breakpoints.down("sm")]: { + marginBottom: "8px", + ...theme.typography.body2, + lineHeight: "22px", + }, +})) + +const LatestEpisodeLine = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, + marginBottom: "16px", + ...theme.typography.body1, + lineHeight: "26px", + [theme.breakpoints.down("sm")]: { + ...theme.typography.body2, + lineHeight: "22px", + marginBottom: "24px", + }, +})) + +const PodcastImage = styled.img(({ theme }) => ({ + gridArea: "image", + width: "280px", + height: "280px", + objectFit: "cover", + borderRadius: "8px", + flexShrink: 0, + border: `1px solid ${theme.custom.colors.lightGray2}`, + [theme.breakpoints.down("sm")]: { + width: "100%", + height: "auto", + aspectRatio: "1 / 1", + borderRadius: "0px", + marginBottom: "16px", + }, +})) + +const HeaderContent = styled.div(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr 280px", + gridTemplateAreas: '"title image" "text image"', + columnGap: "164px", + + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "1fr", + gridTemplateAreas: '"title" "image" "text"', + columnGap: "0", + }, +})) + +const HeaderTextContent = styled.div({ + gridArea: "text", +}) + +/* ── Episodes list ── */ + +const EpisodesSection = styled.div(({ theme }) => ({ + padding: "0 48px", + [theme.breakpoints.down("sm")]: { + padding: "0 0 48px", + }, +})) + +const EpisodesHeading = styled(Typography)(({ theme }) => ({ + textTransform: "uppercase" as const, + color: theme.custom.colors.black, + ...theme.typography.body3, + marginBottom: "24px", + + fontSize: "12px", + fontStyle: "normal", + fontWeight: theme.typography.fontWeightBold, + lineHeight: "150%" /* 18px */, + letterSpacing: "1.92px", + + [theme.breakpoints.down("sm")]: { + fontWeight: theme.typography.fontWeightBold, + lineHeight: "150%", + letterSpacing: "1.92px", + textTransform: "uppercase", + }, +})) + +const EpisodeList = styled.ul({ + listStyle: "none", + margin: 0, + padding: 0, + display: "grid", + gridTemplateColumns: "1fr", +}) + +const EpisodeRow = styled.li(({ theme }) => ({ + margin: 0, + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + padding: "28px 16px", + boxShadow: `0 -1px 0 ${theme.custom.colors.lightGray2}`, + gap: "16px", + "&:last-child": { + boxShadow: `0 -1px 0 ${theme.custom.colors.lightGray2}, 0 1px 0 ${theme.custom.colors.lightGray2}`, + }, + "&:hover": { + backgroundColor: theme.custom.colors.lightGray1, + cursor: "pointer", + }, + "&:hover .episode-title, &:focus-visible .episode-title": { + color: theme.custom.colors.red, + }, + "&:hover .play-button, &:focus-visible .play-button": { + color: theme.custom.colors.red, + }, + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + alignItems: "flex-start", + gap: "16px", + padding: "24px 16px", + }, +})) + +const EpisodeInfo = styled.div(({ theme }) => ({ + flex: 1, + minWidth: 0, + [theme.breakpoints.down("sm")]: { + width: "100%", + }, +})) + +const EpisodeTitleLink = styled.span(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.darkGray2, + textDecoration: "none", + display: "block", + marginBottom: "8px", + fontSize: "18px", + fontStyle: "normal", + fontWeight: theme.typography.fontWeightBold, + lineHeight: "26px", +})) + +const StyledButton = styled(ButtonLink)(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + width: "100%", + }, +})) + +const StyledShowMoreContainer = styled("div")({ + width: "100%", + display: "flex", + justifyContent: "center", +}) +const StyledShowMore = styled(Button)(({ theme }) => ({ + minWidth: "140px", + margin: "40px 0 56px 0", + [theme.breakpoints.down("sm")]: { + width: "100%", + }, +})) + +const BreadcrumbBar = styled.div(({ theme }) => ({ + padding: "32px 0 16px 0", + borderBottom: `2px solid ${theme.custom.colors.red}`, + [theme.breakpoints.down("sm")]: { + padding: "16px 0 0px 0", + }, +})) + +const EpisodeRight = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "28px", + flexShrink: 0, + [theme.breakpoints.down("sm")]: { + alignItems: "center", + justifyContent: "flex-end", + width: "100%", + }, +})) + +const StyledDot = styled.span(({ theme }) => ({ + display: "inline-block", + fontSize: "14px", + padding: "0 6px", + fontWeight: theme.typography.fontWeightBold, +})) + +const PageSection = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, +})) + +const EpisodeMeta = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray1, + whiteSpace: "nowrap", + textAlign: "right", +})) + +const PlayButton = styled(ActionButton, { + shouldForwardProp: (prop) => prop !== "isPlaying", +})<{ + isPlaying: boolean +}>(({ theme, isPlaying }) => [ + { + color: theme.custom.colors.darkGray2, + borderColor: "currentColor", + "&:hover:not(:disabled)": { + color: theme.custom.colors.red, + }, + [theme.breakpoints.down("sm")]: { + width: "80px", + height: "48px", + backgroundColor: theme.custom.colors.white, + }, + }, + isPlaying && { + color: theme.custom.colors.red, + }, +]) + +/* ── Episode row component ── */ + +type EpisodeItemProps = { + episode: LearningResource + onPlayClick: (episode: LearningResource) => void + isPlaying: boolean + isPlayable: boolean +} + +const EpisodeItem: React.FC = ({ + episode, + onPlayClick, + isPlaying, + isPlayable, +}) => { + const podcastEpisode = + episode.resource_type === "podcast_episode" ? episode.podcast_episode : null + + const duration = podcastEpisode?.duration + ? Math.round(moment.duration(podcastEpisode.duration).asMinutes()) + : null + + const date = episode.last_modified + ? formatDate(episode.last_modified, "MMM D") + : null + + const metaParts = [duration ? `${duration} min` : null, date].filter(Boolean) + + return ( + onPlayClick(episode)}> + + + {episode.title} + + + + + {metaParts.length > 0 && ( + + {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} + + )} + + + + + + ) +} + +/* ── Page ── */ + +type PodcastDetailPageProps = { + podcastId: string +} + +const EPISODES_PAGE_SIZE = 5 + +export const PodcastDetailPage: React.FC = ({ + podcastId, +}) => { + const showPodcastDetailPage = useFeatureFlagEnabled( + FeatureFlags.PodcastDetailPage, + ) + const flagsLoaded = useFeatureFlagsLoaded() + const id = Number(podcastId) + const [playingEpisode, setPlayingEpisode] = useState( + null, + ) + + const { data: resource } = useLearningResourcesDetail(id) + + const { + data: episodesData, + isLoading: episodesLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteLearningResourceItems( + id, + { learning_resource_id: id, limit: EPISODES_PAGE_SIZE }, + { enabled: !!resource }, + ) + + const episodes = + episodesData?.pages.flatMap((page) => + page.results + .map((rel) => rel.resource) + .filter((r) => r.resource_type === ResourceTypeEnum.PodcastEpisode), + ) ?? [] + + const isPodcast = resource?.resource_type === ResourceTypeEnum.Podcast + const podcast = isPodcast ? resource.podcast : null + + const offeredBy = resource?.offered_by?.name + const lastModified = resource?.last_modified + ? formatDate(resource.last_modified, "MMM YYYY") + : null + const episodeCount = podcast?.episode_count + + const metaParts = [ + offeredBy, + episodeCount ? `${episodeCount} episodes` : null, + lastModified ? `Updated ${lastModified}` : null, + ].filter(Boolean) + + const latestEpisode = episodes?.[0] + const latestEpisodeDuration = latestEpisode?.podcast_episode?.duration + ? Math.round( + moment.duration(latestEpisode.podcast_episode.duration).asMinutes(), + ) + : null + const latestEpisodeDate = latestEpisode?.last_modified + ? formatDate(latestEpisode.last_modified, "MMM D") + : null + + const subscribeUrl = podcast?.apple_podcasts_url ?? podcast?.rss_url + + const getEpisodeAudioUrl = (episode: LearningResource): string | null => { + if (episode.resource_type !== "podcast_episode") return null + + const candidateUrl = + episode.podcast_episode?.audio_url ?? + episode.podcast_episode?.episode_link + + return candidateUrl?.trim() ? candidateUrl : null + } + + const handlePlayClick = (episode: LearningResource) => { + if (!getEpisodeAudioUrl(episode)) return + setPlayingEpisode(episode) + } + + if (!showPodcastDetailPage) { + return flagsLoaded ? notFound() : null + } + const currentTrack: PodcastTrack | null = playingEpisode + ? (() => { + const audioUrl = getEpisodeAudioUrl(playingEpisode) + if (!audioUrl) return null + + return { + audioUrl, + title: playingEpisode.title || "Untitled Episode", + podcastName: resource?.title || "Podcast", + } + })() + : null + + return ( + <> + + + + + + + + + + + + {resource?.title ?? ""} + + + {resource?.image?.url && ( + + )} + + + {metaParts.length > 0 && ( + {metaParts.join(" · ")} + )} + + {resource?.description && ( + + {resource.description} + + )} + + {latestEpisode && ( + + {"Latest episode: "} + {latestEpisode.title} + {latestEpisodeDuration + ? ` · ${latestEpisodeDuration} min` + : ""} + {latestEpisodeDate ? ` · ${latestEpisodeDate}` : ""} + + )} + + {subscribeUrl && ( + } + > + Play Latest Episode + + )} + + + + + + + + + Episodes + + {episodes && episodes.length > 0 && ( + + {episodes.map((episode) => ( + + ))} + + )} + {(hasNextPage || episodesLoading) && ( + + fetchNextPage()} + disabled={isFetchingNextPage} + > + {isFetchingNextPage ? "Loading..." : "Load more episodes"} + + + )} + + {!episodesLoading && episodes?.length === 0 && ( + + No episodes found. + + )} + + + + {currentTrack && ( + setPlayingEpisode(null)} + /> + )} + + ) +} diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.test.tsx new file mode 100644 index 0000000000..896f7a96db --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.test.tsx @@ -0,0 +1,338 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { ThemeProvider } from "ol-components" +import PodcastPlayer from "./PodcastPlayer" +import type { PodcastTrack } from "./PodcastPlayer" + +// JSDOM does not implement HTMLMediaElement methods; provide minimal stubs. +beforeAll(() => { + window.HTMLMediaElement.prototype.load = jest.fn() + window.HTMLMediaElement.prototype.play = jest + .fn() + .mockResolvedValue(undefined) + window.HTMLMediaElement.prototype.pause = jest.fn() +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const makeTrack = (overrides: Partial = {}): PodcastTrack => ({ + audioUrl: "https://example.com/episode.mp3", + title: "Episode One", + podcastName: "The Test Podcast", + ...overrides, +}) + +/** + * Renders the player and flushes the initial auto-play promise so that the + * setIsPlaying(true) state update inside play().then() is always wrapped in + * act() before any assertion runs. + */ +const renderPlayer = async ( + track: PodcastTrack = makeTrack(), + props: Partial> = {}, + options: { waitForAutoPlay?: boolean } = {}, +) => { + const { waitForAutoPlay = true } = options + const onClose = props.onClose ?? jest.fn() + const view = render( + + + , + ) + if (waitForAutoPlay) { + // Wait until the auto-play play().then(setIsPlaying) microtask has resolved + await waitFor(() => + expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled(), + ) + } + const audio = document.querySelector("audio") as HTMLAudioElement + // Simulate the audio becoming ready to play + const simulateCanPlay = () => fireEvent.canPlay(audio) + const simulateLoadedMetadata = (duration: number) => { + Object.defineProperty(audio, "duration", { + value: duration, + configurable: true, + }) + fireEvent.loadedMetadata(audio) + } + return { ...view, audio, onClose, simulateCanPlay, simulateLoadedMetadata } +} + +describe("PodcastPlayer", () => { + test("renders track title and podcast name", async () => { + await renderPlayer( + makeTrack({ title: "My Episode", podcastName: "My Podcast" }), + ) + expect(screen.getByText("My Episode")).toBeInTheDocument() + expect(screen.getByText("My Podcast")).toBeInTheDocument() + }) + + test("renders the audio element with the correct src", async () => { + const { audio } = await renderPlayer( + makeTrack({ audioUrl: "https://cdn.example.com/ep.mp3" }), + ) + expect(audio).toHaveAttribute("src", "https://cdn.example.com/ep.mp3") + }) + + test("shows loading state initially — play/pause buttons start disabled", async () => { + // Render without flushing canPlay so buffering=true persists + const onClose = jest.fn() + render( + + + , + ) + // Wait until the auto-play play().then(setIsPlaying) microtask has resolved + await waitFor(() => + expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled(), + ) + expect(screen.getByRole("button", { name: /^loading$/i })).toBeDisabled() + }) + + test("enables play/pause button after canplay fires", async () => { + const { simulateCanPlay } = await renderPlayer() + await simulateCanPlay() + expect(screen.getByRole("button", { name: /^pause$/i })).not.toBeDisabled() + }) + + test("plays automatically on mount", async () => { + await renderPlayer() + expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled() + }) + + test("clicking Pause calls audio.pause() and shows Play", async () => { + const { simulateCanPlay } = await renderPlayer() + await simulateCanPlay() + const pauseBtn = screen.getByRole("button", { name: /^pause$/i }) + fireEvent.click(pauseBtn) + expect(window.HTMLMediaElement.prototype.pause).toHaveBeenCalled() + expect(screen.getByRole("button", { name: /^play$/i })).toBeInTheDocument() + }) + + test("clicking Play again calls audio.play()", async () => { + const { simulateCanPlay } = await renderPlayer() + await simulateCanPlay() + const pauseBtn = screen.getByRole("button", { name: /^pause$/i }) + fireEvent.click(pauseBtn) + jest.clearAllMocks() + const playBtn = screen.getByRole("button", { name: /^play$/i }) + fireEvent.click(playBtn) + await waitFor(() => + expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled(), + ) + }) + + test("calls onClose when close button is clicked", async () => { + const { onClose } = await renderPlayer() + fireEvent.click(screen.getByRole("button", { name: /close player/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test("cycles through speed options and updates label", async () => { + await renderPlayer() + // Initial speed label is 1x (index 1 of [0.75, 1, 1.25, 1.5, 2]) + expect( + screen.getByRole("button", { name: /playback speed/i }), + ).toHaveTextContent("1x") + + fireEvent.click(screen.getByRole("button", { name: /playback speed/i })) + expect( + screen.getByRole("button", { name: /playback speed/i }), + ).toHaveTextContent("1.25x") + + fireEvent.click(screen.getByRole("button", { name: /playback speed/i })) + expect( + screen.getByRole("button", { name: /playback speed/i }), + ).toHaveTextContent("1.5x") + }) + + test("cycling speed applies playbackRate to audio element", async () => { + const { audio } = await renderPlayer() + fireEvent.click(screen.getByRole("button", { name: /playback speed/i })) + expect(audio.playbackRate).toBe(1.25) + }) + + test("speed is reapplied to new track (playbackRate preserved on track change)", async () => { + const { rerender, audio } = await renderPlayer() + + // Cycle to 1.5x + fireEvent.click(screen.getByRole("button", { name: /playback speed/i })) // 1.25x + fireEvent.click(screen.getByRole("button", { name: /playback speed/i })) // 1.5x + + jest.clearAllMocks() + + // Change track — flush the new track-change effect + rerender( + + + , + ) + await waitFor(() => expect(audio.playbackRate).toBe(1.5)) + }) + + test("rewind button subtracts 10s from currentTime", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 60, + configurable: true, + writable: true, + }) + fireEvent.click(screen.getByRole("button", { name: /rewind 10 seconds/i })) + expect(audio.currentTime).toBe(50) + }) + + test("forward button adds 30s to currentTime", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 60, + configurable: true, + writable: true, + }) + fireEvent.click(screen.getByRole("button", { name: /forward 30 seconds/i })) + expect(audio.currentTime).toBe(90) + }) + + test("rewind clamps to 0", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 5, + configurable: true, + writable: true, + }) + fireEvent.click(screen.getByRole("button", { name: /rewind 10 seconds/i })) + expect(audio.currentTime).toBe(0) + }) + + test("forward clamps to duration", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 110, + configurable: true, + writable: true, + }) + fireEvent.click(screen.getByRole("button", { name: /forward 30 seconds/i })) + expect(audio.currentTime).toBe(120) + }) + + test("seek slider keyboard ArrowRight skips forward 5s", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 40, + configurable: true, + writable: true, + }) + const slider = screen.getByRole("slider", { name: /seek/i }) + fireEvent.keyDown(slider, { key: "ArrowRight" }) + expect(audio.currentTime).toBe(45) + }) + + test("seek slider keyboard ArrowLeft skips back 5s", async () => { + const { audio, simulateCanPlay, simulateLoadedMetadata } = + await renderPlayer() + await simulateLoadedMetadata(120) + await simulateCanPlay() + Object.defineProperty(audio, "currentTime", { + value: 40, + configurable: true, + writable: true, + }) + const slider = screen.getByRole("slider", { name: /seek/i }) + fireEvent.keyDown(slider, { key: "ArrowLeft" }) + expect(audio.currentTime).toBe(35) + }) + + test("onPlayStateChange is called with true when playing starts", async () => { + const onPlayStateChange = jest.fn() + render( + + + , + ) + await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true)) + }) + + test("onPlayStateChange is called with false when paused", async () => { + const onPlayStateChange = jest.fn() + const { simulateCanPlay } = await renderPlayer(makeTrack(), { + onPlayStateChange, + }) + await simulateCanPlay() + const pauseBtn = screen.getByRole("button", { name: /^pause$/i }) + fireEvent.click(pauseBtn) + expect(onPlayStateChange).toHaveBeenCalledWith(false) + }) + + test("does not show loading spinner for tracks without an audio source", async () => { + await renderPlayer( + makeTrack({ audioUrl: "" }), + {}, + { waitForAutoPlay: false }, + ) + + expect(window.HTMLMediaElement.prototype.play).not.toHaveBeenCalled() + expect( + screen.queryByRole("button", { name: /^loading$/i }), + ).not.toBeInTheDocument() + + expect( + screen.getByRole("button", { name: /play unavailable/i }), + ).toBeDisabled() + }) + + test("prevents duplicate play calls during rapid clicks while play is pending", async () => { + const { simulateCanPlay } = await renderPlayer() + await simulateCanPlay() + + fireEvent.click(screen.getByRole("button", { name: /^pause$/i })) + + jest.clearAllMocks() + + let resolvePlay: (() => void) | undefined + const pendingPlay = new Promise((resolve) => { + resolvePlay = resolve + }) + + ;(window.HTMLMediaElement.prototype.play as jest.Mock).mockImplementation( + () => pendingPlay, + ) + + const playButton = screen.getByRole("button", { name: /^play$/i }) + fireEvent.click(playButton) + fireEvent.click(playButton) + + expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1) + expect(screen.getByRole("button", { name: /^loading$/i })).toBeDisabled() + + resolvePlay?.() + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /^pause$/i }), + ).toBeInTheDocument() + }) + }) +}) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx new file mode 100644 index 0000000000..70a2a30e1b --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx @@ -0,0 +1,451 @@ +"use client" + +import React, { useRef, useState, useEffect, useCallback } from "react" +import { styled, Typography, LoadingSpinner } from "ol-components" +import { + RiPlayCircleLine, + RiPauseCircleLine, + RiReplay10Line, + RiForward30Line, + RiCloseLine, +} from "@remixicon/react" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type PodcastTrack = { + audioUrl: string + title: string + podcastName: string +} + +type PodcastPlayerProps = { + track: PodcastTrack + onClose: () => void + onPlayStateChange?: (isPlaying: boolean) => void +} + +// ─── Styled components ──────────────────────────────────────────────────────── + +const PlayerShell = styled.div(({ theme }) => ({ + position: "fixed", + bottom: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.appBar + 10, + display: "grid", + gridTemplateColumns: "220px 1px auto minmax(0, 1fr) auto auto", + gridTemplateAreas: '"track divider controls progress speed close"', + alignItems: "center", + gap: "24px", + padding: "16px 32px", + background: "linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)", + borderTop: `2px solid ${theme.custom.colors.mitRed}`, + boxShadow: "0 -4px 16px rgba(0,0,0,0.12)", + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "minmax(0, 1fr) auto", + gridTemplateAreas: + '"track close" "controls controls" "progress progress" "speed speed"', + gap: "16px", + padding: "24px", + borderRadius: "12px 12px 0 0", + boxShadow: "0 -4px 24px rgba(0,0,0,0.15)", + }, +})) + +const TrackInfo = styled.div({ + gridArea: "track", + display: "flex", + flexDirection: "column", + gap: "2px", + minWidth: 0, +}) + +const Divider = styled.div(({ theme }) => ({ + gridArea: "divider", + width: "1px", + height: "40px", + backgroundColor: theme.custom.colors.lightGray2, + flexShrink: 0, + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +const Controls = styled.div(({ theme }) => ({ + gridArea: "controls", + display: "flex", + alignItems: "center", + gap: "12px", + flexShrink: 0, + [theme.breakpoints.down("sm")]: { + justifyContent: "center", + gap: "32px", + }, +})) + +const IconButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.silverGray, + "&:hover": { color: theme.custom.colors.mitRed }, + "& svg": { + width: "24px", + height: "24px", + }, + [theme.breakpoints.down("sm")]: { + padding: "8px", + "& svg": { + width: "32px", + height: "32px", + }, + }, +})) + +const PlayPauseButton = styled.button(({ theme }) => ({ + gridArea: "play", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.mitRed, + "&:hover": { opacity: 0.8 }, + "& svg": { + width: "40px", + height: "40px", + }, + [theme.breakpoints.down("sm")]: { + "& svg": { + width: "56px", + height: "56px", + }, + }, +})) + +const TimeLabel = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + whiteSpace: "nowrap", + flexShrink: 0, + minWidth: "38px", + textAlign: "center", +})) + +const TrackTitle = styled(Typography)(({ theme }) => ({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: theme.custom.colors.black, + [theme.breakpoints.down("sm")]: { + display: "-webkit-box", + whiteSpace: "normal", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + }, +})) + +const ProgressWrapper = styled.div(({ theme }) => ({ + gridArea: "progress", + display: "flex", + alignItems: "center", + gap: "12px", + minWidth: 0, + [theme.breakpoints.down("sm")]: { + gap: "8px", + }, +})) + +const ProgressRange = styled.input<{ percent: number }>( + ({ theme, percent }) => ({ + appearance: "none", + WebkitAppearance: "none", + flex: 1, + height: "6px", + borderRadius: "3px", + cursor: "pointer", + outline: "none", + border: "none", + padding: 0, + background: `linear-gradient(to right, ${theme.custom.colors.mitRed} ${percent}%, ${theme.custom.colors.lightGray2} ${percent}%)`, + "&::-webkit-slider-thumb": { + WebkitAppearance: "none", + width: "14px", + height: "14px", + borderRadius: "50%", + background: theme.custom.colors.mitRed, + cursor: "pointer", + }, + "&::-moz-range-thumb": { + width: "14px", + height: "14px", + borderRadius: "50%", + background: theme.custom.colors.mitRed, + border: "none", + cursor: "pointer", + }, + }), +) + +const SpeedButton = styled.button(({ theme }) => ({ + gridArea: "speed", + background: "white", + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + backgroundColor: theme.custom.colors.lightGray1, + borderRadius: "4px", + padding: "2px 8px", + cursor: "pointer", + ...theme.typography.body3, + color: theme.custom.colors.darkGray2, + flexShrink: 0, + "&:hover": { + borderColor: theme.custom.colors.mitRed, + color: theme.custom.colors.mitRed, + }, + [theme.breakpoints.down("sm")]: { + justifySelf: "end", + }, +})) + +const CloseButton = styled.button(({ theme }) => ({ + gridArea: "close", + background: "none", + border: "none", + cursor: "pointer", + padding: 0, + display: "flex", + alignItems: "center", + color: theme.custom.colors.darkGray2, + flexShrink: 0, + "&:hover": { color: theme.custom.colors.mitRed }, + justifySelf: "end", +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] + +const formatTime = (seconds: number): string => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const PodcastPlayer = ({ + track, + onClose, + onPlayStateChange, +}: PodcastPlayerProps) => { + const hasAudioSource = Boolean(track.audioUrl.trim()) + const audioRef = useRef(null) + const isPlayPendingRef = useRef(false) + const playAttemptIdRef = useRef(0) + const [isPlaying, setIsPlaying] = useState(false) + const [isBuffering, setIsBuffering] = useState(true) + const [isPlayPending, setIsPlayPending] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [speedIndex, setSpeedIndex] = useState(1) // default 1x + const speedIndexRef = useRef(1) + + const startPlayback = useCallback(async () => { + if (!hasAudioSource || isPlayPendingRef.current) return + + const audio = audioRef.current + if (!audio) return + + const attemptId = ++playAttemptIdRef.current + isPlayPendingRef.current = true + setIsPlayPending(true) + + try { + await audio.play() + if (playAttemptIdRef.current === attemptId) { + setIsPlaying(true) + } + } catch { + if (playAttemptIdRef.current === attemptId) { + setIsPlaying(false) + } + } finally { + if (playAttemptIdRef.current === attemptId) { + isPlayPendingRef.current = false + setIsPlayPending(false) + } + } + }, [hasAudioSource]) + + // Auto-play when a new track is loaded + useEffect(() => { + // Invalidate any in-flight play attempt from a previous track. + playAttemptIdRef.current += 1 + isPlayPendingRef.current = false + setIsPlayPending(false) + + setCurrentTime(0) + setDuration(0) + setIsPlaying(false) + setIsBuffering(hasAudioSource) + + if (!hasAudioSource) { + return + } + + const audio = audioRef.current + if (!audio) return + audio.load() + audio.playbackRate = SPEED_OPTIONS[speedIndexRef.current] + void startPlayback() + }, [track.audioUrl, hasAudioSource, startPlayback]) + + const handlePlayPause = async () => { + if (!hasAudioSource) return + + const audio = audioRef.current + if (!audio) return + + if (isPlaying) { + audio.pause() + setIsPlaying(false) + } else { + void startPlayback() + } + } + + useEffect(() => { + onPlayStateChange?.(isPlaying) + }, [isPlaying, onPlayStateChange]) + + const handleSkip = (seconds: number) => { + const audio = audioRef.current + if (!audio) return + audio.currentTime = Math.max( + 0, + Math.min(audio.currentTime + seconds, duration), + ) + } + + const handleSpeedCycle = () => { + const nextIndex = (speedIndex + 1) % SPEED_OPTIONS.length + speedIndexRef.current = nextIndex + setSpeedIndex(nextIndex) + if (audioRef.current) { + audioRef.current.playbackRate = SPEED_OPTIONS[nextIndex] + } + } + + const handleSeekKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return + + event.preventDefault() + handleSkip(event.key === "ArrowRight" ? 5 : -5) + } + + const percent = duration ? (currentTime / duration) * 100 : 0 + return ( + <> + {/* Shared audio element */} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +