feat(player): a video's related videos, on demand beside it

The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
This commit is contained in:
2026-10-24 16:57:52 +02:00
parent 8b37c470ba
commit 686c673fb2
18 changed files with 697 additions and 19 deletions
+67 -9
View File
@@ -23,6 +23,7 @@ import {
CheckCheck,
ExternalLink,
Info,
ListVideo,
Maximize,
Minimize,
Minimize2,
@@ -83,6 +84,10 @@ import { SURFACE } from "../../components/ui/surfaces";
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
import { SectionLabel } from "../../components/ui/Headings";
import { useMediaQuery } from "../../lib/useMediaQuery";
import Modal from "../../components/ui/Modal";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { RelatedVideos } from "./RelatedVideos";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -669,6 +674,17 @@ export default function PlayerModal({
// a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player
// rather than off-screen; `flip` still rescues it when there is no room up there.
const [showDesc, setShowDesc] = useState(false);
// The related videos, on demand (the owner's pick: the modal has little room to spare). Wide, they
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [relatedOpen, setRelatedOpen] = useState(false);
const panelTabs = useTabPanel();
const playRelated = (v: Video, startAt: number) => {
setRelatedOpen(false);
loadVideo(v.id, startAt);
};
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
@@ -1245,6 +1261,29 @@ export default function PlayerModal({
<Info />
</IconButton>
)}
{!wide && (
<IconButton
variant="ghost"
onClick={() => setRelatedOpen(true)}
aria-expanded={relatedOpen}
label={t("player.related.title")}
>
<ListVideo />
</IconButton>
)}
{/* Inside the card, whose click stops at its edge: a portal's events still bubble
along the React tree, and from the backdrop a click on a row closed the player. */}
{relatedOpen && !wide && (
<Modal
title={t("player.related.title")}
onClose={() => setRelatedOpen(false)}
placement="drawer"
maxWidth="max-w-sm"
initialFocus="close"
>
<RelatedVideos videoId={active.id} onPlay={playRelated} />
</Modal>
)}
{navigated && (
<Button
variant="ghost"
@@ -1455,16 +1494,35 @@ export default function PlayerModal({
)}
</div>
{wide && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
<div className="mt-5">
<Tabs
tabs={[
{ id: "description", label: t("player.description") },
{ id: "related", label: t("player.related.title") },
]}
active={panelTab}
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
idBase={panelTabs.idBase}
label={t("player.panelTabs")}
className="mb-3"
/>
</section>
<div {...panelTabs.panelProps(panelTab)}>
{panelTab === "description" ? (
<section data-testid="player-description">
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
/>
</section>
) : (
// Out into the panel's padding: in 20rem, every rem is the title's.
<div className="-mx-3">
<RelatedVideos videoId={active.id} onPlay={playRelated} />
</div>
)}
</div>
</div>
)}
</div>
</div>