- HLS sweep only deletes directories matching the session-name shape; the root is admin-configurable and may be a shared path - playlist unwrap picks the first entry that actually downloaded, not entries[0] - concat quoting keeps backslashes literal (ffmpeg treats them so inside '') - ArrowUp/Down defer while the focused element can still scroll that way - the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN) - the boot HLS sweep runs off the event loop - volume persistence is debounced (was a setItem per wheel notch) and a spin no longer loses notches to the iframe's lagging getVolume - _Breaker.run owns the whole open/call/record protocol; four call sites shrink - should_prune extracted and unit-tested, plus RSS error-vs-empty tests - the worker exits non-zero when the schema never lands
1111 lines
52 KiB
TypeScript
1111 lines
52 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
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 Avatar from "./Avatar";
|
||
import AddToPlaylist from "./AddToPlaylist";
|
||
import { modalCount } from "./Modal";
|
||
import DownloadButton from "./DownloadButton";
|
||
import { api, type Video } from "../lib/api";
|
||
import {
|
||
channelYouTubeUrl,
|
||
formatDate,
|
||
formatDuration,
|
||
formatViews,
|
||
relativeTime,
|
||
} from "../lib/format";
|
||
import { renderDescription } from "../lib/descriptionLinks";
|
||
import { useBackToClose } from "../lib/history";
|
||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||
import { useScrollFade } from "../lib/useScrollFade";
|
||
|
||
// 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
|
||
// and resume where the user left off. The modal closes via the in-card Close
|
||
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
||
// the cross-origin player iframe — a browser security boundary we can't cross).
|
||
|
||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||
const FINISH_MARGIN = 10;
|
||
|
||
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
|
||
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
|
||
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
|
||
type AutoMode = "off" | "next" | "prev" | "random";
|
||
type LoopMode = "off" | "one" | "all";
|
||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||
|
||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
||
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
||
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||
* behind the modal is `overflow:hidden` anyway. */
|
||
function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
||
const style = getComputedStyle(node);
|
||
if (!/(auto|scroll|overlay)/.test(style.overflowY)) continue;
|
||
const room =
|
||
dir < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1; // -1: sub-pixel heights
|
||
if (room) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// --- IFrame Player API loader (singleton) ---
|
||
let apiPromise: Promise<any> | null = null;
|
||
function loadYouTubeApi(): Promise<any> {
|
||
if (apiPromise) return apiPromise;
|
||
apiPromise = new Promise((resolve) => {
|
||
const w = window as any;
|
||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||
const prev = w.onYouTubeIframeAPIReady;
|
||
w.onYouTubeIframeAPIReady = () => {
|
||
if (typeof prev === "function") prev();
|
||
resolve(w.YT);
|
||
};
|
||
const tag = document.createElement("script");
|
||
tag.src = "https://www.youtube.com/iframe_api";
|
||
document.head.appendChild(tag);
|
||
});
|
||
return apiPromise;
|
||
}
|
||
|
||
export default function PlayerModal({
|
||
video,
|
||
startAt,
|
||
queue: queueProp,
|
||
startIndex,
|
||
onClose,
|
||
onState,
|
||
}: {
|
||
video: Video;
|
||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
||
// to resume from the server-saved position carried on the video.
|
||
startAt?: number | null;
|
||
// Optional playback queue (e.g. a playlist). When present, the player advances through
|
||
// it (auto-advance on end + prev/next), recreating per item to reuse the single-video
|
||
// resume / auto-watch / progress logic.
|
||
queue?: Video[];
|
||
startIndex?: number;
|
||
onClose: () => void;
|
||
onState: (id: string, status: string) => void;
|
||
// Open the active video's channel page in-app (closes the player first). The small external
|
||
// icon next to the name keeps the open-on-YouTube behaviour.
|
||
}) {
|
||
const { t, i18n } = useTranslation();
|
||
const { openChannel } = useNavigationActions();
|
||
const qc = useQueryClient();
|
||
const descFade = useScrollFade();
|
||
// Browser/mouse Back closes the player instead of leaving the page.
|
||
useBackToClose(onClose);
|
||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
||
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
||
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
||
// would otherwise shrink the queue mid-playback and break prev/next + Loop "All" (it could never
|
||
// wrap back to the first item once earlier items dropped out). Snapshot once at mount via
|
||
// useState's lazy initializer and ignore later prop changes. Items that turn out gone/
|
||
// non-embeddable still surface the existing playback-error overlay when reached.
|
||
const [queue] = useState(() => queueProp);
|
||
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||
const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
|
||
// Track the playing item by id (not a frozen index) so it survives the queue changing
|
||
// under us — e.g. an item removed in another tab. The index is derived from the *live*
|
||
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
||
// playback of the current video.
|
||
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,
|
||
);
|
||
// 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,
|
||
// where a just-watched item could drop out and snap playback to queue[0]).
|
||
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
||
if (index < 0) index = 0;
|
||
const active = queue?.[index] ?? video; // the queue item at index, else the opened video (never undefined)
|
||
const hasQueue = !!queue && queue.length > 1;
|
||
// startAt only applies to the item we opened with; advanced items resume their own pos.
|
||
const resumeAt =
|
||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||
const playerRef = useRef<any>(null);
|
||
const autoMarkedRef = useRef(false);
|
||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
||
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||
const errorStreakRef = useRef(0);
|
||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
|
||
// controls; the interaction overlay below also stops the iframe stealing focus on a video
|
||
// click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
|
||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
||
// modal adjusts volume (not just the small player area).
|
||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||
const volTimerRef = useRef<number | undefined>(undefined);
|
||
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
|
||
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
||
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
|
||
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
|
||
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
|
||
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
|
||
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
|
||
const [nativeControls, setNativeControls] = useState(false);
|
||
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
|
||
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
|
||
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
|
||
// the native fullscreen button — permanently hidden after pressing F.
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
|
||
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
|
||
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
|
||
// contradict it.
|
||
const [playerPrefs, patchPlayerPrefs] = useAccountPersistedObject(LS.ytPlayerPrefs, {
|
||
volume: 100,
|
||
});
|
||
// The live volume: updated on every notch, while the localStorage write behind it is debounced
|
||
// (a wheel spin fires ~16 events a second, and each patch is a synchronous setItem on the frame
|
||
// the player is decoding on). Everything that READS the level reads this ref, so it never sees
|
||
// the stale persisted value mid-spin. The window/wheel listeners are bound once, so a ref — not
|
||
// state — is what they can safely close over.
|
||
const volumeRef = useRef(playerPrefs.volume);
|
||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||
const lastNudgeAtRef = useRef(0);
|
||
|
||
const focusModal = () => cardRef.current?.focus();
|
||
const togglePlay = () => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getPlayerState !== "function") return;
|
||
if (p.getPlayerState() === 1)
|
||
p.pauseVideo?.(); // 1 === playing
|
||
else p.playVideo?.();
|
||
};
|
||
const toggleFullscreen = () => {
|
||
const el = stageRef.current;
|
||
if (!el) return;
|
||
if (document.fullscreenElement) document.exitFullscreen?.();
|
||
else el.requestFullscreen?.();
|
||
};
|
||
const flashVolume = (level: number) => {
|
||
setVolumeUi(level);
|
||
window.clearTimeout(volTimerRef.current);
|
||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||
};
|
||
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
|
||
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
|
||
const persistVolume = () => {
|
||
window.clearTimeout(volSaveTimerRef.current);
|
||
volSaveTimerRef.current = undefined;
|
||
patchPlayerPrefs({ volume: volumeRef.current });
|
||
};
|
||
const applyVolume = (level: number, flash = true) => {
|
||
const next = Math.max(0, Math.min(100, Math.round(level)));
|
||
const p = playerRef.current;
|
||
if (p && typeof p.setVolume === "function") {
|
||
if (next > 0) p.unMute?.();
|
||
p.setVolume(next);
|
||
if (next === 0) p.mute?.();
|
||
}
|
||
volumeRef.current = next;
|
||
// Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes
|
||
// a pending write, so the last change is never lost.
|
||
window.clearTimeout(volSaveTimerRef.current);
|
||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||
if (flash) flashVolume(next);
|
||
};
|
||
const nudgeVolume = (delta: number) => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getVolume !== "function") return;
|
||
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
|
||
// the player, because the user may have moved YouTube's own slider in the meantime.
|
||
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
|
||
// re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five
|
||
// wheel notches moved 100 → 90 instead of 75).
|
||
const now = Date.now();
|
||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||
lastNudgeAtRef.current = now;
|
||
const cur = continuing
|
||
? volumeRef.current
|
||
: p.isMuted?.()
|
||
? 0
|
||
: (p.getVolume?.() ?? volumeRef.current);
|
||
applyVolume(cur + delta);
|
||
};
|
||
|
||
// The player can navigate to other videos (YouTube links in a description). The
|
||
// currently-playing id may differ from the active item.
|
||
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||
const currentIdRef = useRef(active.id);
|
||
const navigated = currentVideoId !== active.id;
|
||
// Title/author of a navigated-to video, read from the player (free, no API call).
|
||
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
|
||
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
|
||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||
const [playerError, setPlayerError] = useState<number | null>(null);
|
||
|
||
const loadVideo = (id: string, start: number | null) => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.loadVideoById !== "function") return;
|
||
// Explicit start wins; otherwise resume the active item, start others at 0.
|
||
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
||
setPlayerError(null);
|
||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||
currentIdRef.current = id;
|
||
setCurrentVideoId(id);
|
||
setLiveData(null);
|
||
};
|
||
|
||
// Local mirror of watch status so the toggle reacts instantly; changes are
|
||
// propagated to the feed (and server) via onState.
|
||
const [status, setStatus] = useState(active.status);
|
||
const watched = status === "watched";
|
||
const setWatched = (on: boolean) => {
|
||
const next = on ? "watched" : "new";
|
||
setStatus(next);
|
||
onState(active.id, next);
|
||
};
|
||
|
||
// On queue advance (active changes), sync local state to the new item.
|
||
useEffect(() => {
|
||
setStatus(active.status);
|
||
setCurrentVideoId(active.id);
|
||
currentIdRef.current = active.id;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [active.id]);
|
||
|
||
const seekTo = (seconds: number) => {
|
||
const p = playerRef.current;
|
||
if (p && typeof p.seekTo === "function") {
|
||
p.seekTo(seconds, true);
|
||
p.playVideo?.();
|
||
}
|
||
};
|
||
|
||
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
||
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
||
// current position, not a stale closure.
|
||
const navRef = useRef({ queue, index });
|
||
navRef.current = { queue, index };
|
||
const goPrev = () => {
|
||
const { queue: q, index: i } = navRef.current;
|
||
if (q && i > 0) setPlayingId(q[i - 1]!.id);
|
||
};
|
||
const goNext = () => {
|
||
const { queue: q, index: i } = navRef.current;
|
||
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
|
||
};
|
||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||
const nudgeSeek = (delta: number) => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||
};
|
||
|
||
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
|
||
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
|
||
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
||
const savedPrefs = useMemo(
|
||
() =>
|
||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||
playerAutoAdvance?: AutoMode;
|
||
playerLoop?: LoopMode;
|
||
},
|
||
[qc],
|
||
);
|
||
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
||
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
||
const modeRef = useRef({ autoMode, loopMode });
|
||
modeRef.current = { autoMode, loopMode };
|
||
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,
|
||
);
|
||
};
|
||
const cycleAuto = () => {
|
||
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
|
||
setAutoMode(next);
|
||
persistPref({ playerAutoAdvance: next });
|
||
};
|
||
const cycleLoop = () => {
|
||
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
|
||
setLoopMode(next);
|
||
persistPref({ playerLoop: next });
|
||
};
|
||
const replayCurrent = () => {
|
||
const p = playerRef.current;
|
||
if (p && typeof p.seekTo === "function") {
|
||
p.seekTo(0, true);
|
||
p.playVideo?.();
|
||
}
|
||
};
|
||
// Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise
|
||
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
|
||
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
||
const advanceOnEnd = () => {
|
||
const { autoMode: a, loopMode: l } = modeRef.current;
|
||
if (l === "one") return replayCurrent();
|
||
const { queue: q, index: i } = navRef.current;
|
||
if (!q || q.length === 0) return;
|
||
const wrap = l === "all";
|
||
if (a === "next") {
|
||
if (i < q.length - 1) setPlayingId(q[i + 1]!.id);
|
||
else if (wrap) {
|
||
if (q.length === 1) replayCurrent();
|
||
else setPlayingId(q[0]!.id);
|
||
}
|
||
} else if (a === "prev") {
|
||
if (i > 0) setPlayingId(q[i - 1]!.id);
|
||
else if (wrap) {
|
||
if (q.length === 1) replayCurrent();
|
||
else setPlayingId(q[q.length - 1]!.id);
|
||
}
|
||
} else if (a === "random" && q.length > 1) {
|
||
let r = i;
|
||
while (r === i) r = Math.floor(Math.random() * q.length);
|
||
setPlayingId(q[r]!.id);
|
||
} else if (a === "random" && wrap) {
|
||
replayCurrent();
|
||
}
|
||
};
|
||
|
||
// Lazy description (fetched only when the title is hovered). The popover is
|
||
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
|
||
// can't clip it. A small close grace lets the mouse travel title → popover.
|
||
const [showDesc, setShowDesc] = useState(false);
|
||
// `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,
|
||
);
|
||
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||
const closeTimer = useRef<number | undefined>(undefined);
|
||
const openTimer = useRef<number | undefined>(undefined);
|
||
const openDescNow = () => {
|
||
window.clearTimeout(closeTimer.current);
|
||
window.clearTimeout(openTimer.current);
|
||
const el = titleRef.current;
|
||
if (el) {
|
||
const r = el.getBoundingClientRect();
|
||
const width = Math.min(560, window.innerWidth - 32);
|
||
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
|
||
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
|
||
}
|
||
setShowDesc(true);
|
||
};
|
||
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
|
||
// quick pass over it doesn't flash the popover.
|
||
const scheduleOpenDesc = () => {
|
||
window.clearTimeout(closeTimer.current);
|
||
window.clearTimeout(openTimer.current);
|
||
openTimer.current = window.setTimeout(openDescNow, 400);
|
||
};
|
||
const scheduleCloseDesc = () => {
|
||
window.clearTimeout(openTimer.current);
|
||
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||
};
|
||
const detail = useQuery({
|
||
queryKey: ["video-detail", currentVideoId],
|
||
queryFn: () => api.videoDetail(currentVideoId),
|
||
// On hover (for the description) and eagerly when navigated, so we can link the
|
||
// linked video's channel. Both share the one cached result.
|
||
enabled: showDesc || navigated,
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
|
||
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
|
||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||
if (document.fullscreenElement) return;
|
||
// Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes
|
||
// it, ours would otherwise also fire and close the player underneath it (U-C).
|
||
if (modalCount() > 0) return;
|
||
onClose();
|
||
return;
|
||
}
|
||
const el = document.activeElement as HTMLElement | null;
|
||
const tag = (el?.tagName || "").toLowerCase();
|
||
const typing =
|
||
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
|
||
if (e.key === "f" || e.key === "F") {
|
||
if (typing) return;
|
||
e.preventDefault();
|
||
toggleFullscreen();
|
||
} else if (e.key === " " || e.code === "Space") {
|
||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||
// page from scrolling).
|
||
if (typing || tag === "button" || tag === "a") return;
|
||
e.preventDefault();
|
||
togglePlay();
|
||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||
// focused button.
|
||
const up = e.key === "ArrowUp";
|
||
if (typing || canScrollBy(el, up ? -1 : 1)) return;
|
||
e.preventDefault();
|
||
nudgeVolume(up ? 5 : -5);
|
||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||
// previous/next video in the queue (the feed's order or a playlist).
|
||
if (typing) return;
|
||
e.preventDefault();
|
||
const forward = e.key === "ArrowRight";
|
||
if (e.shiftKey) {
|
||
if (forward) goNext();
|
||
else goPrev();
|
||
} else {
|
||
nudgeSeek(forward ? 5 : -5);
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
focusModal();
|
||
const prevOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
window.removeEventListener("keydown", onKey);
|
||
document.body.style.overflow = prevOverflow;
|
||
window.clearTimeout(closeTimer.current);
|
||
window.clearTimeout(openTimer.current);
|
||
window.clearTimeout(volTimerRef.current);
|
||
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
|
||
// way to leave) must not drop the level the user just set.
|
||
if (volSaveTimerRef.current != null) persistVolume();
|
||
};
|
||
}, [onClose]);
|
||
|
||
// Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
|
||
// Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
|
||
// swallows wheel events over its own native-control strips, but the central interaction overlay
|
||
// is our element (wheel there bubbles here), and everything outside the video works directly.
|
||
useEffect(() => {
|
||
const el = dialogRef.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
|
||
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
|
||
if (e.ctrlKey || e.metaKey) return;
|
||
e.preventDefault();
|
||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||
};
|
||
el.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => el.removeEventListener("wheel", onWheel);
|
||
}, []);
|
||
|
||
// Track whether we're the fullscreen element (F, our button, or the browser's own Esc exit).
|
||
useEffect(() => {
|
||
const onChange = () =>
|
||
setIsFullscreen(
|
||
!!document.fullscreenElement && document.fullscreenElement === stageRef.current,
|
||
);
|
||
document.addEventListener("fullscreenchange", onChange);
|
||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||
}, []);
|
||
|
||
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
||
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
|
||
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
|
||
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
|
||
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
|
||
// card blurs the iframe → the parent window fires 'focus').
|
||
useEffect(() => {
|
||
const iframeFocused = () => {
|
||
const p = playerRef.current;
|
||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||
return !!f && document.activeElement === f;
|
||
};
|
||
// activeElement settles on the tick after blur, so defer the check.
|
||
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
||
const rearm = () => {
|
||
setNativeControls(false);
|
||
focusModal();
|
||
};
|
||
const stage = stageRef.current;
|
||
window.addEventListener("blur", onBlur);
|
||
window.addEventListener("focus", rearm);
|
||
stage?.addEventListener("mouseleave", rearm);
|
||
return () => {
|
||
window.removeEventListener("blur", onBlur);
|
||
window.removeEventListener("focus", rearm);
|
||
stage?.removeEventListener("mouseleave", rearm);
|
||
};
|
||
}, []);
|
||
|
||
// Create the player, resume from the saved position, persist progress, and
|
||
// auto-mark watched once playback reaches the end.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const id = active.id;
|
||
autoMarkedRef.current = false;
|
||
setPlayerError(null);
|
||
|
||
// Auto-watch only applies to the active item — not to other videos the player
|
||
// navigated to via description links.
|
||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||
// Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
|
||
// auto-marked watched almost immediately (its threshold would otherwise go negative).
|
||
const margin = Math.min(FINISH_MARGIN, duration / 2);
|
||
if (current > duration - margin) {
|
||
autoMarkedRef.current = true;
|
||
setWatched(true);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
const persist = (): Promise<unknown> | void => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||
try {
|
||
const cur = p.getCurrentTime();
|
||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
|
||
// only clear the resume position, and firing both at once needlessly races the
|
||
// watched write against the progress row it deletes.
|
||
if (maybeAutoWatch(cur, dur)) return;
|
||
// Only checkpoint the feed video we opened with — a navigated-to video may not
|
||
// be in this user's library, so the server would 404 on it.
|
||
if (currentIdRef.current === id) {
|
||
return api.saveProgress(id, cur, dur).catch(() => {});
|
||
}
|
||
} catch {
|
||
/* player may be tearing down */
|
||
}
|
||
};
|
||
|
||
loadYouTubeApi().then((YT) => {
|
||
if (cancelled || !mountRef.current) return;
|
||
playerRef.current = new YT.Player(mountRef.current, {
|
||
width: "100%",
|
||
height: "100%",
|
||
videoId: id,
|
||
playerVars: {
|
||
autoplay: 1,
|
||
start: resumeAt || undefined,
|
||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||
enablejsapi: 1,
|
||
origin: window.location.origin,
|
||
playsinline: 1,
|
||
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
||
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
||
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
|
||
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
||
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
||
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
||
vq: "hd1080",
|
||
},
|
||
events: {
|
||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
||
// restore the remembered volume — a fresh player always starts at 100.
|
||
onReady: () => {
|
||
focusModal();
|
||
applyVolume(volumeRef.current, false);
|
||
},
|
||
onStateChange: (e: any) => {
|
||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||
if (e?.data === 1) {
|
||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||
const p = playerRef.current;
|
||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||
if (d) setLiveData({ title: d.title, author: d.author });
|
||
}
|
||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||
if (e?.data === 0 && currentIdRef.current === id) {
|
||
if (!autoMarkedRef.current) {
|
||
autoMarkedRef.current = true;
|
||
setWatched(true);
|
||
}
|
||
advanceOnEnd();
|
||
}
|
||
},
|
||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||
// Surface our own message + an "Open on YouTube" escape hatch.
|
||
onError: (e: any) => {
|
||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
|
||
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
|
||
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
|
||
// NOT mark the broken video watched (only the ended-handler does that).
|
||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||
const { autoMode: a } = modeRef.current;
|
||
const { queue: q } = navRef.current;
|
||
if (a === "off" || !q || q.length <= 1) return;
|
||
errorStreakRef.current += 1;
|
||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||
advanceOnEnd();
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||
const timer = window.setInterval(persist, 5000);
|
||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
||
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
||
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
||
// description-link video may not be in this user's library (the server would 404).
|
||
const onHide = () => {
|
||
const p = playerRef.current;
|
||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||
try {
|
||
const cur = p.getCurrentTime();
|
||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||
} catch {
|
||
/* player tearing down */
|
||
}
|
||
};
|
||
window.addEventListener("pagehide", onHide);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
window.removeEventListener("pagehide", onHide);
|
||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||
Promise.resolve(persist()).finally(() => {
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||
});
|
||
const p = playerRef.current;
|
||
if (p && typeof p.destroy === "function") {
|
||
try {
|
||
p.destroy();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
playerRef.current = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [active.id]);
|
||
|
||
// 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
|
||
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
|
||
// header (z-chrome) paint over the modal — worst at browser zoom, where the centered card is smaller
|
||
// relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.)
|
||
return createPortal(
|
||
<div
|
||
ref={dialogRef}
|
||
data-testid="player-modal"
|
||
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
|
||
onClick={onClose}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
||
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
|
||
where there's no room beside the card. */}
|
||
<div className="relative flex w-full max-w-4xl max-h-full">
|
||
{hasQueue && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
goPrev();
|
||
}}
|
||
disabled={index === 0}
|
||
aria-label={t("player.previous")}
|
||
title={`${t("player.previous")} · Shift+←`}
|
||
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||
>
|
||
<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"
|
||
>
|
||
{/* 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
|
||
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, and fully yielded in fullscreen (YouTube
|
||
reveals its control bar only while the iframe itself sees the mouse). */}
|
||
{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 || isFullscreen ? "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>
|
||
)}
|
||
{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.
|
||
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">
|
||
<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 && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{hasQueue && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
goNext();
|
||
}}
|
||
disabled={index >= queue!.length - 1}
|
||
aria-label={t("player.next")}
|
||
title={`${t("player.next")} · Shift+→`}
|
||
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||
>
|
||
<ChevronRight className="h-8 w-8" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|