style(frontend): Prettier config + one-time repo-wide format pass

Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
This commit is contained in:
2026-07-21 02:26:40 +02:00
parent 04ecd8b3a7
commit e257e2aca0
101 changed files with 4388 additions and 2641 deletions
+361 -309
View File
@@ -3,12 +3,35 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import {
AlertTriangle,
ArrowLeft,
Check,
CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink,
Repeat,
Repeat1,
Settings,
Shuffle,
SkipBack,
SkipForward,
Volume2,
VolumeX,
X,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import {
channelYouTubeUrl,
formatDate,
formatDuration,
formatViews,
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
import { useScrollFade } from "../lib/useScrollFade";
@@ -94,7 +117,7 @@ export default function PlayerModal({
const [playingId, setPlayingId] = useState<string>(
// A caller with an explicit index (playlist) starts there; otherwise locate the opened
// `video` in the queue (the feed passes its whole list + the clicked video, no index).
startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id
startIndex != null ? (queue?.[startIndex]?.id ?? video.id) : video.id,
);
// Locate the playing item in the frozen launch queue → drives prev/next + the N/M counter.
// Because the queue is frozen, the playing id always stays in it (unlike the old live queue,
@@ -136,7 +159,8 @@ export default function PlayerModal({
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
if (p.getPlayerState() === 1)
p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
const toggleFullscreen = () => {
@@ -153,7 +177,7 @@ export default function PlayerModal({
const nudgeVolume = (delta: number) => {
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
const cur = p.isMuted?.() ? 0 : (p.getVolume?.() ?? 50);
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
if (next > 0) p.unMute?.();
p.setVolume?.(next);
@@ -240,7 +264,7 @@ export default function PlayerModal({
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
[qc]
[qc],
);
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
@@ -249,7 +273,7 @@ export default function PlayerModal({
const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const cycleAuto = () => {
@@ -306,7 +330,7 @@ export default function PlayerModal({
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null
null,
);
const titleRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
@@ -356,7 +380,8 @@ export default function PlayerModal({
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
const typing =
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
@@ -616,325 +641,352 @@ export default function PlayerModal({
<ChevronLeft className="h-8 w-8" />
</button>
)}
<div
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges
uncovered so YouTube's native controls — the top-right cluster (volume / CC / settings)
and the bottom bar (seek / More videos / fullscreen) — stay clickable. Hidden on error
so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</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">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? <Repeat1 className="h-3.5 w-3.5" /> : <Repeat className="h-3.5 w-3.5" />}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? liveData?.title ?? t("player.loading") : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{showDesc &&
descRect &&
createPortal(
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</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">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{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 break-words 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>
)}
</div>,
document.body
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
)}
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
{showDesc &&
descRect &&
createPortal(
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{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 break-words 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>
)}
</div>,
document.body,
)}
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
{/* 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>
<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>
)
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
onClick={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</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: formatViews(active.view_count) })}</span>
)}
<span>
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{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">
{detail.data?.view_count != null && (
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
onClick={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</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: formatViews(active.view_count),
})}
</span>
)}
<span>
· {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{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">
{detail.data?.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatViews(detail.data.view_count),
})}
</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>
</div>
</div>
</div>
{hasQueue && (
<button
onClick={(e) => {
@@ -951,6 +1003,6 @@ export default function PlayerModal({
)}
</div>
</div>,
document.body
document.body,
);
}