import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, 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, formatDuration, formatViews, relativeTime } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; import { useBackToClose } from "../lib/history"; // 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"]; // --- IFrame Player API loader (singleton) --- let apiPromise: Promise | null = null; function loadYouTubeApi(): Promise { 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, startIndex, onClose, onState, onOpenChannel, }: { 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. onOpenChannel?: (channelId: string, channelName?: string) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Browser/mouse Back closes the player instead of leaving the page. useBackToClose(onClose); // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). const fullDate = (d: string | null | undefined) => d ? new Date(d).toLocaleDateString(i18n.language, { year: "numeric", month: "short", day: "numeric", }) : ""; // 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 ); // Resolve the playing item from the LIVE queue so prev/next + the N/M counter track it. But // the feed query reorders/refetches under us — e.g. auto-marking the current video watched near // its end invalidates the feed — and the playing item can drop OUT of the loaded queue. When // that happens we must KEEP PLAYING it, not snap back to queue[0] (which would silently jump to // an unrelated video mid-playback, ignoring loop/auto-advance). So pin the last resolved video // in a ref and fall back to it; neighbour stepping just treats it as sitting before queue[0]. const foundIndex = queue ? queue.findIndex((v) => v.id === playingId) : -1; const inQueue = foundIndex >= 0; const activeRef = useRef