diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 2137b7d..f02723b 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -516,9 +516,7 @@ export default function Chat({ user }: { user: User }) { return (
- {/* ── Conversation bar: labels above icon rows so text + avatars align cleanly ── */}
- {/* Global: pinned icons, label sits under the icon */}
- {/* DMs: scrollable list, labels sit under each icon */}
- {/* + aligned with avatar row (bottom), not with section title */} + +
+
+ ) : ( + setMediaViewer(null)} + /> + )} +
+
+ , + document.body, + )} +
= 120 || text.includes("\n"); @@ -167,16 +259,25 @@ export default function Messages({ components={{ code({ className, children, ...props }) { const match = /language-(\w+)/.exec(className || ""); + const codeText = String(children).replace(/\n$/, ""); + + if (!match) { + return ( + )} + > + {children} + + ); + } + return ( - )} - > - {String(children).replace(/\n$/, "")} - + } + /> ); }, }} @@ -190,7 +291,9 @@ export default function Messages({ {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att, i) => ( -
{getAttachments(att)}
+
+ {getAttachments(att, (payload) => setMediaViewer(payload))} +
))}
)} @@ -206,27 +309,127 @@ export default function Messages({ ); } -function getAttachments(attachment: { - mimetype: string; - public_url: string; - filename: string; +function CodeBlock({ + code, + language, + props, +}: { + code: string; + language: string; + props: Record; }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch (err) { + console.error("Copy code failed:", err); + } + }; + + return ( +
+ + + {code} + +
+ ); +} + +function getAttachments( + attachment: { + mimetype: string; + public_url: string; + filename: string; + }, + onOpenMedia: (payload: { type: "image" | "video"; url: string; filename: string }) => void, +) { switch (attachment.mimetype.split("/")[0]) { case "image": return ( - {attachment.filename} + ); case "video": return ( - + ); case "audio": return ( diff --git a/app/components/chat/Player.tsx b/app/components/chat/Player.tsx new file mode 100644 index 0000000..e8bbc70 --- /dev/null +++ b/app/components/chat/Player.tsx @@ -0,0 +1,741 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faDownload, + faCompress, + faExpand, + faGear, + faPause, + faPlay, + faRectangleList, + faVolumeHigh, + faVolumeXmark, + faXmark, +} from "@fortawesome/free-solid-svg-icons"; + +type PlayerProps = { + src: string; + className?: string; + autoPlay?: boolean; + immersive?: boolean; + onClose?: () => void; + onDownload?: () => void; +}; + +const fmt = (sec: number) => { + if (!Number.isFinite(sec) || sec < 0) return "0:00"; + const mins = Math.floor(sec / 60); + const secs = Math.floor(sec % 60) + .toString() + .padStart(2, "0"); + return `${mins}:${secs}`; +}; + +export default function Player({ + src, + className = "", + autoPlay = true, + immersive = false, + onClose, + onDownload, +}: PlayerProps) { + const videoRef = useRef(null); + const ambientVideoRef = useRef(null); + const wrapRef = useRef(null); + const settingsBtnRef = useRef(null); + const settingsMenuRef = useRef(null); + const volumeBtnRef = useRef(null); + const [playing, setPlaying] = useState(autoPlay); + const [muted, setMuted] = useState(false); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [volume, setVolume] = useState(1); + const [showUi, setShowUi] = useState(false); + const [isSeeking, setIsSeeking] = useState(false); + const [videoRatio, setVideoRatio] = useState(16 / 9); + const [showSettings, setShowSettings] = useState(false); + const [ambientBackground, setAmbientBackground] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + const [quality, setQuality] = useState("Auto"); + const [detectedQuality, setDetectedQuality] = useState(null); + const [isPipActive, setIsPipActive] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showMobileVolume, setShowMobileVolume] = useState(false); + const [activeHint, setActiveHint] = useState(null); + const [hintPos, setHintPos] = useState<{ x: number; y: number; below: boolean } | null>( + null, + ); + const [settingsMenuX, setSettingsMenuX] = useState(null); + const [settingsArrowX, setSettingsArrowX] = useState(null); + const hintTimeoutRef = useRef(null); + + const progress = useMemo(() => { + if (!duration) return 0; + return Math.min(100, (currentTime / duration) * 100); + }, [currentTime, duration]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const onPlay = () => setPlaying(true); + const onPause = () => setPlaying(false); + const onTime = () => setCurrentTime(video.currentTime || 0); + const onLoaded = () => { + setDuration(video.duration || 0); + if (video.videoWidth > 0 && video.videoHeight > 0) { + setVideoRatio(video.videoWidth / video.videoHeight); + const detected = + [2160, 1440, 1080, 720, 480, 360].find((q) => video.videoHeight >= q) ?? + Math.max(144, Math.round(video.videoHeight / 10) * 10); + setDetectedQuality(`${detected}p`); + } + }; + + video.addEventListener("play", onPlay); + video.addEventListener("pause", onPause); + video.addEventListener("timeupdate", onTime); + video.addEventListener("loadedmetadata", onLoaded); + + return () => { + video.removeEventListener("play", onPlay); + video.removeEventListener("pause", onPause); + video.removeEventListener("timeupdate", onTime); + video.removeEventListener("loadedmetadata", onLoaded); + }; + }, []); + + useEffect(() => { + const syncFullscreen = () => { + const wrap = wrapRef.current; + setIsFullscreen(!!wrap && document.fullscreenElement === wrap); + }; + document.addEventListener("fullscreenchange", syncFullscreen); + syncFullscreen(); + return () => document.removeEventListener("fullscreenchange", syncFullscreen); + }, []); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.volume = volume; + video.muted = muted; + }, [volume, muted]); + + useEffect(() => { + const video = videoRef.current; + const ambient = ambientVideoRef.current; + if (!video) return; + video.playbackRate = playbackRate; + if (ambient) ambient.playbackRate = playbackRate; + }, [playbackRate]); + + useEffect(() => { + if (!showUi) return; + if (!playing) return; + if (isSeeking) return; + const id = setTimeout(() => setShowUi(false), 1800); + return () => clearTimeout(id); + }, [playing, showUi, currentTime, isSeeking]); + + useEffect(() => { + if (!showUi) setShowSettings(false); + if (!showUi) setShowMobileVolume(false); + }, [showUi]); + + const computeAnchoredMenu = ( + triggerEl: HTMLElement | null, + menuEl: HTMLElement | null, + ) => { + const wrap = wrapRef.current; + if (!wrap || !triggerEl || !menuEl) return null; + const wrapRect = wrap.getBoundingClientRect(); + const triggerRect = triggerEl.getBoundingClientRect(); + const triggerCenterX = triggerRect.left - wrapRect.left + triggerRect.width / 2; + + const menuWidth = menuEl.offsetWidth || 210; + const edgePad = 8; + const minCenter = menuWidth / 2 + edgePad; + const maxCenter = Math.max(minCenter, wrapRect.width - menuWidth / 2 - edgePad); + const menuCenterX = Math.min(maxCenter, Math.max(minCenter, triggerCenterX)); + const arrowX = Math.min( + menuWidth - 12, + Math.max(12, triggerCenterX - (menuCenterX - menuWidth / 2)), + ); + return { menuCenterX, arrowX }; + }; + + useLayoutEffect(() => { + if (!showSettings) return; + const update = () => { + const pos = computeAnchoredMenu(settingsBtnRef.current, settingsMenuRef.current); + if (!pos) return; + setSettingsMenuX(pos.menuCenterX); + setSettingsArrowX(pos.arrowX); + }; + update(); + const rafA = window.requestAnimationFrame(update); + const rafB = window.requestAnimationFrame(() => window.requestAnimationFrame(update)); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + return () => { + window.cancelAnimationFrame(rafA); + window.cancelAnimationFrame(rafB); + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + }; + }, [showSettings]); + + useEffect(() => { + if (showSettings) hideHint(); + }, [showSettings]); + + useEffect(() => { + if (quality !== "Auto" && detectedQuality && quality !== detectedQuality) { + setQuality(detectedQuality); + } + }, [quality, detectedQuality]); + + useEffect(() => { + const video = videoRef.current; + const ambient = ambientVideoRef.current; + if (!video || !ambient || !ambientBackground) return; + + const syncTime = () => { + if (Math.abs((ambient.currentTime || 0) - (video.currentTime || 0)) > 0.08) { + ambient.currentTime = video.currentTime || 0; + } + }; + + const onPlay = async () => { + syncTime(); + try { + await ambient.play(); + } catch { + // ignore autoplay block; ambient is decorative only + } + }; + const onPause = () => { + syncTime(); + ambient.pause(); + }; + const onSeeking = syncTime; + const onRateChange = () => { + ambient.playbackRate = video.playbackRate || 1; + }; + + syncTime(); + if (!video.paused) onPlay(); + else ambient.pause(); + + video.addEventListener("play", onPlay); + video.addEventListener("pause", onPause); + video.addEventListener("seeking", onSeeking); + video.addEventListener("timeupdate", onSeeking); + video.addEventListener("ratechange", onRateChange); + + return () => { + video.removeEventListener("play", onPlay); + video.removeEventListener("pause", onPause); + video.removeEventListener("seeking", onSeeking); + video.removeEventListener("timeupdate", onSeeking); + video.removeEventListener("ratechange", onRateChange); + }; + }, [ambientBackground]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const onEnterPip = () => setIsPipActive(true); + const onLeavePip = () => setIsPipActive(false); + + video.addEventListener("enterpictureinpicture", onEnterPip as EventListener); + video.addEventListener("leavepictureinpicture", onLeavePip as EventListener); + + return () => { + video.removeEventListener("enterpictureinpicture", onEnterPip as EventListener); + video.removeEventListener("leavepictureinpicture", onLeavePip as EventListener); + }; + }, []); + + const togglePlay = async () => { + const video = videoRef.current; + if (!video) return; + if (video.paused) await video.play(); + else video.pause(); + }; + + const onVideoTap = async () => { + if (!showUi) { + setShowUi(true); + return; + } + await togglePlay(); + }; + + const seek = (value: number) => { + const video = videoRef.current; + if (!video || !Number.isFinite(duration)) return; + const next = Math.max(0, Math.min(duration, value)); + video.currentTime = next; + setCurrentTime(next); + }; + + const toggleFullscreen = async () => { + const el = wrapRef.current; + if (!el) return; + if (!document.fullscreenElement) await el.requestFullscreen(); + else await document.exitFullscreen(); + }; + + const togglePictureInPicture = async () => { + const video = videoRef.current as + | (HTMLVideoElement & { requestPictureInPicture?: () => Promise }) + | null; + if (!video || typeof document === "undefined") return; + + const pipDoc = document as Document & { + pictureInPictureElement?: Element | null; + exitPictureInPicture?: () => Promise; + }; + + try { + if (pipDoc.pictureInPictureElement) { + await pipDoc.exitPictureInPicture?.(); + } else if (video.requestPictureInPicture) { + await video.requestPictureInPicture(); + } + } catch (err) { + console.error("PiP toggle failed:", err); + } + }; + + const showHint = ( + label: string, + target?: EventTarget | null, + placement: "above" | "below" = "above", + ) => { + const wrap = wrapRef.current; + if (wrap && target instanceof HTMLElement) { + const wrapRect = wrap.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + setHintPos({ + x: targetRect.left - wrapRect.left + targetRect.width / 2, + y: + placement === "below" + ? targetRect.bottom - wrapRect.top + 6 + : targetRect.top - wrapRect.top - 6, + below: placement === "below", + }); + } + if (hintTimeoutRef.current) window.clearTimeout(hintTimeoutRef.current); + setActiveHint(label); + hintTimeoutRef.current = window.setTimeout(() => setActiveHint(null), 1200); + }; + + const hideHint = () => { + if (hintTimeoutRef.current) window.clearTimeout(hintTimeoutRef.current); + setActiveHint(null); + setHintPos(null); + }; + + const getVolumeHintLabel = () => { + if (typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches) { + return "Volume"; + } + return muted ? "Unmute" : "Mute"; + }; + + const frameStyle = useMemo(() => { + if (immersive) { + return { + width: "100%", + height: "100%", + }; + } + const safeRatio = Math.max(0.35, Math.min(videoRatio, 3)); + return { + aspectRatio: `${safeRatio}`, + width: `min(92vw, calc(78vh * ${safeRatio}))`, + maxWidth: "1100px", + maxHeight: "78vh", + }; + }, [videoRatio]); + + return ( +
+ {immersive && ambientBackground && ( +