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.
This commit is contained in:
parent
4afd0c7b21
commit
1abf22623d
1 changed files with 117 additions and 52 deletions
|
|
@ -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 (
|
||||
<div className="library-layout">
|
||||
|
|
@ -22,41 +22,45 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
|||
<div>
|
||||
<h4>Projects</h4>
|
||||
<div className="rail-list">
|
||||
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
|
||||
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
|
||||
<Icon name="library" size={13} className="rail-icon" />
|
||||
<span>All projects</span>
|
||||
<span className="rail-count">{ALL_ASSETS.length}</span>
|
||||
</div>
|
||||
{PROJECTS.slice(0, 8).map(p => (
|
||||
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
|
||||
onClick={() => { navigate('projects'); }}>
|
||||
<span className="rail-color-dot" style={{ background: p.color }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
<span className="rail-count">{p.assets}</span>
|
||||
</div>
|
||||
))}
|
||||
{PROJECTS.slice(0, 8).map(function(p) {
|
||||
return (
|
||||
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
|
||||
onClick={function() { navigate('projects'); }}>
|
||||
<span className="rail-color-dot" style={{ background: p.color }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
<span className="rail-count">{p.assets}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{BINS.length > 0 && (
|
||||
<div>
|
||||
<h4>Bins</h4>
|
||||
<div className="rail-list">
|
||||
{BINS.map(b => (
|
||||
<div key={b.id} className="rail-item">
|
||||
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
|
||||
<span>{b.name}</span>
|
||||
<span className="rail-count">{b.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{BINS.map(function(b) {
|
||||
return (
|
||||
<div key={b.id} className="rail-item">
|
||||
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
|
||||
<span>{b.name}</span>
|
||||
<span className="rail-count">{b.count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4>Smart filters</h4>
|
||||
<div className="rail-list">
|
||||
{errorCount > 0 && <div className="rail-item" onClick={() => setFilter('error')} style={{ cursor: 'pointer' }}><Icon name="alert" size={13} className="rail-icon" /><span>Errors</span><span className="rail-count">{errorCount}</span></div>}
|
||||
<div className="rail-item" onClick={() => setFilter('all')} style={{ cursor: 'pointer' }}><Icon name="clock" size={13} className="rail-icon" /><span>Last 24h</span><span className="rail-count">{recentCount}</span></div>
|
||||
<div className="rail-item" onClick={() => setFilter('ready')} style={{ cursor: 'pointer' }}><Icon name="check" size={13} className="rail-icon" /><span>Ready</span><span className="rail-count">{ALL_ASSETS.filter(a => a.status === 'ready').length}</span></div>
|
||||
{errorCount > 0 && <div className="rail-item" onClick={function() { setFilter('error'); }} style={{ cursor: 'pointer' }}><Icon name="alert" size={13} className="rail-icon" /><span>Errors</span><span className="rail-count">{errorCount}</span></div>}
|
||||
<div className="rail-item" onClick={function() { setFilter('all'); }} style={{ cursor: 'pointer' }}><Icon name="clock" size={13} className="rail-icon" /><span>Last 24h</span><span className="rail-count">{recentCount}</span></div>
|
||||
<div className="rail-item" onClick={function() { setFilter('ready'); }} style={{ cursor: 'pointer' }}><Icon name="check" size={13} className="rail-icon" /><span>Ready</span><span className="rail-count">{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -68,51 +72,55 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
|||
<div style={{ flex: 1 }} />
|
||||
<div className="search" style={{ width: 220 }}>
|
||||
<Icon name="search" className="search-icon" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Filter assets…" />
|
||||
<input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />
|
||||
</div>
|
||||
<div className="tab-group">
|
||||
{['all', 'ready', 'processing', 'live', 'error'].map(f => (
|
||||
<button key={f} className={filter === f ? 'active' : ''} onClick={() => setFilter(f)}>
|
||||
{f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
|
||||
return (
|
||||
<button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
|
||||
{f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tab-group">
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => navigate('upload')}><Icon name="upload" />Upload</button>
|
||||
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
|
||||
</div>
|
||||
|
||||
{assets.length === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No assets match this filter.</div>
|
||||
) : view === 'grid' ? (
|
||||
<div className="library-grid">
|
||||
{assets.map(a => <AssetCard key={a.id} asset={a} onOpen={() => onOpenAsset(a)} />)}
|
||||
{assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="library-list">
|
||||
<div className="list-row head">
|
||||
<div></div><div>Name</div><div>Duration</div><div>Resolution</div><div>Codec</div><div>Size</div><div>Updated</div><div></div>
|
||||
</div>
|
||||
{assets.map(a => (
|
||||
<div key={a.id} className="list-row" onClick={() => onOpenAsset(a)} style={{ cursor: 'pointer' }}>
|
||||
<div className="thumb"><AssetThumb asset={a} /></div>
|
||||
<div>
|
||||
<div className="name">{a.name}</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
|
||||
<StatusDot status={a.status} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{a.status}</span>
|
||||
{assets.map(function(a) {
|
||||
return (
|
||||
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} style={{ cursor: 'pointer' }}>
|
||||
<div className="thumb"><AssetThumb asset={a} /></div>
|
||||
<div>
|
||||
<div className="name">{a.name}</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
|
||||
<StatusDot status={a.status} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{a.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sub">{a.duration}</div>
|
||||
<div className="col-sub">{a.res}</div>
|
||||
<div className="col-sub">{a.codec || '—'}</div>
|
||||
<div className="col-sub">{a.size}</div>
|
||||
<div className="col-sub">{a.updated}</div>
|
||||
<button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>
|
||||
</div>
|
||||
<div className="col-sub">{a.duration}</div>
|
||||
<div className="col-sub">{a.res}</div>
|
||||
<div className="col-sub">{a.codec || '—'}</div>
|
||||
<div className="col-sub">{a.size}</div>
|
||||
<div className="col-sub">{a.updated}</div>
|
||||
<button className="icon-btn" onClick={e => e.stopPropagation()}><Icon name="more" /></button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="asset-card" onClick={onOpen}>
|
||||
<AssetThumb asset={asset} />
|
||||
<div className="asset-card" onClick={onOpen} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<AssetThumb asset={asset} />
|
||||
{showVideo && (
|
||||
<video
|
||||
key={hoverStream.url}
|
||||
ref={videoRef}
|
||||
src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="thumb-status">
|
||||
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
||||
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
||||
|
|
|
|||
Loading…
Reference in a new issue