From 7814c130b542a35a53c2cef63d8f620c2b2a3ba4 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 5 May 2026 18:13:24 -0400 Subject: [PATCH 1/6] use Intl to get native language names (#3297) * use Intl to get native language names Co-authored-by: Copilot * remove forced uppercasing * add a static fallback in case intl is not available * memoize intl calls --------- Co-authored-by: Copilot --- .../DashboardPage/ContractContent.test.tsx | 4 +- .../EnrollmentDisplay.test.tsx | 2 +- .../CoursewareDisplay/languageOptions.test.ts | 185 +++++++++++++++++- .../CoursewareDisplay/languageOptions.ts | 117 ++++++++--- 4 files changed, 281 insertions(+), 27 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index a3bdf6cc5f..cea1282c31 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -1443,7 +1443,7 @@ describe("ContractContent", () => { expect(card).toHaveTextContent("Module in English") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) await waitFor(() => { expect(root.getByTestId("enrollment-card-desktop")).toHaveTextContent( @@ -1549,7 +1549,7 @@ describe("ContractContent", () => { expect(card).toHaveTextContent("Collection English") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) await waitFor(() => { expect( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index b68a09ee14..ff076ea89d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -2543,7 +2543,7 @@ describe("EnrollmentDisplay", () => { expect(card).toHaveTextContent("Start Course") await user.click(languageSelect) - await user.click(await screen.findByRole("option", { name: "Español" })) + await user.click(await screen.findByRole("option", { name: "español" })) const desktopCard = await screen.findByTestId("enrollment-card-desktop") await within(desktopCard).findByText("Modulo en Espanol") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index d0b9f247df..79f17cb91c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -18,6 +18,16 @@ type LanguageOptionWithEnrollability = CourseRunLanguageOption & { } describe("languageOptions", () => { + const setIntlDisplayNames = ( + value: typeof Intl.DisplayNames | undefined, + ): void => { + Object.defineProperty(Intl, "DisplayNames", { + value, + configurable: true, + writable: true, + }) + } + test("normalizes language keys", () => { expect( getLanguageOptionKey({ @@ -159,7 +169,7 @@ describe("languageOptions", () => { }) expect(options[1]).toEqual({ value: "language:es", - label: "Español", + label: "español", }) }) @@ -213,6 +223,179 @@ describe("languageOptions", () => { expect(selectedRun?.id).toBe(spanishRun.id) }) + test("uses static fallback labels when Intl.DisplayNames is unavailable", () => { + const originalDisplayNames = Intl.DisplayNames + setIntlDisplayNames(undefined) + + try { + const run = factories.courses.courseRun({ + id: 7001, + title: "Spanish LATAM", + courseware_id: "cw-es-419", + courseware_url: "https://example.com/cw-es-419", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "es-419", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options).toEqual([ + { + value: "language:es-419", + label: "español (Latinoamérica)", + }, + ]) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + + test("falls back to the base language subtag when regional code is unresolved", () => { + const originalDisplayNames = Intl.DisplayNames + + class MockDisplayNames { + of(code: string): string | undefined { + if (code === "zz-419") { + return undefined + } + if (code === "zz") { + return "Zed" + } + return undefined + } + } + + setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) + + try { + const run = factories.courses.courseRun({ + id: 7002, + title: "Mock Regional", + courseware_id: "cw-zz-419", + courseware_url: "https://example.com/cw-zz-419", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "zz-419", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options).toEqual([ + { + value: "language:zz-419", + label: "Zed", + }, + ]) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + + test("memoizes native language labels by language code", () => { + const originalDisplayNames = Intl.DisplayNames + let constructorCalls = 0 + + class MockDisplayNames { + constructor() { + constructorCalls += 1 + } + + of(code: string): string | undefined { + if (code === "es") { + return "español" + } + return undefined + } + } + + setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) + + try { + const runA = factories.courses.courseRun({ + id: 7101, + title: "Spanish A", + courseware_id: "cw-es-7101", + courseware_url: "https://example.com/cw-es-7101", + is_enrollable: true, + }) + + const runB = factories.courses.courseRun({ + id: 7102, + title: "Spanish B", + courseware_id: "cw-es-7102", + courseware_url: "https://example.com/cw-es-7102", + is_enrollable: true, + }) + + const courseA = factories.courses.course({ + courseruns: [runA], + next_run_id: runA.id, + language_options: [ + { + id: runA.id, + courseware_id: runA.courseware_id, + courseware_url: runA.courseware_url ?? "", + language: "es", + title: runA.title, + run_tag: runA.run_tag, + }, + ], + }) + + const courseB = factories.courses.course({ + courseruns: [runB], + next_run_id: runB.id, + language_options: [ + { + id: runB.id, + courseware_id: runB.courseware_id, + courseware_url: runB.courseware_url ?? "", + language: "es", + title: runB.title, + run_tag: runB.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([courseA, courseB]) + + expect(options).toEqual([ + { + value: "language:es", + label: "español", + }, + ]) + expect(constructorCalls).toBe(1) + } finally { + setIntlDisplayNames(originalDisplayNames) + } + }) + test("keeps language when one of multiple matching runs is enrollable", () => { const englishRun = factories.courses.courseRun({ id: 6101, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index efebedbda2..05389f4d05 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -7,22 +7,6 @@ import type { } from "@mitodl/mitxonline-api-axios/v2" import { getBestRun, selectBestEnrollment } from "./helpers" -const LANGUAGE_CODE_TO_NATIVE_NAME: Record = { - ar: "العربية", - de: "Deutsch", - "de-de": "Deutsch", - el: "Ελληνικά", - es: "Español", - "es-419": "Español (Latinoamérica)", - fr: "Français", - pt: "Português", - ja: "日本語", - "pt-br": "Português (Brasil)", - zh: "中文", - "zh-hans": "简体中文", - en: "English", -} - const getLanguageCode = (option: CourseRunLanguageOption): string | null => { const normalized = option.language?.trim().toLowerCase().replace(/_/g, "-") return normalized || null @@ -41,19 +25,106 @@ const getLanguageCodeFromOptionKey = (optionKey: string): string | null => { return code || null } +const FALLBACK_NATIVE_LANGUAGE_NAMES: Record = { + ar: "العربية", + de: "Deutsch", + en: "English", + es: "español", + "es-419": "español (Latinoamérica)", + fr: "français", + hi: "हिन्दी", + it: "italiano", + ja: "日本語", + ko: "한국어", + pt: "português", + "pt-br": "português (Brasil)", + ru: "русский", + zh: "中文", + "zh-cn": "简体中文", + "zh-tw": "繁體中文", +} + +const nativeLanguageNameCache = new Map() +let cachedDisplayNamesRef: typeof Intl.DisplayNames | undefined = + Intl.DisplayNames + +const ensureNativeLanguageNameCacheIsFresh = (): void => { + if (Intl.DisplayNames !== cachedDisplayNamesRef) { + cachedDisplayNamesRef = Intl.DisplayNames + nativeLanguageNameCache.clear() + } +} + +const getFallbackNativeLanguageName = (languageCode: string): string | null => { + const exactMatch = FALLBACK_NATIVE_LANGUAGE_NAMES[languageCode] + if (exactMatch) { + return exactMatch + } + + const baseLanguageSubtag = languageCode.split("-")[0] + if (!baseLanguageSubtag) { + return null + } + + return ( + FALLBACK_NATIVE_LANGUAGE_NAMES[baseLanguageSubtag] ?? baseLanguageSubtag + ) +} + +const getNativeLanguageName = (languageCode: string): string => { + ensureNativeLanguageNameCacheIsFresh() + + const normalizedLanguageCode = languageCode.trim().toLowerCase() + const baseLanguageSubtag = normalizedLanguageCode.split("-")[0] + + const cachedLabel = nativeLanguageNameCache.get(normalizedLanguageCode) + if (cachedLabel) { + return cachedLabel + } + + let resolvedLabel: string | null = null + + try { + if (typeof Intl.DisplayNames === "function") { + const displayNames = new Intl.DisplayNames([normalizedLanguageCode], { + type: "language", + }) + const label = displayNames.of(normalizedLanguageCode) + if (label && label.toLowerCase() !== normalizedLanguageCode) { + resolvedLabel = label + } + + if ( + !resolvedLabel && + baseLanguageSubtag && + baseLanguageSubtag !== normalizedLanguageCode + ) { + const baseLabel = displayNames.of(baseLanguageSubtag) + if (baseLabel && baseLabel.toLowerCase() !== baseLanguageSubtag) { + resolvedLabel = baseLabel + } + } + } + } catch { + // Fall through to static fallback labels. + } + + const finalLabel = + resolvedLabel ?? + getFallbackNativeLanguageName(normalizedLanguageCode) ?? + normalizedLanguageCode + + nativeLanguageNameCache.set(normalizedLanguageCode, finalLabel) + return finalLabel +} + const getLanguageOptionLabel = (option: CourseRunLanguageOption): string => { const languageCode = getLanguageCode(option) if (!languageCode) { return "" } - const exact = LANGUAGE_CODE_TO_NATIVE_NAME[languageCode] - if (exact) { - return exact - } - - const baseCode = languageCode.split("-")[0] - return LANGUAGE_CODE_TO_NATIVE_NAME[baseCode] ?? languageCode + return getNativeLanguageName(languageCode) } type ExtendedLanguageOption = CourseRunLanguageOption & { From f9e95efb782a5decd89c94923400b36a7df8aa42 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 6 May 2026 12:35:39 +0500 Subject: [PATCH 2/6] feat: add podcast episode page and few fixes in podcast page (#3283) * feat: add podcast episode page and few fixes in podcast page --------- Co-authored-by: Ahtesham Quraish --- .../PodcastPage/PodcastDetailPage.tsx | 68 ++- .../PodcastEpisodeDetailPage.test.tsx | 307 +++++++++++ .../PodcastPage/PodcastEpisodeDetailPage.tsx | 375 ++++++++++++++ .../app-pages/PodcastPage/PodcastPlayer.tsx | 483 ++++++++++-------- .../VideoDetailPage.tsx | 11 +- .../VideoPageHeader.tsx | 23 +- .../VideoPlaylistCollectionPage.tsx | 6 +- .../VideoSeriesDetailPage.styled.ts | 5 +- .../podcast/{[id] => [podcastId]}/page.tsx | 20 +- .../podcast_episode/[episodeId]/page.tsx | 65 +++ frontends/main/src/common/urls.ts | 11 +- .../CallToActionSection.tsx | 10 + 12 files changed, 1138 insertions(+), 246 deletions(-) create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx rename frontends/main/src/app/podcast/{[id] => [podcastId]}/page.tsx (69%) create mode 100644 frontends/main/src/app/podcast/[podcastId]/podcast_episode/[episodeId]/page.tsx diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx index 0b1bcc9810..6f50c45679 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -1,12 +1,12 @@ "use client" -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useRef } from "react" import { Breadcrumbs, Typography, styled, useMediaQuery } from "ol-components" import type { Theme } from "ol-components" import { Button, ActionButton } from "@mitodl/smoot-design" -import { RiPlayFill } from "@remixicon/react" +import { RiPlayFill, RiPauseFill } from "@remixicon/react" import PodcastPlayer, { PLAYER_HEIGHT } from "./PodcastPlayer" -import type { PodcastTrack } from "./PodcastPlayer" +import type { PodcastTrack, PodcastPlayerHandle } from "./PodcastPlayer" import { useLearningResourcesDetail, useInfiniteLearningResourceItems, @@ -156,13 +156,18 @@ const EpisodeList = styled.ul({ gridTemplateColumns: "1fr", }) -const EpisodeRow = styled.li(({ theme }) => ({ +const EpisodeRow = styled("li", { + shouldForwardProp: (prop) => prop !== "isEpisodePage", +})<{ isEpisodePage?: boolean }>(({ theme, isEpisodePage }) => ({ margin: 0, display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between", - padding: "28px 16px", + padding: !isEpisodePage ? "28px 16px" : "28px 0px", + ...(isEpisodePage && { + "&:first-of-type": { paddingTop: 0, boxShadow: "none" }, + }), boxShadow: `0 -1px 0 ${theme.custom.colors.lightGray2}`, gap: "16px", "&:last-child": { @@ -199,7 +204,6 @@ const EpisodeTitleLink = styled.span(({ theme }) => ({ color: theme.custom.colors.darkGray2, textDecoration: "none", display: "block", - marginBottom: "8px", fontSize: "18px", fontStyle: "normal", fontWeight: theme.typography.fontWeightBold, @@ -207,6 +211,10 @@ const EpisodeTitleLink = styled.span(({ theme }) => ({ })) const StyledButton = styled(Button)(({ theme }) => ({ + padding: "16px 20px", + ...theme.typography.body1, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "16px", [theme.breakpoints.down("sm")]: { width: "100%", }, @@ -225,8 +233,13 @@ const StyledShowMore = styled(Button)(({ theme }) => ({ }, })) +const StyledIcon = styled(RiPlayFill)({ + width: "24px !important", + height: "24px !important", +}) + const BreadcrumbBar = styled.div(({ theme }) => ({ - padding: "32px 0 16px 0", + padding: "20px 0 4px 0", borderBottom: `2px solid ${theme.custom.colors.red}`, [theme.breakpoints.down("sm")]: { padding: "16px 0 0px 0", @@ -254,7 +267,7 @@ const StyledDot = styled.span(({ theme }) => ({ })) const PageSection = styled.div(({ theme }) => ({ - backgroundColor: theme.custom.colors.lightGray1, + backgroundColor: theme.custom.colors.white, })) const EpisodeMeta = styled(Typography)(({ theme }) => ({ @@ -269,7 +282,10 @@ const PlayButton = styled(ActionButton, { isPlaying: boolean }>(({ theme, isPlaying }) => [ { + width: "48px", + height: "48px", color: theme.custom.colors.darkGray2, + backgroundColor: theme.custom.colors.white, borderColor: "currentColor", "&:hover:not(:disabled)": { color: theme.custom.colors.red, @@ -287,18 +303,22 @@ const PlayButton = styled(ActionButton, { /* ── Episode row component ── */ -type EpisodeItemProps = { +export type EpisodeItemProps = { episode: LearningResource onPlayClick: (episode: LearningResource) => void + onPauseClick?: () => void isPlaying: boolean isPlayable: boolean + isEpisodePage?: boolean } -const EpisodeItem: React.FC = ({ +export const EpisodeItem: React.FC = ({ episode, onPlayClick, + onPauseClick, isPlaying, isPlayable, + isEpisodePage = false, }) => { const podcastEpisode = episode.resource_type === "podcast_episode" ? episode.podcast_episode : null @@ -314,7 +334,10 @@ const EpisodeItem: React.FC = ({ const metaParts = [duration ? `${duration} min` : null, date].filter(Boolean) return ( - onPlayClick(episode)}> + (isPlaying ? onPauseClick?.() : onPlayClick(episode))} + isEpisodePage={isEpisodePage} + > {episode.title} @@ -333,13 +356,15 @@ const EpisodeItem: React.FC = ({ )} - + {isPlaying ? : } @@ -366,6 +391,8 @@ export const PodcastDetailPage: React.FC = ({ const [playingEpisode, setPlayingEpisode] = useState( null, ) + const [isAudioPlaying, setIsAudioPlaying] = useState(false) + const playerRef = useRef(null) const { data: resource } = useLearningResourcesDetail(id) @@ -425,7 +452,11 @@ export const PodcastDetailPage: React.FC = ({ const handlePlayClick = (episode: LearningResource) => { if (!getEpisodeAudioUrl(episode)) return - setPlayingEpisode(episode) + if (playingEpisode?.id === episode.id) { + playerRef.current?.resume() + } else { + setPlayingEpisode(episode) + } } const currentTrack: PodcastTrack | null = playingEpisode @@ -517,7 +548,7 @@ export const PodcastDetailPage: React.FC = ({ handlePlayClick(latestEpisode)} variant="primary" - startIcon={} + startIcon={} disabled={!getEpisodeAudioUrl(latestEpisode)} > Play Latest Episode @@ -540,7 +571,10 @@ export const PodcastDetailPage: React.FC = ({ key={episode.id} episode={episode} onPlayClick={handlePlayClick} - isPlaying={playingEpisode?.id === episode.id} + onPauseClick={() => playerRef.current?.pause()} + isPlaying={ + playingEpisode?.id === episode.id && isAudioPlaying + } isPlayable={Boolean(getEpisodeAudioUrl(episode))} /> ))} @@ -568,8 +602,10 @@ export const PodcastDetailPage: React.FC = ({ {currentTrack && ( 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..df3dfee78a --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.test.tsx @@ -0,0 +1,307 @@ +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 { episode, podcast } = setupApis() + + 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..a5dc5429d4 --- /dev/null +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEpisodeDetailPage.tsx @@ -0,0 +1,375 @@ +"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 } 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), + ) ?? [] + 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 handlePlay = () => { + if (episode && 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)} + > + Play Episode + + )} + + {episode?.description && ( + + )} + + + + + More from {podcast?.title ?? "Podcast"} + + {episodes && episodes.length > 0 && ( + + {episodes.map((episode) => ( + { + if (!getAudioUrl(ep)) return + if (playingEpisode?.id === ep.id) { + playerRef.current?.resume() + } else { + setPlayingEpisode(ep) + } + }} + onPauseClick={() => playerRef.current?.pause()} + isPlayable={Boolean(getAudioUrl(episode))} + isEpisodePage + /> + ))} + + )} + {podcastId && ( + View all episodes → + )} + + + + {currentTrack && ( + setPlayingEpisode(null)} + onPlayStateChange={setIsAudioPlaying} + /> + )} + + ) +} diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx index ec5cd6597b..0a5baf1ed1 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx @@ -1,6 +1,13 @@ "use client" -import React, { useRef, useState, useEffect, useCallback } from "react" +import React, { + useRef, + useState, + useEffect, + useCallback, + forwardRef, + useImperativeHandle, +} from "react" import { styled, Typography, LoadingSpinner } from "ol-components" import { RiPlayCircleLine, @@ -21,6 +28,11 @@ export type PodcastTrack = { podcastName: string } +export type PodcastPlayerHandle = { + pause: () => void + resume: () => void +} + type PodcastPlayerProps = { track: PodcastTrack onClose: () => void @@ -42,7 +54,7 @@ const PlayerShell = styled.div(({ theme }) => ({ gap: "24px", padding: "32px", background: theme.custom.colors.white, - borderTop: `2px solid ${theme.custom.colors.mitRed}`, + borderTop: `2px solid ${theme.custom.colors.red}`, boxShadow: "0 -4px 16px rgba(0,0,0,0.12)", [theme.breakpoints.down("sm")]: { gridTemplateColumns: "minmax(0, 1fr) auto", @@ -94,7 +106,7 @@ const IconButton = styled.button(({ theme }) => ({ display: "flex", alignItems: "center", color: theme.custom.colors.silverGray, - "&:hover": { color: theme.custom.colors.mitRed }, + "&:hover": { color: theme.custom.colors.red }, "& svg": { width: "24px", height: "24px", @@ -114,16 +126,27 @@ const PlayPauseButton = styled.button(({ theme }) => ({ border: "none", cursor: "pointer", padding: 0, + // Fixed size + overflow:hidden keeps the spinner clipped inside the button. + // The spinner is absolutely centered; play/pause icons fill the same area. + position: "relative", display: "flex", alignItems: "center", - color: theme.custom.colors.mitRed, + justifyContent: "center", + width: "64px", + height: "64px", + flexShrink: 0, + overflow: "hidden", + color: theme.custom.colors.red, "&:hover": { opacity: 0.8 }, - "& svg": { + // Target only direct SVG children (Remix icons) — not the spinner's SVG. + "& > svg": { width: "64px", height: "64px", }, [theme.breakpoints.down("sm")]: { - "& svg": { + width: "56px", + height: "56px", + "& > svg": { width: "56px", height: "56px", }, @@ -157,6 +180,13 @@ const PodcastName = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +const PodcastPlayerLoader = styled(LoadingSpinner)({ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + const ProgressWrapper = styled.div(({ theme }) => ({ gridArea: "progress", display: "flex", @@ -179,20 +209,20 @@ const ProgressRange = styled.input<{ percent: number }>( outline: "none", border: "none", padding: 0, - background: `linear-gradient(to right, ${theme.custom.colors.mitRed} ${percent}%, ${theme.custom.colors.lightGray2} ${percent}%)`, + background: `linear-gradient(to right, ${theme.custom.colors.red} ${percent}%, ${theme.custom.colors.lightGray2} ${percent}%)`, "&::-webkit-slider-thumb": { WebkitAppearance: "none", width: "14px", height: "14px", borderRadius: "50%", - background: theme.custom.colors.mitRed, + background: theme.custom.colors.red, cursor: "pointer", }, "&::-moz-range-thumb": { width: "14px", height: "14px", borderRadius: "50%", - background: theme.custom.colors.mitRed, + background: theme.custom.colors.red, border: "none", cursor: "pointer", }, @@ -211,8 +241,8 @@ const SpeedButton = styled.button(({ theme }) => ({ color: theme.custom.colors.darkGray2, flexShrink: 0, "&:hover": { - borderColor: theme.custom.colors.mitRed, - color: theme.custom.colors.mitRed, + borderColor: theme.custom.colors.red, + color: theme.custom.colors.red, }, [theme.breakpoints.down("sm")]: { justifySelf: "end", @@ -229,7 +259,15 @@ const CloseButton = styled.button(({ theme }) => ({ alignItems: "center", color: theme.custom.colors.darkGray2, flexShrink: 0, - "&:hover": { color: theme.custom.colors.mitRed }, + "&:hover": { + backgroundColor: theme.custom.colors.red, + color: theme.custom.colors.white, + width: "32px", + height: "32px", + alignItems: "center", + justifyContent: "center", + borderRadius: "4px", + }, justifySelf: "end", })) @@ -245,216 +283,235 @@ const formatTime = (seconds: number): string => { // ─── 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) +const PodcastPlayer = forwardRef( + ({ track, onClose, onPlayStateChange }, ref) => { + 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) + } } - } catch { - if (playAttemptIdRef.current === attemptId) { - setIsPlaying(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 } - } finally { - if (playAttemptIdRef.current === attemptId) { - isPlayPendingRef.current = false - setIsPlayPending(false) + + const audio = audioRef.current + if (!audio) return + audio.load() + audio.playbackRate = SPEED_OPTIONS[speedIndexRef.current] + void startPlayback() + }, [track.audioUrl, hasAudioSource, startPlayback]) + + useImperativeHandle( + ref, + () => ({ + pause: () => { + const audio = audioRef.current + if (audio) { + audio.pause() + setIsPlaying(false) + } + }, + resume: () => { + void startPlayback() + }, + }), + [startPlayback], + ) + + const handlePlayPause = async () => { + if (!hasAudioSource) return + + const audio = audioRef.current + if (!audio) return + + if (isPlaying) { + audio.pause() + setIsPlaying(false) + } else { + void startPlayback() } } - }, [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]) + 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 handlePlayPause = async () => { - if (!hasAudioSource) return + const handleSpeedCycle = () => { + const nextIndex = (speedIndex + 1) % SPEED_OPTIONS.length + speedIndexRef.current = nextIndex + setSpeedIndex(nextIndex) + if (audioRef.current) { + audioRef.current.playbackRate = SPEED_OPTIONS[nextIndex] + } + } - const audio = audioRef.current - if (!audio) return + const handleSeekKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return - if (isPlaying) { - audio.pause() - setIsPlaying(false) - } else { - void startPlayback() + event.preventDefault() + handleSkip(event.key === "ArrowRight" ? 5 : -5) } - } - - 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 percent = duration ? (currentTime / duration) * 100 : 0 + return ( + <> + {/* Shared audio element */} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +