From f7e8da7dbf4129489452393b7cf6b996521447df Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:51:58 -0400 Subject: [PATCH 1/8] Update dependency opensearch-py to v3 (#2764) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09a121249a..0c84324b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ "onnxruntime==1.22.1", "openai>=2.0.0,<3", "opensearch-dsl>=2.0.0,<3", - "opensearch-py>=2.0.0,<3", + "opensearch-py>=3.1,<4", "opentelemetry-instrumentation-celery>=0.52b0", "opentelemetry-instrumentation-django>=0.52b0", "opentelemetry-instrumentation-psycopg>=0.52b0", diff --git a/uv.lock b/uv.lock index 819da5272f..876a498ca9 100644 --- a/uv.lock +++ b/uv.lock @@ -2718,7 +2718,7 @@ requires-dist = [ { name = "opencv-python", specifier = ">=4.12.0.88,<5" }, { name = "opendataloader-pdf", specifier = ">=1.3.0,<2" }, { name = "opensearch-dsl", specifier = ">=2.0.0,<3" }, - { name = "opensearch-py", specifier = ">=2.0.0,<3" }, + { name = "opensearch-py", specifier = ">=3.1,<4" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-django", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.52b0" }, @@ -3171,20 +3171,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/28/1b06a9314f49892843f1559fb47c30d17771ba838ee01a3c0c638832101a/opensearch_dsl-2.1.0-py2.py3-none-any.whl", hash = "sha256:31559b738b48ed5abe87b357205a040fa1dc64042a6454ad2d6854050d911ba0", size = 63775, upload-time = "2023-03-21T23:43:54.287Z" }, ] +[[package]] +name = "opensearch-protobufs" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e2/8a09dbdbfe51e30dfecb625a0f5c524a53bfa4b1fba168f73ac85621dba2/opensearch_protobufs-0.19.0-py3-none-any.whl", hash = "sha256:5137c9c2323cc7debb694754b820ca4cfb5fc8eb180c41ff125698c3ee11bfc2", size = 39778, upload-time = "2025-09-29T20:05:52.379Z" }, +] + [[package]] name = "opensearch-py" -version = "2.8.0" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "events" }, + { name = "opensearch-protobufs" }, { name = "python-dateutil" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/e4/192c97ca676c81f69e138a22e10fb03f64e14a55633cb2acffb41bf6d061/opensearch_py-2.8.0.tar.gz", hash = "sha256:6598df0bc7a003294edd0ba88a331e0793acbb8c910c43edf398791e3b2eccda", size = 237923, upload-time = "2024-11-29T21:06:02.952Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/9f/d4969f7e8fa221bfebf254cc3056e7c743ce36ac9874e06110474f7c947d/opensearch_py-3.1.0.tar.gz", hash = "sha256:883573af13175ff102b61c80b77934a9e937bdcc40cda2b92051ad53336bc055", size = 258616, upload-time = "2025-11-20T16:37:36.777Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/35/a957c6fb88ff6874996be688448b889475cf0ea978446cd5a30e764e0561/opensearch_py-2.8.0-py3-none-any.whl", hash = "sha256:52c60fdb5d4dcf6cce3ee746c13b194529b0161e0f41268b98ab8f1624abe2fa", size = 353492, upload-time = "2024-11-29T21:05:56.075Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/293c8ad81768ad625283d960685bde07c6302abf20a685e693b48ab6eb91/opensearch_py-3.1.0-py3-none-any.whl", hash = "sha256:e5af83d0454323e6ea9ddee8c0dcc185c0181054592d23cb701da46271a3b65b", size = 385729, upload-time = "2025-11-20T16:37:34.941Z" }, ] [[package]] From fd98a20076932cb6119287a565c4ef405e988e05 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:52:36 -0400 Subject: [PATCH 2/8] Update dependency onnxruntime to v1.24.4 (#2742) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 38 +++++++------------------------------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c84324b29..3c6faebb32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ dependencies = [ "nested-lookup>=0.2.25,<0.3", "nh3>=0.3.0,<0.4", "ocw-data-parser>=0.35.1,<0.36", - "onnxruntime==1.22.1", + "onnxruntime==1.24.4", "openai>=2.0.0,<3", "opensearch-dsl>=2.0.0,<3", "opensearch-py>=3.1,<4", diff --git a/uv.lock b/uv.lock index 876a498ca9..f345c638d3 100644 --- a/uv.lock +++ b/uv.lock @@ -499,18 +499,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - [[package]] name = "configargparse" version = "1.7.5" @@ -1705,18 +1693,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, ] -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, -] - [[package]] name = "hyperframe" version = "6.1.0" @@ -2713,7 +2689,7 @@ requires-dist = [ { name = "nested-lookup", specifier = ">=0.2.25,<0.3" }, { name = "nh3", specifier = ">=0.3.0,<0.4" }, { name = "ocw-data-parser", specifier = ">=0.35.1,<0.36" }, - { name = "onnxruntime", specifier = "==1.22.1" }, + { name = "onnxruntime", specifier = "==1.24.4" }, { name = "openai", specifier = ">=2.0.0,<3" }, { name = "opencv-python", specifier = ">=4.12.0.88,<5" }, { name = "opendataloader-pdf", specifier = ">=1.3.0,<2" }, @@ -3095,10 +3071,9 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.22.1" +version = "1.24.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, @@ -3106,10 +3081,11 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/48/70/ca2a4d38a5deccd98caa145581becb20c53684f451e89eb3a39915620066/onnxruntime-1.22.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:a938d11c0dc811badf78e435daa3899d9af38abee950d87f3ab7430eb5b3cf5a", size = 34342883, upload-time = "2025-07-10T19:15:38.223Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/00b099b4d4f6223b610421080d0eed9327ef9986785c9141819bbba0d396/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984cea2a02fcc5dfea44ade9aca9fe0f7a8a2cd6f77c258fc4388238618f3928", size = 14473861, upload-time = "2025-07-10T19:15:42.911Z" }, - { url = "https://files.pythonhosted.org/packages/0a/50/519828a5292a6ccd8d5cd6d2f72c6b36ea528a2ef68eca69647732539ffa/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d39a530aff1ec8d02e365f35e503193991417788641b184f5b1e8c9a6d5ce8d", size = 16475713, upload-time = "2025-07-10T19:15:45.452Z" }, - { url = "https://files.pythonhosted.org/packages/5d/54/7139d463bb0a312890c9a5db87d7815d4a8cce9e6f5f28d04f0b55fcb160/onnxruntime-1.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a64291d57ea966a245f749eb970f4fa05a64d26672e05a83fdb5db6b7d62f87", size = 12690910, upload-time = "2025-07-10T19:15:47.478Z" }, + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, ] [[package]] From 2266bf6f2343a0bd7da75df0982bc9b7cf20f978 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:22:32 -0400 Subject: [PATCH 3/8] Update nginx Docker tag to v1.29.7 (#2986) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- nginx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 7664c8cf69..6b46a563f2 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -3,7 +3,7 @@ # it's primary purpose is to emulate heroku-buildpack-nginx's # functionality that compiles config/nginx.conf.erb # See https://github.com/heroku/heroku-buildpack-nginx/blob/fefac6c569f28182b3459cb8e34b8ccafc403fde/bin/start-nginx -FROM nginx:1.29.3 +FROM nginx:1.29.7@sha256:1854da86e82d5dfb49a8f3d78b099adcc7e36608b207146ed95cd47937938a40 # Logs are configured to a relatic path under /etc/nginx # but the container expects /var/log From 1b1808d3b00a812fcb38aa1fc6cf16260f9b388c Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Tue, 21 Apr 2026 17:11:14 -0400 Subject: [PATCH 4/8] Revert "Update dependency opensearch-py to v3 (#2764)" (#3230) This reverts commit f7e8da7dbf4129489452393b7cf6b996521447df. --- pyproject.toml | 2 +- uv.lock | 21 ++++----------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c6faebb32..11f9d3ca72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ "onnxruntime==1.24.4", "openai>=2.0.0,<3", "opensearch-dsl>=2.0.0,<3", - "opensearch-py>=3.1,<4", + "opensearch-py>=2.0.0,<3", "opentelemetry-instrumentation-celery>=0.52b0", "opentelemetry-instrumentation-django>=0.52b0", "opentelemetry-instrumentation-psycopg>=0.52b0", diff --git a/uv.lock b/uv.lock index f345c638d3..657218ae6c 100644 --- a/uv.lock +++ b/uv.lock @@ -2694,7 +2694,7 @@ requires-dist = [ { name = "opencv-python", specifier = ">=4.12.0.88,<5" }, { name = "opendataloader-pdf", specifier = ">=1.3.0,<2" }, { name = "opensearch-dsl", specifier = ">=2.0.0,<3" }, - { name = "opensearch-py", specifier = ">=3.1,<4" }, + { name = "opensearch-py", specifier = ">=2.0.0,<3" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-django", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.52b0" }, @@ -3147,33 +3147,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/28/1b06a9314f49892843f1559fb47c30d17771ba838ee01a3c0c638832101a/opensearch_dsl-2.1.0-py2.py3-none-any.whl", hash = "sha256:31559b738b48ed5abe87b357205a040fa1dc64042a6454ad2d6854050d911ba0", size = 63775, upload-time = "2023-03-21T23:43:54.287Z" }, ] -[[package]] -name = "opensearch-protobufs" -version = "0.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e2/8a09dbdbfe51e30dfecb625a0f5c524a53bfa4b1fba168f73ac85621dba2/opensearch_protobufs-0.19.0-py3-none-any.whl", hash = "sha256:5137c9c2323cc7debb694754b820ca4cfb5fc8eb180c41ff125698c3ee11bfc2", size = 39778, upload-time = "2025-09-29T20:05:52.379Z" }, -] - [[package]] name = "opensearch-py" -version = "3.1.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "events" }, - { name = "opensearch-protobufs" }, { name = "python-dateutil" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/9f/d4969f7e8fa221bfebf254cc3056e7c743ce36ac9874e06110474f7c947d/opensearch_py-3.1.0.tar.gz", hash = "sha256:883573af13175ff102b61c80b77934a9e937bdcc40cda2b92051ad53336bc055", size = 258616, upload-time = "2025-11-20T16:37:36.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/e4/192c97ca676c81f69e138a22e10fb03f64e14a55633cb2acffb41bf6d061/opensearch_py-2.8.0.tar.gz", hash = "sha256:6598df0bc7a003294edd0ba88a331e0793acbb8c910c43edf398791e3b2eccda", size = 237923, upload-time = "2024-11-29T21:06:02.952Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/a1/293c8ad81768ad625283d960685bde07c6302abf20a685e693b48ab6eb91/opensearch_py-3.1.0-py3-none-any.whl", hash = "sha256:e5af83d0454323e6ea9ddee8c0dcc185c0181054592d23cb701da46271a3b65b", size = 385729, upload-time = "2025-11-20T16:37:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/a957c6fb88ff6874996be688448b889475cf0ea978446cd5a30e764e0561/opensearch_py-2.8.0-py3-none-any.whl", hash = "sha256:52c60fdb5d4dcf6cce3ee746c13b194529b0161e0f41268b98ab8f1624abe2fa", size = 353492, upload-time = "2024-11-29T21:05:56.075Z" }, ] [[package]] From 0855c55e6fa2489462650abf08974069188be8c0 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 21 Apr 2026 18:14:56 -0400 Subject: [PATCH 5/8] remove default slide transition from dialogs (#3229) * remove default slide transition from dialogs * skip dialog transition during tests --- .../ProductPages/StayUpdatedModal.test.tsx | 18 +++++++------ .../CourseEnrollmentDialog.test.tsx | 8 ++++-- .../ProgramEnrollmentDialog.test.tsx | 8 ++++-- .../ManageListDialogs.test.tsx | 25 ++++++++++++------- .../src/components/Dialog/Dialog.tsx | 24 +----------------- .../src/components/FormDialog/FormDialog.tsx | 3 +++ 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx index c912c9fd1b..102cc2204d 100644 --- a/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/StayUpdatedModal.test.tsx @@ -2,7 +2,7 @@ import React from "react" import * as NiceModal from "@ebay/nice-modal-react" import { HubspotForm, type HubspotFormProps } from "ol-components" import { setMockResponse, urls, factories } from "api/test-utils" -import { renderWithProviders, screen, user, act } from "@/test-utils" +import { renderWithProviders, screen, user, act, waitFor } from "@/test-utils" import { StayUpdatedModal } from "./StayUpdatedModal" import { STAY_UPDATED_FORM_ID } from "./test-utils/stayUpdated" @@ -133,9 +133,11 @@ describe("StayUpdatedModal", () => { const doneButton = await screen.findByRole("button", { name: "Done" }) await user.click(doneButton) - expect( - screen.queryByRole("dialog", { name: "Stay Updated" }), - ).not.toBeInTheDocument() + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "Stay Updated" }), + ).not.toBeInTheDocument() + }) }) it("closes the dialog when 'Cancel' is clicked in the form view", async () => { @@ -148,9 +150,11 @@ describe("StayUpdatedModal", () => { await screen.findByRole("dialog", { name: "Stay Updated" }) await user.click(screen.getByRole("button", { name: "Cancel" })) - expect( - screen.queryByRole("dialog", { name: "Stay Updated" }), - ).not.toBeInTheDocument() + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "Stay Updated" }), + ).not.toBeInTheDocument() + }) }) it("shows error message when form submission fails", async () => { diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx index 95d85571e6..4591fd3d6a 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -425,7 +425,9 @@ describe("CourseEnrollmentDialog", () => { ) // Verify dialog has closed - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Custom onCourseEnroll: calls callback instead of redirecting", async () => { @@ -456,7 +458,9 @@ describe("CourseEnrollmentDialog", () => { ) // Verify dialog has closed - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Shows error message when enrollment fails", async () => { diff --git a/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx index d680826105..a42c25d386 100644 --- a/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx +++ b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx @@ -159,7 +159,9 @@ describe("ProgramEnrollmentDialog", () => { expect(location.current.searchParams.get("enrollment_title")).toBe( program.title, ) - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Custom onProgramEnroll: calls callback instead of redirecting", async () => { @@ -190,7 +192,9 @@ describe("ProgramEnrollmentDialog", () => { expect(`${location.current.pathname}${location.current.search}`).toBe( initialLocation, ) - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Shows error message when enrollment fails", async () => { diff --git a/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx b/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx index 9bc6d96d2c..9e393c85ee 100644 --- a/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx +++ b/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx @@ -106,14 +106,16 @@ describe("manageListDialogs.upsertLearningPath", () => { setup({ resource: factories.learningResources.learningPath(), }) - const dialog = screen.getByRole("dialog") + screen.getByRole("dialog") await user.click(inputs.cancel()) expect(makeRequest).not.toHaveBeenCalledWith( "patch", expect.anything(), expect.anything(), ) - expect(dialog).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Validates required fields", async () => { @@ -258,14 +260,16 @@ describe("manageListDialogs.upsertUserList", () => { setup({ userList: factories.userLists.userList(), }) - const dialog = screen.getByRole("dialog") + screen.getByRole("dialog") await user.click(inputs.cancel()) expect(makeRequest).not.toHaveBeenCalledWith( "patch", expect.anything(), expect.anything(), ) - expect(dialog).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) test("Validates required fields", async () => { @@ -401,12 +405,13 @@ describe("manageListDialogs.destroyLearningPath", () => { test("Clicking cancel does not delete list", async () => { setup() - const dialog = screen.getByRole("dialog") + screen.getByRole("dialog") await user.click(inputs.cancel()) expect(makeRequest).not.toHaveBeenCalled() - - expect(dialog).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) }) @@ -443,10 +448,12 @@ describe("manageListDialogs.destroyUserList", () => { test("Clicking cancel does not delete list", async () => { setup() - const dialog = screen.getByRole("dialog") + screen.getByRole("dialog") await user.click(inputs.cancel()) expect(makeRequest).not.toHaveBeenCalled() - expect(dialog).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) }) }) diff --git a/frontends/ol-components/src/components/Dialog/Dialog.tsx b/frontends/ol-components/src/components/Dialog/Dialog.tsx index 5cd7dd0432..e2196c66b4 100644 --- a/frontends/ol-components/src/components/Dialog/Dialog.tsx +++ b/frontends/ol-components/src/components/Dialog/Dialog.tsx @@ -8,8 +8,6 @@ import { Button, ActionButton } from "@mitodl/smoot-design" import MuiDialogActions from "@mui/material/DialogActions" import { RiCloseLine } from "@remixicon/react" import Typography from "@mui/material/Typography" -import Slide from "@mui/material/Slide" -import { TransitionProps } from "@mui/material/transitions" const Close = styled.div` position: absolute; @@ -40,24 +38,6 @@ const DialogActions = styled(MuiDialogActions)` } ` -const Transition = React.forwardRef( - ( - props: TransitionProps & { - children: React.ReactElement - }, - ref: React.Ref, - ) => { - return ( - - ) - }, -) - type DialogProps = { className?: string contentCss?: CSSObject @@ -146,10 +126,8 @@ const Dialog: React.FC = ({ paper: PaperProps, transition: TransitionProps, }} - slots={{ - transition: Transition, - }} aria-labelledby={titleId} + transitionDuration={process.env.NODE_ENV === "test" ? 0 : undefined} maxWidth={maxWidth} scroll={scroll} > diff --git a/frontends/ol-components/src/components/FormDialog/FormDialog.tsx b/frontends/ol-components/src/components/FormDialog/FormDialog.tsx index a1dc729d89..29aa4647bb 100644 --- a/frontends/ol-components/src/components/FormDialog/FormDialog.tsx +++ b/frontends/ol-components/src/components/FormDialog/FormDialog.tsx @@ -64,6 +64,7 @@ interface FormDialogProps { className?: string maxWidth?: DialogProps["maxWidth"] disabled?: boolean + TransitionProps?: DialogProps["TransitionProps"] } /** @@ -91,6 +92,7 @@ const FormDialog: React.FC = ({ className, maxWidth, disabled = false, + TransitionProps, }) => { const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit: React.FormEventHandler = useCallback( @@ -137,6 +139,7 @@ const FormDialog: React.FC = ({ actions={actions} maxWidth={maxWidth} disabled={isSubmitting || disabled} + TransitionProps={TransitionProps} > {children} From f6fdcff6f268471eeef20cbe6d1335285b08a6c3 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 22 Apr 2026 19:39:46 +0500 Subject: [PATCH 6/8] feat: implementing the podcast detail page (#3184) podcast detail page --------- Co-authored-by: Ahtesham Quraish --- frontends/api/src/clients.ts | 1 + .../api/src/hooks/learningResources/index.ts | 14 + .../src/hooks/learningResources/queries.ts | 39 +- .../PodcastPage/PodcastContainer.tsx | 14 + .../PodcastPage/PodcastDetailPage.test.tsx | 217 +++++++ .../PodcastPage/PodcastDetailPage.tsx | 560 ++++++++++++++++++ .../PodcastPage/PodcastPlayer.test.tsx | 338 +++++++++++ .../app-pages/PodcastPage/PodcastPlayer.tsx | 451 ++++++++++++++ frontends/main/src/app/podcast/[id]/page.tsx | 48 ++ frontends/main/src/common/feature_flags.ts | 1 + 10 files changed, 1682 insertions(+), 1 deletion(-) create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastContainer.tsx create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastPlayer.test.tsx create mode 100644 frontends/main/src/app-pages/PodcastPage/PodcastPlayer.tsx create mode 100644 frontends/main/src/app/podcast/[id]/page.tsx 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 */} +