feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
@@ -76,6 +76,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { useMediaQuery } from "../lib/useMediaQuery";
|
||||
|
||||
// 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
|
||||
@@ -98,6 +99,20 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// From this window width the player's details sit in a side panel beside the video (E31). The panel is
|
||||
// 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 meta line's items each lead with a "·" that parts them from what stands before: the channel name
|
||||
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
|
||||
const metaSep = (
|
||||
<span aria-hidden="true" data-meta-sep="">
|
||||
·{" "}
|
||||
</span>
|
||||
);
|
||||
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
@@ -194,6 +209,10 @@ export default function PlayerModal({
|
||||
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
|
||||
// height, measured (it wraps with the title and the text size), and caps the card's width below.
|
||||
const [below, setBelow] = useState(0);
|
||||
// A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue
|
||||
// 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);
|
||||
useLayoutEffect(() => {
|
||||
const card = cardRef.current;
|
||||
const stage = stageRef.current;
|
||||
@@ -450,7 +469,8 @@ export default function PlayerModal({
|
||||
queryFn: () => api.videoDetail(currentVideoId),
|
||||
// On opening the description, and eagerly when navigated, so we can link the
|
||||
// linked video's channel. Both share the one cached result.
|
||||
enabled: showDesc || navigated,
|
||||
// On a wide window the side panel shows the description outright (a stored video's is a DB read).
|
||||
enabled: showDesc || navigated || wide,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -609,10 +629,13 @@ export default function PlayerModal({
|
||||
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).
|
||||
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel.
|
||||
style={{
|
||||
maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
||||
hasQueue ? " + 7rem" : ""
|
||||
}))`,
|
||||
maxWidth: wide
|
||||
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
|
||||
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
||||
hasQueue ? " + 7rem" : ""
|
||||
}))`,
|
||||
}}
|
||||
>
|
||||
{hasQueue && (
|
||||
@@ -632,14 +655,16 @@ export default function PlayerModal({
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full overflow-y-auto outline-hidden`}
|
||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||||
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
|
||||
maximized ? "player-stage--max" : ""
|
||||
}`}
|
||||
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" : ""}`}
|
||||
>
|
||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||
through our overlay. */}
|
||||
@@ -798,234 +823,284 @@ export default function PlayerModal({
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
</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>
|
||||
<IconButton
|
||||
ref={descPop.refs.setReference}
|
||||
{...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>
|
||||
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={descPop}
|
||||
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
|
||||
>
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.data?.description ? (
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
title={t("player.closeEsc")}
|
||||
>
|
||||
{t("player.close")}
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
||||
<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 && (
|
||||
<IconButton
|
||||
ref={descPop.refs.setReference}
|
||||
{...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>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.data?.description ? (
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
title={t("player.closeEsc")}
|
||||
>
|
||||
{t("player.close")}
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Channel + meta on one line, with the watched toggle 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. */}
|
||||
<div className="flex items-center gap-3 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"
|
||||
/>
|
||||
<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 min-w-0">
|
||||
{active.view_count != null && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("player.views", {
|
||||
count: active.view_count,
|
||||
formattedCount: formatCompact(active.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
|
||||
{!navigated && (
|
||||
<Avatar
|
||||
src={active.channel_thumbnail}
|
||||
fallback={active.channel_title ?? ""}
|
||||
className="w-9 h-9 rounded-full shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
· {relativeTime(active.published_at, lang)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
<span>· {formatDuration(active.duration_seconds)}</span>
|
||||
{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"
|
||||
/>
|
||||
<IconLink
|
||||
href={active.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
{!navigated ? (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||
>
|
||||
{active.view_count != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{t("player.views", {
|
||||
count: active.view_count,
|
||||
formattedCount: formatCompact(active.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(active.published_at, lang)}
|
||||
{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 min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||
>
|
||||
{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)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{formatDuration(detail.data.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 min-w-0">
|
||||
{detail.data?.view_count != null && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("player.views", {
|
||||
count: detail.data.view_count,
|
||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
· {relativeTime(detail.data.published_at, lang)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>· {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 && (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
className="ml-auto"
|
||||
>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||||
{!navigated && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{!navigated && 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>
|
||||
)}
|
||||
{!navigated && (
|
||||
<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>
|
||||
)}
|
||||
{!navigated && (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||
className={wide ? undefined : "ml-auto"}
|
||||
>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||||
{!navigated && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{!navigated && 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>
|
||||
)}
|
||||
{!navigated && (
|
||||
<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>
|
||||
{wide && (
|
||||
<section className="mt-5" data-testid="player-description">
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.data?.description ? (
|
||||
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user