"use client"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { Button } from "~/components/ui/button"; import { toastManager } from "~/components/ui/toast"; import { useThreadPreviewState } from "~/previewStateStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, } from "./previewMiniPlayerLayout"; interface DragState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; readonly playerX: number; readonly playerY: number; } interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; readonly width: number; readonly height: number; } interface Props { readonly threadRef: ScopedThreadRef; readonly tabId: string; readonly bottomInset: number; } export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null; const size = miniPlayer?.tabId === tabId && miniPlayer.size ? miniPlayer.size : PREVIEW_MINI_PLAYER_DEFAULT_SIZE; const close = () => { usePreviewMiniPlayerStore.getState().close(threadRef); }; const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openBrowser(threadRef, tabId); }; const toggleNativePictureInPicture = () => { if (!previewBridge) return; const operation = desktopOverlay?.pictureInPicture ? previewBridge.pictureInPicture.close : previewBridge.pictureInPicture.open; void operation(runtimeTabId).catch((error) => { toastManager.add({ type: "error", title: "Unable to update popped-out preview", description: error instanceof Error ? error.message : "An error occurred.", }); }); }; useLayoutEffect(() => { const clampAndMove = () => { const root = rootRef.current; const parent = root?.offsetParent; if (!root || !(parent instanceof HTMLElement)) return; const nextSize = clampPreviewMiniPlayerSize( { width: root.offsetWidth, height: root.offsetHeight }, { width: parent.clientWidth, height: parent.clientHeight }, bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const next = clampPreviewMiniPlayerPosition( position ?? { x: root.offsetLeft, y: root.offsetTop }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, ); usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); }; clampAndMove(); const root = rootRef.current; const parent = root?.offsetParent; if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver(clampAndMove); observer.observe(root); observer.observe(parent); return () => observer.disconnect(); }, [bottomInset, position, tabId, threadRef]); const handlePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; const parent = root?.offsetParent; if (!root || !(parent instanceof HTMLElement)) return; const rootRect = root.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); dragRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, playerX: rootRect.left - parentRect.left, playerY: rootRect.top - parentRect.top, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); }; const handlePointerMove = (event: ReactPointerEvent) => { const drag = dragRef.current; const root = rootRef.current; const parent = root?.offsetParent; if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) { return; } const next = clampPreviewMiniPlayerPosition( { x: drag.playerX + event.clientX - drag.pointerX, y: drag.playerY + event.clientY - drag.pointerY, }, { width: parent.clientWidth, height: parent.clientHeight }, { width: root.offsetWidth, height: root.offsetHeight }, bottomInset, ); usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); }; const endDrag = (event: ReactPointerEvent) => { if (dragRef.current?.pointerId !== event.pointerId) return; dragRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; if (!root) return; resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, width: root.offsetWidth, height: root.offsetHeight, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; const handleResizePointerMove = (event: ReactPointerEvent) => { const resize = resizeRef.current; const root = rootRef.current; const parent = root?.offsetParent; if ( !resize || resize.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement) ) { return; } const nextSize = clampPreviewMiniPlayerSize( { width: resize.width + event.clientX - resize.pointerX, height: resize.height + event.clientY - resize.pointerY, }, { width: parent.clientWidth, height: parent.clientHeight }, bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( position ?? { x: root.offsetLeft, y: root.offsetTop }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, ); usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition); }; const endResize = (event: ReactPointerEvent) => { if (resizeRef.current?.pointerId !== event.pointerId) return; resizeRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; if (!snapshot || miniPlayer?.tabId !== tabId) return null; return (
{!desktopOverlay?.hasWebContents ? (
Reconnecting preview…
) : null}
); }