From 1abf22623dae16e74b6e3dd3955ec628674f8b68 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Fri, 22 May 2026 16:58:11 -0400 Subject: [PATCH] feat: hover-to-play video preview on library asset cards Fetches stream URL on hover after 350ms delay; renders muted autoplay video overlay over the thumbnail. Supports both mp4 and HLS streams. Only triggers for ready/live assets to avoid pointless API calls. --- services/web-ui/public/screens-library.jsx | 169 ++++++++++++++------- 1 file changed, 117 insertions(+), 52 deletions(-) diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 045f6f7..4bb4223 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -7,14 +7,14 @@ function Library({ navigate, onOpenAsset, openProject }) { const [search, setSearch] = React.useState(''); let assets = openProject - ? ALL_ASSETS.filter(a => a.project_id === openProject.id) + ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; }) : ALL_ASSETS; - if (filter !== 'all') assets = assets.filter(a => a.status === filter); - if (search) assets = assets.filter(a => a.name.toLowerCase().includes(search.toLowerCase())); + if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; }); + if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); const displayTitle = openProject ? openProject.name : 'All Assets'; - const errorCount = ALL_ASSETS.filter(a => a.status === 'error').length; - const recentCount = ALL_ASSETS.filter(a => (Date.now() - new Date(a.created_at)) < 86400000).length; + const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length; + const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length; return (
@@ -22,41 +22,45 @@ function Library({ navigate, onOpenAsset, openProject }) {

Projects

-
navigate('library')} style={{ cursor: 'pointer' }}> +
All projects {ALL_ASSETS.length}
- {PROJECTS.slice(0, 8).map(p => ( -
{ navigate('projects'); }}> - - {p.name} - {p.assets} -
- ))} + {PROJECTS.slice(0, 8).map(function(p) { + return ( +
+ + {p.name} + {p.assets} +
+ ); + })}
{BINS.length > 0 && (

Bins

- {BINS.map(b => ( -
- - {b.name} - {b.count} -
- ))} + {BINS.map(function(b) { + return ( +
+ + {b.name} + {b.count} +
+ ); + })}
)}

Smart filters

- {errorCount > 0 &&
setFilter('error')} style={{ cursor: 'pointer' }}>Errors{errorCount}
} -
setFilter('all')} style={{ cursor: 'pointer' }}>Last 24h{recentCount}
-
setFilter('ready')} style={{ cursor: 'pointer' }}>Ready{ALL_ASSETS.filter(a => a.status === 'ready').length}
+ {errorCount > 0 &&
Errors{errorCount}
} +
Last 24h{recentCount}
+
Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}
@@ -68,51 +72,55 @@ function Library({ navigate, onOpenAsset, openProject }) {
- setSearch(e.target.value)} placeholder="Filter assets…" /> +
- {['all', 'ready', 'processing', 'live', 'error'].map(f => ( - - ))} + {['all', 'ready', 'processing', 'live', 'error'].map(function(f) { + return ( + + ); + })}
- - + +
- +
{assets.length === 0 ? (
No assets match this filter.
) : view === 'grid' ? (
- {assets.map(a => onOpenAsset(a)} />)} + {assets.map(function(a) { return ; })}
) : (
Name
Duration
Resolution
Codec
Size
Updated
- {assets.map(a => ( -
onOpenAsset(a)} style={{ cursor: 'pointer' }}> -
-
-
{a.name}
-
- - {a.status} + {assets.map(function(a) { + return ( +
+
+
+
{a.name}
+
+ + {a.status} +
+
{a.duration}
+
{a.res}
+
{a.codec || '—'}
+
{a.size}
+
{a.updated}
+
-
{a.duration}
-
{a.res}
-
{a.codec || '—'}
-
{a.size}
-
{a.updated}
- -
- ))} + ); + })}
)}
@@ -121,9 +129,66 @@ function Library({ navigate, onOpenAsset, openProject }) { } function AssetCard({ asset, onOpen }) { + const [hoverStream, setHoverStream] = React.useState(null); + const [hovered, setHovered] = React.useState(false); + const timerRef = React.useRef(null); + const hlsRef = React.useRef(null); + const videoRef = React.useRef(null); + + const handleMouseEnter = function() { + timerRef.current = setTimeout(function() { + setHovered(true); + if (!hoverStream && (asset.status === 'ready' || asset.status === 'live')) { + window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream') + .then(function(r) { if (r && r.url) setHoverStream(r); }) + .catch(function() {}); + } + }, 350); + }; + + const handleMouseLeave = function() { + clearTimeout(timerRef.current); + setHovered(false); + }; + + // HLS wiring + React.useEffect(function() { + if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return; + if (!window.Hls) return; + hlsRef.current = new window.Hls({ maxBufferLength: 10 }); + hlsRef.current.loadSource(hoverStream.url); + hlsRef.current.attachMedia(videoRef.current); + return function() { + if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } + }; + }, [hovered, hoverStream]); + + const showVideo = hovered && hoverStream; + return ( -
- +
+
+ + {showVideo && ( +
{asset.status === 'live' && LIVE} {asset.status === 'processing' && Processing}