import {
useCallback,
useEffect,
useEffectEvent,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
import {
AlertTriangle,
ArrowLeft,
Check,
CheckCheck,
ExternalLink,
Info,
Maximize,
Minimize,
Minimize2,
PanelRightClose,
PanelRightOpen,
Settings,
SkipBack,
SkipForward,
Trash2,
Volume2,
VolumeX,
X,
} from "lucide-react";
import Avatar from "./ui/Avatar";
import { useLatest, useStableCallback } from "../lib/useLatest";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import {
pushLayer,
removeLayer,
anyBlockingLayerAbove,
anyLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
formatDuration,
formatCompact,
relativeTime,
} from "../lib/format";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
import {
useNativeControlsYield,
useYtBigView,
useYtProgress,
useYtVolume,
YT_API_LOAD_FAILED,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
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
// 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).
/** 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 `
`: 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;
}
// From this window width the player's details sit in a side panel beside the video. 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 side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
const PANEL_STRIP = "2.75rem";
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the
// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button
// above its right end, 80–128 px up and 24–72 px in from the right. The click layer's cutouts never
// shrink below those, else on a short player (a small window, a browser zoomed to 150 % or more) the
// layer lies over that button and a click on it pauses instead. Only the right-hand column needs the
// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the
// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any
// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap.
// Px, not rem: the text-size slider does not grow YouTube's controls.
const YT_TOP_CONTROLS_PX = 56;
const YT_SEEK_BAR_PX = 104;
const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 };
// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the
// column above that button.
const YT_CLICK_LAYER: CSSProperties[] = [
{
left: 0,
right: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`,
},
{
right: 0,
width: YT_FULLSCREEN_BUTTON_PX.fromRight,
top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`,
bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`,
},
];
// 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 = (
·{" "}
);
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
// Beside the channel name the meta takes what is left of the line, but never less than this: under it
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
const META_MIN = "min-w-48 flex-1";
export default function PlayerModal({
video,
startAt,
queue: queueProp,
startIndex,
onClose,
onState,
onRemoveFromList,
}: {
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: VideoStatus) => void;
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
* drops the item itself and steps to the next one, closing when the list runs out. */
onRemoveFromList?: (video: Video) => Promise;
// 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 lang = i18n.language;
const { openChannel } = useNavigationActions();
const qc = useQueryClient();
const { ref: descFadeRef, style: descFadeStyle } = 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.
//
// The setter exists for exactly ONE writer: removing a video from its playlist (below).
// That is not the feed changing under us — it is this player's own action — and leaving the item
// in the frozen queue would let prev/next step back onto a video that no longer exists.
const [queue, setQueue] = 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(
// 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;
// Positions this session has checkpointed (server-confirmed), per video id — see useYtProgress.
// The launch queue is frozen at mount, so its items keep the position they were snapshotted with;
// without this map, stepping next→prev back to an item played earlier in the SAME session would
// resume at that snapshot and then persist it over the newer position.
// A position this session already stored wins over `resumeAt` — it is the freshest truth we have —
// so the map is read where the player starts (useYtProgress, loadVideo), never during render.
const sessionPosRef = useRef