mono/packages/ui/src/pages/Post/renderers/components/CompactMediaViewer.tsx

272 lines
13 KiB
TypeScript

import React, { useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from 'lucide-react';
import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import ResponsiveImage from "@/components/ResponsiveImage";
import { PostMediaItem } from "../../types";
import { getTikTokVideoId, getYouTubeVideoId, isTikTokUrl } from "@/utils/mediaUtils";
import { Play } from 'lucide-react';
import { SpyGlassImage } from "./SpyGlassImage";
interface CompactMediaViewerProps {
mediaItem: PostMediaItem;
isVideo: boolean;
showDesktopLayout: boolean;
mediaItems: PostMediaItem[];
currentImageIndex: number;
navigationData: any;
handleNavigate: (direction: 'next' | 'prev') => void;
onMediaSelect: (item: PostMediaItem) => void;
onExpand: (item: PostMediaItem) => void;
cacheBustKeys: Record<string, number>;
navigate: (path: any) => void; // React Router navigate
isOwner: boolean;
videoPlaybackUrl?: string;
videoPosterUrl?: string;
imageFit?: 'contain' | 'cover';
zoomEnabled?: boolean;
}
export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
mediaItem,
isVideo,
showDesktopLayout,
mediaItems,
currentImageIndex,
navigationData,
handleNavigate,
onMediaSelect,
onExpand,
cacheBustKeys,
navigate,
isOwner,
videoPlaybackUrl,
videoPosterUrl,
imageFit = 'cover',
zoomEnabled = false
}) => {
const playerRef = useRef<MediaPlayerInstance>(null);
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
// Cleanup video player
useEffect(() => {
// Stop any background videos
window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: 'compact-media-viewer' } }));
return () => {
playerRef.current?.pause();
};
}, []);
// Version Control Logic
const getVersionsForCurrentItem = () => {
if (!mediaItem) return [];
const rootId = mediaItem.parent_id || mediaItem.id;
const visibleVersions = mediaItems.filter(item => {
const isGroupMatch = (item.id === rootId) || (item.parent_id === rootId);
if (!isGroupMatch) return false;
if (!isOwner && !item.visible) return false;
return true;
});
return visibleVersions.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
};
const currentVersions = getVersionsForCurrentItem();
const currentVersionIndex = currentVersions.findIndex(v => v.id === mediaItem.id);
const handleVersionSwitch = (direction: 'next' | 'prev') => {
if (currentVersions.length <= 1) return;
let newIndex = direction === 'next' ? currentVersionIndex + 1 : currentVersionIndex - 1;
if (newIndex >= currentVersions.length) newIndex = 0;
if (newIndex < 0) newIndex = currentVersions.length - 1;
const nextVersion = currentVersions[newIndex];
if (nextVersion) {
onMediaSelect(nextVersion);
}
};
// Keyboard navigation for versions
useEffect(() => {
if (!showDesktopLayout) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
handleVersionSwitch('prev');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
handleVersionSwitch('next');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showDesktopLayout, currentVersions, currentVersionIndex, mediaItem]);
return (
<div className="absolute inset-0 flex items-center justify-center px-1">
{showDesktopLayout ? (
isVideo ? (
mediaItem.mediaType === 'tiktok' ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={mediaItem.image_url}
className="h-full aspect-[9/16] border-0"
allow="encrypted-media;"
title={mediaItem.title}
></iframe>
</div>
) : (
<div className="w-full h-full">
<MediaPlayer
ref={playerRef}
title={mediaItem.title}
src={videoPlaybackUrl}
poster={videoPosterUrl}
load="idle"
posterLoad="eager"
controls
className="w-full h-full"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
</div>
)
) : (
(() => {
if (mediaItem.mediaType === 'page-external') {
const url = (mediaItem.meta as any)?.url || mediaItem.image_url;
const ytId = getYouTubeVideoId(url);
if (ytId) {
return (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={`https://www.youtube.com/embed/${ytId}`}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={mediaItem.title}
></iframe>
</div>
);
}
if (isTikTokUrl(url)) {
const tikTokId = getTikTokVideoId(url);
const isPlaying = showDesktopLayout && externalVideoState[mediaItem.id];
if (isPlaying && tikTokId) {
return (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={`https://www.tiktok.com/embed/v2/${tikTokId}`}
className="h-full aspect-[9/16] border-0"
allow="encrypted-media;"
title={mediaItem.title}
></iframe>
</div>
);
}
return (
<div className="relative w-full h-full group" onClick={() => {
if (tikTokId) {
setExternalVideoState(prev => ({ ...prev, [mediaItem.id]: true }));
} else {
window.open(url, '_blank')
}
}}>
<ResponsiveImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
alt={mediaItem.title}
imgClassName="w-full h-full object-contain cursor-pointer select-none opacity-80 group-hover:opacity-60 transition-opacity"
sizes="(max-width: 1024px) 100vw, 1200px"
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-black/50 p-4 group-hover:bg-black/70 transition-colors">
<Play className="w-12 h-12 text-white fill-white" />
</div>
</div>
</div>
);
}
}
// Main Image Viewer with conditional SpyGlass
if (zoomEnabled) {
return (
<SpyGlassImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
alt={mediaItem.title}
className="w-full h-full"
onClick={() => onExpand(mediaItem)}
imageFit={imageFit}
/>
);
}
return (
<ResponsiveImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
alt={mediaItem.title}
imgClassName={`w-full h-full object-${imageFit} cursor-pointer select-none`}
onClick={() => onExpand(mediaItem)}
title="Double-tap to view fullscreen"
sizes="(max-width: 1024px) 100vw, 1200px"
/>
);
})()
)
) : null}
{/* Navigation arrows */}
{(currentImageIndex > 0 || (navigationData && navigationData.currentIndex > 0)) && (
<button
onClick={(e) => {
e.stopPropagation();
if (currentImageIndex > 0) {
onMediaSelect(mediaItems[currentImageIndex - 1]);
} else {
handleNavigate('prev');
}
}}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-background/80 hover:bg-background/90 text-foreground border rounded-full flex items-center justify-center transition-all z-30 backdrop-blur-sm shadow-md"
title={currentImageIndex > 0 ? "Previous image" : "Previous post"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
if (currentImageIndex < mediaItems.length - 1) {
onMediaSelect(mediaItems[currentImageIndex + 1]);
} else {
handleNavigate('next');
}
}}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-background/80 hover:bg-background/90 text-foreground border rounded-full flex items-center justify-center transition-all z-30 backdrop-blur-sm shadow-md"
title="Next"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
};