Add HLS playback for video previews and improve scrolling experience
Integrate HLS.js for HLS video playback in video cards, enabling sound and smoother preview functionality. Updates server-side Bunny.net configuration to use signed HLS URLs for MP4 previews. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 074b0e4c-6171-43bd-aa98-f9e04623ca14 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/074b0e4c-6171-43bd-aa98-f9e04623ca14/iazasnP
This commit is contained in:
parent
60f33156b4
commit
7185633a94
@ -1,7 +1,7 @@
|
|||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import { type Video } from "@shared/schema";
|
import { type Video } from "@shared/schema";
|
||||||
import HLSPreviewThumbnail from "./hls-preview-thumbnail";
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
video: Video;
|
video: Video;
|
||||||
@ -46,6 +46,10 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const hoverTimeoutRef = useRef<NodeJS.Timeout>();
|
const hoverTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
// Delay preview start to avoid loading on quick mouse passes
|
// Delay preview start to avoid loading on quick mouse passes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -62,15 +66,59 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
|||||||
clearTimeout(hoverTimeoutRef.current);
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
}
|
}
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
|
// Clean up HLS when not hovering
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (hoverTimeoutRef.current) {
|
if (hoverTimeoutRef.current) {
|
||||||
clearTimeout(hoverTimeoutRef.current);
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [isHovered]);
|
}, [isHovered]);
|
||||||
|
|
||||||
|
// Initialize HLS when preview shows
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPreview && videoRef.current && video.videoUrl.includes('.m3u8')) {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
startLevel: 0, // Start with lowest quality
|
||||||
|
capLevelToPlayerSize: true,
|
||||||
|
maxBufferLength: 5, // Minimal buffering
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(video.videoUrl);
|
||||||
|
hls.attachMedia(videoElement);
|
||||||
|
hlsRef.current = hls;
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
videoElement.addEventListener('loadedmetadata', () => {
|
||||||
|
setDuration(videoElement.duration);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS support
|
||||||
|
videoElement.src = video.videoUrl;
|
||||||
|
videoElement.addEventListener('loadedmetadata', () => {
|
||||||
|
setDuration(videoElement.duration);
|
||||||
|
});
|
||||||
|
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showPreview, video.videoUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`card-video-${video.id}`}
|
data-testid={`card-video-${video.id}`}
|
||||||
@ -115,35 +163,45 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
|||||||
|
|
||||||
{/* Video preview - only load when hovering */}
|
{/* Video preview - only load when hovering */}
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0 z-10">
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
objectPosition: video.faceCenterPosition || 'center center',
|
objectPosition: video.faceCenterPosition || 'center center',
|
||||||
objectFit: 'cover'
|
objectFit: 'cover'
|
||||||
}}
|
}}
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted={false}
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
|
||||||
controls={false}
|
controls={false}
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
webkit-playsinline="true"
|
|
||||||
x5-playsinline="true"
|
|
||||||
onLoadStart={() => console.log('Preview loading for:', video.title)}
|
onLoadStart={() => console.log('Preview loading for:', video.title)}
|
||||||
onError={(e) => console.log('Preview failed for:', video.title)}
|
onError={(e) => console.log('Preview failed for:', video.title)}
|
||||||
onCanPlay={() => console.log('Preview ready for:', video.title)}
|
onCanPlay={() => console.log('Preview ready for:', video.title)}
|
||||||
>
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||||
{/* Try MP4 source first for faster loading on mobile */}
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||||
{video.videoUrlMp4 && (
|
onMouseMove={(e) => {
|
||||||
<source src={video.videoUrlMp4} type="video/mp4" />
|
if (!videoRef.current || duration === 0) return;
|
||||||
)}
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
{/* Fallback to HLS if MP4 fails */}
|
const x = e.clientX - rect.left;
|
||||||
{video.videoUrl && (
|
const percentage = x / rect.width;
|
||||||
<source src={video.videoUrl} type="application/x-mpegURL" />
|
const newTime = Math.max(0, Math.min(duration, percentage * duration));
|
||||||
)}
|
videoRef.current.currentTime = newTime;
|
||||||
</video>
|
setCurrentTime(newTime);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Video scrubbing progress bar - only show during preview */}
|
||||||
|
{duration > 0 && (
|
||||||
|
<div className="absolute bottom-1 left-2 right-2 h-1 bg-white/30 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-red-500 transition-all duration-100"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -107,7 +107,7 @@ export class BunnyService {
|
|||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
customThumbnailUrl: null,
|
customThumbnailUrl: null,
|
||||||
videoUrl: hlsUrl, // Signed HLS URL
|
videoUrl: hlsUrl, // Signed HLS URL
|
||||||
videoUrlMp4: `https://${this.hostname}/${bunnyVideo.guid}/play_720p.mp4`, // Direct MP4 URL for preview
|
videoUrlMp4: hlsUrl, // Use signed HLS URL for preview as well
|
||||||
videoUrlIframe: iframeUrl, // iframe fallback
|
videoUrlIframe: iframeUrl, // iframe fallback
|
||||||
duration: Math.floor(bunnyVideo.length || 0),
|
duration: Math.floor(bunnyVideo.length || 0),
|
||||||
views: bunnyVideo.views || 0,
|
views: bunnyVideo.views || 0,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user