feat(player): a roomier YouTube player, its panel shut on demand
- The full-height previous/next strips beside the card are gone: the panel's transport row and Shift+arrows step on their own, and the video gains the 7rem the strips' gutters took. - Close is one icon in the card's top-right corner (the wide panel's head, the stacked card's title row). - The panel's description is cut to three lines with More / Less (ClampedText now takes the linked text as children). - Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for a bigger video; it stays shut while the queue steps on, and a video opened from a card starts it open. Focus moves to the counterpart toggle. - The wheel has one meaning per place: over a panel (or the stacked card) with something to scroll it only scrolls, never falling through to the volume at the end; over the video, or a panel that fits, it is the volume. - A long channel name is cut on the panel's line instead of running the 20rem panel into a sideways scroll.
This commit is contained in:
@@ -21,14 +21,16 @@ import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
@@ -74,7 +76,6 @@ import {
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
@@ -105,6 +106,8 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
// a rem width, so it grows with the text size; the video takes what is left.
|
||||
const WIDE_PLAYER_PX = 1280;
|
||||
const SIDE_PANEL = "20rem";
|
||||
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
|
||||
const PANEL_STRIP = "2.75rem";
|
||||
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
||||
const MIN_VIDEO = "36rem";
|
||||
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
|
||||
@@ -248,6 +251,23 @@ export default function PlayerModal({
|
||||
// move into a side panel beside the video, so the video takes the window's whole height — at
|
||||
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
|
||||
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
|
||||
// The wide panel shut to a strip, for a bigger video. This player's own state: it holds while the
|
||||
// queue steps on (a next video plays in this same modal), and a video opened from a card mounts a
|
||||
// fresh player, which starts with the panel open (owner).
|
||||
const [panelShut, setPanelShut] = useState(false);
|
||||
// The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus
|
||||
// goes to its counterpart, not to <body>, where the player's keys would no longer reach it.
|
||||
const panelToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const refocusToggle = useRef(false);
|
||||
const togglePanel = (shut: boolean) => {
|
||||
refocusToggle.current = true;
|
||||
setPanelShut(shut);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!refocusToggle.current) return;
|
||||
refocusToggle.current = false;
|
||||
panelToggleRef.current?.focus();
|
||||
}, [panelShut]);
|
||||
useLayoutEffect(() => {
|
||||
const card = cardRef.current;
|
||||
const stage = stageRef.current;
|
||||
@@ -646,9 +666,22 @@ export default function PlayerModal({
|
||||
onLoadVideo: loadVideo,
|
||||
};
|
||||
|
||||
// The card's width budget (its maxWidth, below): the video's, and the side strips' gutters.
|
||||
// The card's width budget (its maxWidth, below): the video's, and the side panel's when wide.
|
||||
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
|
||||
const gutters = hasQueue ? " + 7rem" : "";
|
||||
const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL;
|
||||
// One Close, icon only, in the card's top-right corner: the wide panel's head (or its strip), the
|
||||
// stacked card's title row.
|
||||
const closeButton = (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
label={t("player.close")}
|
||||
title={t("player.closeEsc")}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||
@@ -665,15 +698,13 @@ export default function PlayerModal({
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||||
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
|
||||
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
|
||||
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
|
||||
Hidden on narrow screens where there's no room beside the card. */}
|
||||
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
||||
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
||||
for a second copy of the same two buttons (owner). */}
|
||||
<div
|
||||
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
|
||||
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
|
||||
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
|
||||
className="relative flex w-full max-h-full"
|
||||
// The design width (56rem), narrowed so the 16:9 video plus the chrome under it fits the
|
||||
// window's height less the backdrop's padding (`p-6` = 3rem).
|
||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
|
||||
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
|
||||
// does not scroll there, always holds the whole video.
|
||||
@@ -682,27 +713,15 @@ export default function PlayerModal({
|
||||
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
|
||||
style={{
|
||||
maxWidth: wide
|
||||
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
|
||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
|
||||
? `calc(${fitWidth} + ${panelWidth})`
|
||||
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
|
||||
}}
|
||||
>
|
||||
{hasQueue && (
|
||||
<Pressable
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
disabled={index === 0}
|
||||
aria-label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8" />
|
||||
</Pressable>
|
||||
)}
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
|
||||
data-wheel-scrolls={wide ? undefined : ""}
|
||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||||
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
@@ -710,6 +729,8 @@ export default function PlayerModal({
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||
data-wheel-volume=""
|
||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||||
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
||||
} ${maximized ? "player-stage--max" : ""}`}
|
||||
@@ -891,275 +912,315 @@ export default function PlayerModal({
|
||||
description never makes the card taller than the video. */}
|
||||
<div
|
||||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||
style={wide ? { width: SIDE_PANEL } : undefined}
|
||||
style={wide ? { width: panelWidth } : undefined}
|
||||
>
|
||||
<div
|
||||
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||||
data-testid="player-info"
|
||||
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
|
||||
data-wheel-scrolls={wide ? "" : undefined}
|
||||
>
|
||||
{hasQueue && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
||||
<PlayerTransportControls
|
||||
variant="modal"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
</h2>
|
||||
{!wide && (
|
||||
{wide && panelShut ? (
|
||||
<div
|
||||
data-testid="player-strip"
|
||||
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||||
>
|
||||
{closeButton}
|
||||
<IconButton
|
||||
ref={panelToggleRef}
|
||||
variant="ghost"
|
||||
onClick={() => togglePanel(false)}
|
||||
label={t("player.expandPanel")}
|
||||
aria-expanded={false}
|
||||
>
|
||||
<PanelRightOpen />
|
||||
</IconButton>
|
||||
{hasQueue && (
|
||||
<>
|
||||
<span className="flex-1" />
|
||||
<IconButton
|
||||
ref={setDescAnchor}
|
||||
{...descPop.getReferenceProps()}
|
||||
variant="ghost"
|
||||
aria-expanded={showDesc}
|
||||
label={t("player.description")}
|
||||
onClick={goPrev}
|
||||
disabled={index === 0}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
>
|
||||
<Info />
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
)}
|
||||
{navigated && (
|
||||
<Button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => loadVideo(active.id, null)}
|
||||
title={t("player.backToOriginal")}
|
||||
icon={<ArrowLeft />}
|
||||
onClick={goNext}
|
||||
disabled={index >= queue!.length - 1}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
>
|
||||
{t("player.back")}
|
||||
</Button>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={descPop}
|
||||
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||||
>
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
<PlayerDescription
|
||||
ref={descFadeRef}
|
||||
style={descFadeStyle}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
{...descriptionProps}
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||||
data-testid="player-info"
|
||||
// A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
|
||||
data-wheel-scrolls={wide ? "" : undefined}
|
||||
>
|
||||
{wide && (
|
||||
<div className="flex items-center justify-between px-2 pt-2">
|
||||
<IconButton
|
||||
ref={panelToggleRef}
|
||||
variant="ghost"
|
||||
onClick={() => togglePanel(true)}
|
||||
label={t("player.collapsePanel")}
|
||||
aria-expanded
|
||||
>
|
||||
<PanelRightClose />
|
||||
</IconButton>
|
||||
{closeButton}
|
||||
</div>
|
||||
)}
|
||||
{hasQueue && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
||||
<PlayerTransportControls
|
||||
variant="modal"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
title={t("player.closeEsc")}
|
||||
>
|
||||
{t("player.close")}
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel + meta on one line, with the actions pushed to the right.
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
||||
right (wide, it heads the panel). */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
</h2>
|
||||
{!wide && (
|
||||
<IconButton
|
||||
ref={setDescAnchor}
|
||||
{...descPop.getReferenceProps()}
|
||||
variant="ghost"
|
||||
aria-expanded={showDesc}
|
||||
label={t("player.description")}
|
||||
>
|
||||
<Info />
|
||||
</IconButton>
|
||||
)}
|
||||
{navigated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => loadVideo(active.id, null)}
|
||||
title={t("player.backToOriginal")}
|
||||
icon={<ArrowLeft />}
|
||||
>
|
||||
{t("player.back")}
|
||||
</Button>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={descPop}
|
||||
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||||
>
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
<PlayerDescription
|
||||
ref={descFadeRef}
|
||||
style={descFadeStyle}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
{...descriptionProps}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
{!wide && closeButton}
|
||||
</div>
|
||||
|
||||
{/* Channel + meta on one line, with the actions pushed to the right.
|
||||
When navigated to a linked video we only have its author (from the player),
|
||||
so we show that as plain text and hide feed-video-specific bits.
|
||||
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
|
||||
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
|
||||
one-word column between the name and the buttons. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||||
{!navigated && (
|
||||
<Avatar
|
||||
src={active.channel_thumbnail}
|
||||
fallback={active.channel_title ?? ""}
|
||||
className="w-9 h-9 rounded-full shrink-0"
|
||||
/>
|
||||
)}
|
||||
{navigated ? (
|
||||
detail.data?.channel_id ? (
|
||||
<a
|
||||
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
>
|
||||
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
||||
<ChannelNameLink
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
// openChannel closes this player first and pushes only once its
|
||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||
// and clicking the name merely closes the player
|
||||
// (lib/history.closeOverlaysThenNavigate).
|
||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
|
||||
{!navigated && (
|
||||
<Avatar
|
||||
src={active.channel_thumbnail}
|
||||
fallback={active.channel_title ?? ""}
|
||||
className="w-9 h-9 rounded-full shrink-0"
|
||||
/>
|
||||
<IconLink
|
||||
href={active.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
)}
|
||||
{navigated ? (
|
||||
detail.data?.channel_id ? (
|
||||
<a
|
||||
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`font-medium hover:text-accent ${wide ? "min-w-0 truncate" : "shrink-0"}`}
|
||||
>
|
||||
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
||||
</a>
|
||||
) : (
|
||||
<span className={`font-medium ${wide ? "min-w-0 truncate" : "shrink-0"}`}>
|
||||
{liveData?.author ?? ""}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
// Wide, the name takes what the avatar leaves of the panel's line and is cut
|
||||
// there: unshrinkable, a long one ran the 20rem panel into a sideways scroll.
|
||||
<div
|
||||
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
)}
|
||||
{!navigated ? (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{active.view_count != null && (
|
||||
<ChannelNameLink
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
// openChannel closes this player first and pushes only once its
|
||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||
// and clicking the name merely closes the player
|
||||
// (lib/history.closeOverlaysThenNavigate).
|
||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
/>
|
||||
<IconLink
|
||||
href={active.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
)}
|
||||
{!navigated ? (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{active.view_count != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{t("player.views", {
|
||||
count: active.view_count,
|
||||
formattedCount: formatCompact(active.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{metaSep}
|
||||
{t("player.views", {
|
||||
count: active.view_count,
|
||||
formattedCount: formatCompact(active.view_count, lang),
|
||||
})}
|
||||
{relativeTime(active.published_at, lang, t)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(active.published_at, lang, t)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{formatDuration(active.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{active.live_status === "was_live" && (
|
||||
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
||||
{t("player.stream")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Linked video's stats come from the (already-fetched) video detail.
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{detail.data?.view_count != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{t("player.views", {
|
||||
count: detail.data.view_count,
|
||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{formatDuration(detail.data.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{active.duration_seconds != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{formatDuration(active.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{active.live_status === "was_live" && (
|
||||
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
||||
{t("player.stream")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Linked video's stats come from the (already-fetched) video detail.
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
|
||||
>
|
||||
{detail.data?.view_count != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{t("player.views", {
|
||||
count: detail.data.view_count,
|
||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{formatDuration(detail.data.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
||||
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
||||
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
||||
the only fullscreen button previously reachable in the windowed video was YouTube's
|
||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||
the docked bar offers OUR true fullscreen. */}
|
||||
{!navigated && (
|
||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{onRemoveFromList && (
|
||||
{!navigated && (
|
||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => void removeFromList()}
|
||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
label={t("playlists.removeItemOf", { title: active.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
>
|
||||
<Trash2 />
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</Button>
|
||||
</div>
|
||||
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{onRemoveFromList && (
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
size="md"
|
||||
onClick={() => void removeFromList()}
|
||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
label={t("playlists.removeItemOf", { title: active.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</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"
|
||||
{...descriptionProps}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasQueue && (
|
||||
<Pressable
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
disabled={index >= queue!.length - 1}
|
||||
aria-label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className="absolute right-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronRight className="h-8 w-8" />
|
||||
</Pressable>
|
||||
)}
|
||||
</div>
|
||||
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
||||
Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the
|
||||
|
||||
Reference in New Issue
Block a user