video player and link preview
parent
587ecddb13
commit
a5b78f0a0c
|
|
@ -29,6 +29,7 @@ import { Reactions } from "./attachments/Reactions";
|
|||
import { MessageOverlayBar } from "./bars/MessageOverlayBar";
|
||||
import Embed from "./embed/Embed";
|
||||
import InviteList from "./embed/EmbedInvite";
|
||||
import LinkPreview from "./embed/LinkPreview";
|
||||
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
|
|
@ -195,6 +196,14 @@ const Message = observer(
|
|||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
{/* Fallback LinkPreview if no embeds and message contains URLs */}
|
||||
{!message.embeds?.length && content && (() => {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const urls = content.match(urlRegex);
|
||||
return urls?.slice(0, 3).map((url, index) => (
|
||||
<LinkPreview key={`preview-${index}`} url={url} />
|
||||
));
|
||||
})()}
|
||||
<Reactions message={message} />
|
||||
{(mouseHovering || reactionsOpen) &&
|
||||
!replacement && !type_msg &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
import { memo } from "preact/compat";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import styles from "./Embed.module.scss";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface LinkPreviewData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
url: string;
|
||||
siteName?: string;
|
||||
favicon?: string;
|
||||
videoId?: string; // For YouTube videos
|
||||
}
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// YouTube URL detector
|
||||
const YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
// Simple URL detector
|
||||
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
||||
|
||||
// Function to fetch metadata from URL with timeout
|
||||
async function fetchMetadata(url: string): Promise<Partial<LinkPreviewData>> {
|
||||
try {
|
||||
// Use a CORS proxy service to fetch metadata with timeout
|
||||
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8 second timeout
|
||||
|
||||
const response = await fetch(proxyUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.contents) return {};
|
||||
|
||||
const html = data.contents;
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Extract Open Graph or meta tags
|
||||
const getMetaContent = (property: string, attribute = 'property') => {
|
||||
const element = doc.querySelector(`meta[${attribute}="${property}"]`);
|
||||
return element?.getAttribute('content') || '';
|
||||
};
|
||||
|
||||
const title = getMetaContent('og:title') ||
|
||||
getMetaContent('twitter:title') ||
|
||||
doc.querySelector('title')?.textContent || '';
|
||||
|
||||
const description = getMetaContent('og:description') ||
|
||||
getMetaContent('twitter:description') ||
|
||||
getMetaContent('description', 'name') || '';
|
||||
|
||||
const image = getMetaContent('og:image') ||
|
||||
getMetaContent('twitter:image') || '';
|
||||
|
||||
const siteName = getMetaContent('og:site_name') || '';
|
||||
|
||||
return {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
image: image,
|
||||
siteName: siteName || new URL(url).hostname
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch metadata for:', url, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function extractYouTubeId(url: string): string | null {
|
||||
const match = url.match(YOUTUBE_REGEX);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function createYouTubePreview(videoId: string, url: string): Promise<LinkPreviewData> {
|
||||
// For YouTube, use a reliable fallback approach
|
||||
// YouTube often blocks metadata fetching, so we'll use their oEmbed API
|
||||
try {
|
||||
const oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout for YouTube
|
||||
|
||||
const response = await fetch(oEmbedUrl, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data.title || "YouTube Video",
|
||||
description: `by ${data.author_name || 'YouTube'} • Click to watch`,
|
||||
image: data.thumbnail_url || `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
||||
url,
|
||||
siteName: "YouTube",
|
||||
favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png",
|
||||
videoId: videoId
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('YouTube oEmbed failed:', error);
|
||||
}
|
||||
|
||||
// Fallback to basic preview
|
||||
return {
|
||||
title: "YouTube Video",
|
||||
description: "Click to watch on YouTube",
|
||||
image: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
||||
url,
|
||||
siteName: "YouTube",
|
||||
favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png",
|
||||
videoId: videoId
|
||||
};
|
||||
}
|
||||
|
||||
async function createGenericPreview(url: string): Promise<LinkPreviewData> {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Add timeout for metadata fetching
|
||||
const metadataPromise = fetchMetadata(url);
|
||||
const timeoutPromise = new Promise<Partial<LinkPreviewData>>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout')), 6000);
|
||||
});
|
||||
|
||||
const metadata = await Promise.race([metadataPromise, timeoutPromise]).catch(() => ({} as Partial<LinkPreviewData>));
|
||||
|
||||
return {
|
||||
title: metadata?.title || urlObj.hostname,
|
||||
description: metadata?.description || "Click to open link",
|
||||
image: metadata?.image,
|
||||
url,
|
||||
siteName: metadata?.siteName || urlObj.hostname,
|
||||
favicon: `${urlObj.protocol}//${urlObj.hostname}/favicon.ico`
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "External Link",
|
||||
description: "Click to open link",
|
||||
url,
|
||||
siteName: "External Site"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(function LinkPreview({ url }: Props) {
|
||||
const [previewData, setPreviewData] = useState<LinkPreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPlayer, setShowPlayer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
const loadPreview = async () => {
|
||||
try {
|
||||
console.log('Loading preview for:', url);
|
||||
// Check if it's a YouTube link
|
||||
const youtubeId = extractYouTubeId(url);
|
||||
let preview: LinkPreviewData;
|
||||
|
||||
if (youtubeId) {
|
||||
console.log('Detected YouTube video:', youtubeId);
|
||||
// For YouTube, try oEmbed first, but don't wait too long
|
||||
try {
|
||||
preview = await Promise.race([
|
||||
createYouTubePreview(youtubeId, url),
|
||||
new Promise<LinkPreviewData>((resolve) => {
|
||||
setTimeout(() => resolve({
|
||||
title: "YouTube Video",
|
||||
description: "Click to watch on YouTube",
|
||||
image: `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`,
|
||||
url,
|
||||
siteName: "YouTube",
|
||||
favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png",
|
||||
videoId: youtubeId
|
||||
}), 3000); // 3 second fallback
|
||||
})
|
||||
]);
|
||||
} catch {
|
||||
// Immediate fallback for YouTube
|
||||
preview = {
|
||||
title: "YouTube Video",
|
||||
description: "Click to watch on YouTube",
|
||||
image: `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`,
|
||||
url,
|
||||
siteName: "YouTube",
|
||||
favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png",
|
||||
videoId: youtubeId
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log('Loading generic preview for:', url);
|
||||
preview = await createGenericPreview(url);
|
||||
}
|
||||
|
||||
console.log('Preview loaded:', preview);
|
||||
|
||||
if (!cancelled) {
|
||||
setPreviewData(preview);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load preview for:', url, error);
|
||||
if (!cancelled) {
|
||||
// Fallback to basic preview
|
||||
const urlObj = new URL(url);
|
||||
setPreviewData({
|
||||
title: urlObj.hostname,
|
||||
description: "Click to open link",
|
||||
url,
|
||||
siteName: urlObj.hostname
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={classNames(styles.embed, styles.website)} style={{ cursor: 'pointer', maxWidth: '550px', minHeight: '200px', padding: '20px', margin: '10px 0', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', color: 'var(--secondary-foreground)' }}>
|
||||
Loading preview...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!previewData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (previewData?.videoId) {
|
||||
setShowPlayer(!showPlayer);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.embed, styles.website)} onClick={handleClick} style={{ cursor: 'pointer', maxWidth: '550px', minHeight: '200px', padding: '20px', margin: '10px 0', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{previewData.siteName && (
|
||||
<div className={styles.siteinfo}>
|
||||
{previewData.favicon && (
|
||||
<img
|
||||
loading="lazy"
|
||||
className={styles.favicon}
|
||||
src={previewData.favicon}
|
||||
draggable={false}
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.site}>
|
||||
{previewData.siteName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.title && (
|
||||
<span>
|
||||
<div className={styles.title} style={{ fontSize: '18px', fontWeight: '600', marginBottom: '10px' }}>
|
||||
{previewData.title}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{previewData.description && (
|
||||
<div className={styles.description} style={{ fontSize: '15px', lineHeight: '1.4', color: 'var(--secondary-foreground)', maxHeight: '70px', overflow: 'hidden' }}>
|
||||
{previewData.description.length > 300 ? previewData.description.substring(0, 300) + '...' : previewData.description}
|
||||
{previewData.videoId && !showPlayer && (
|
||||
<span style={{ marginLeft: '8px', color: 'var(--accent)', fontSize: '12px' }}>
|
||||
• Click to play
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewData.image && (
|
||||
<div style={{ width: '100%', marginTop: '12px' }}>
|
||||
{showPlayer && previewData.videoId ? (
|
||||
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube-nocookie.com/embed/${previewData.videoId}?autoplay=1&modestbranding=1`}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlayer(false);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', minHeight: '200px' }}>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={previewData.image}
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '8px', display: 'block' }}
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
/>
|
||||
{previewData.videoId && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
borderRadius: '50%',
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
zIndex: 10
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlayer(true);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(0, 0, 0, 0.9)';
|
||||
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(0, 0, 0, 0.8)';
|
||||
e.currentTarget.style.transform = 'translate(-50%, -50%) scale(1)';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
style={{ marginLeft: '4px' }}
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue