From 600af4564e3265a471602ac6cfb984227f3a9cce Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Wed, 3 Jun 2026 00:19:55 +0000 Subject: [PATCH] fix: restore screens-library.jsx and visuals.jsx to clean state + add deriveGrowingRaster scanHint fix --- services/capture/src/capture-manager.js | 8 +- services/web-ui/public/screens-library.jsx | 912 +++++++++++++++++++-- services/web-ui/public/visuals.jsx | 41 +- 3 files changed, 875 insertions(+), 86 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 9003b3b..ae76051 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -311,7 +311,7 @@ const GROWING_PART_INTERVAL_FRAMES = 30; // a sensible value from the recorder's configured resolution/framerate; if those // are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of // the actual SDI raster/fps is advised before production use (see report). -function deriveGrowingRaster(resolution, framerate) { +function deriveGrowingRaster(resolution, framerate, scanHint = null) { // Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'… let fpsNum = null; const fr = (framerate == null) ? '' : String(framerate).trim(); @@ -352,7 +352,9 @@ function deriveGrowingRaster(resolution, framerate) { } // Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p. - if (scan == null) scan = (height >= 1080) ? 'i' : 'p'; + // scanHint ('p'/'i') overrides this default so progressive Deltacast captures + // are wrapped as progressive MXFs. + if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p'); const r = rates(fpsNum); let rawFlag; @@ -683,7 +685,7 @@ class CaptureManager { * Returns the argv for spawn('bash', argv). */ _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) { - const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate); + const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p'); const vb = videoBitrate || GROWING_DEFAULT_BITRATE; const ach = audioChannels ? Number(audioChannels) : 2; diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index faa82e0..1b318d5 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -1,85 +1,863 @@ // screens-library.jsx -// visuals.jsx - reusable visual elements -const _thumbCache = new Map(); +function Library({ navigate, onOpenAsset, openProject, onClearProject }) { + const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; + const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []); + const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged -function AssetThumb({ asset, size = 'md' }) { - const aspect = size === 'tall' ? '9 / 16' : '16 / 9'; - const [thumbUrl, setThumbUrl] = React.useState(_thumbCache.get(asset.id) || null); + // Re-fetch bins on mount + whenever the open project changes; surfaces + // every-project bins when the global view is on, project-scoped otherwise. + var refreshBins = React.useCallback(function() { + var qs2 = openProject ? '?project_id=' + openProject.id : ''; + window.ZAMPP_API.fetch('/bins' + qs2) + .then(function(list) { + var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; }); + if (!openProject) window.ZAMPP_DATA.BINS = normalized; + setBins(normalized); + }) + .catch(function() {}); + }, [openProject]); - React.useEffect(() => { - if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return; - let cancelled = false; - fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' }) - .then(r => r.ok ? r.json() : null) - .then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } }) + React.useEffect(function() { + refreshBins(); + var onBinsChanged = function() { refreshBins(); }; + window.addEventListener('df:bins-changed', onBinsChanged); + return function() { window.removeEventListener('df:bins-changed', onBinsChanged); }; + }, [refreshBins]); + + const createBin = () => { + if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; } + setNewBinName(''); setCreatingBin(true); + }; + + const submitBin = (name) => { + if (!name || !name.trim()) { setCreatingBin(false); return; } + setCreatingBin(false); + window.ZAMPP_API.fetch('/bins', { + method: 'POST', + body: JSON.stringify({ project_id: openProject.id, name: name.trim() }), + }) + .then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id)) + .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' })))) + .catch(e => window.alert('Could not create bin: ' + e.message)); + }; + const [view, setView] = React.useState('grid'); + const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent' + const [search, setSearch] = React.useState(window._dfPendingSearch || ''); + React.useEffect(() => { delete window._dfPendingSearch; }, []); + // Local state lets us re-render after delete / move-to-bin without forcing + // a full app reload - keeps ZAMPP_DATA in sync as the cache of record. + const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); + const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } + const [renamingAsset, setRenamingAsset] = React.useState(null); + const [confirm, confirmModal] = window.useConfirm(); + // Asset queued for hi-res download. Null means no modal showing. Set when + // the user clicks Download and has NOT dismissed the "are you sure" warning. + const [pendingDownload, setPendingDownload] = React.useState(null); + + // Single entry point for hi-res downloads from the library. If the user + // ticked "Don't show again" in the warning modal, skip straight to the + // download; otherwise pop the modal with this asset queued. The modal's + // confirm handler calls runDownload directly. + const requestDownload = function(asset) { + if (!asset || !asset.original_s3_key) return; + try { + if (localStorage.getItem('df.lib.download.warnDismissed') === '1') { + runDownload(asset); + return; + } + } catch (_) { /* localStorage unavailable: show modal to be safe */ } + setPendingDownload(asset); + }; + const [creatingBin, setCreatingBin] = React.useState(false); + const [newBinName, setNewBinName] = React.useState(''); + const [draggingAssetId, setDraggingAssetId] = React.useState(null); + const [dragOverBinId, setDragOverBinId] = React.useState(null); + const [recentlyMovedId, setRecentlyMovedId] = React.useState(null); + // Rename project state + const [renamingProject, setRenamingProject] = React.useState(null); + const [projVersion, setProjVersion] = React.useState(0); + // Multi-select state + const [selectedAssets, setSelectedAssets] = React.useState(new Set()); + const [selectionMode, setSelectionMode] = React.useState(false); + + const refreshAssets = React.useCallback(() => { + window.ZAMPP_API.refreshAssets() + .then(function(normalized) { + setAllAssets(normalized); + }) .catch(() => {}); - return () => { cancelled = true; }; - }, [asset.id, asset.thumbnail_s3_key]); + }, []); - if (asset.type === 'audio' || asset.media_type === 'audio') { - return ( -
- -
-
- ); - } + const toggleSelection = function(assetId, e) { + if (e) e.stopPropagation(); + setSelectedAssets(function(prev) { + var next = new Set(prev); + if (next.has(assetId)) next.delete(assetId); + else next.add(assetId); + return next; + }); + }; - if (asset.status === 'live' && asset.id) { - if (asset.thumbnail_s3_key || thumbUrl) { - const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail'; - return ( -
- {thumbUrl - ? {altText} - : } -
-
- ); + const selectAll = function() { + setSelectedAssets(new Set(assets.map(function(a) { return a.id; }))); + }; + + const clearSelection = function() { + setSelectedAssets(new Set()); + setSelectionMode(false); + }; + + const bulkMoveToBin = async function(binId) { + var ids = Array.from(selectedAssets); + if (ids.length === 0) return; + var targetBin = bins.find(function(b) { return b.id === binId; }); + for (var i = 0; i < ids.length; i++) { + var asset = allAssets.find(function(a) { return a.id === ids[i]; }); + if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) { + alert('Cannot move assets to a bin in a different project.'); + return; + } } - return ; - } + try { + await Promise.all(ids.map(function(id) { + return window.ZAMPP_API.fetch('/assets/' + id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }); + })); + refreshAssets(); + window.dispatchEvent(new Event('df:bins-changed')); + clearSelection(); + } catch (e) { + alert('Bulk move failed: ' + e.message); + } + }; - // pending_migration: placeholder with upload icon, no video - if (asset.status === 'pending_migration') { - return ( -
-
- - Awaiting migration -
-
- ); - } + const bulkDelete = async function() { + var ids = Array.from(selectedAssets); + if (ids.length === 0) return; + if (!(await confirm({ + title: 'Delete ' + ids.length + ' assets?', + message: 'Delete ' + ids.length + ' assets permanently?\nThis removes the database rows and S3 objects.\nThis cannot be undone.', + }))) return; + try { + await Promise.all(ids.map(function(id) { + return window.ZAMPP_API.fetch('/assets/' + id + '?hard=true', { method: 'DELETE' }); + })); + refreshAssets(); + clearSelection(); + } catch (e) { + alert('Bulk delete failed: ' + e.message); + } + }; + + const deleteAsset = React.useCallback(async (asset) => { + if (!(await confirm({ + title: 'Delete asset?', + message: 'Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.', + }))) return; + window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' }) + .then(refreshAssets) + .catch(function(e) { alert('Delete failed: ' + e.message); }); + }, [confirm, refreshAssets]); + + // Auto-refresh: poll the library while it's open so live recordings flip + // to 'ready' (with thumbnail) without a manual reload. Also pull once on + // mount so uploads/imports created on other screens appear immediately. + const hasLive = React.useMemo( + () => allAssets.some(a => a.status === 'live' || a.status === 'processing' || a.status === 'ingesting'), + [allAssets] + ); + React.useEffect(() => { + refreshAssets(); + const tick = hasLive ? 4000 : 15000; + const id = setInterval(refreshAssets, tick); + const onAssetsChanged = () => refreshAssets(); + window.addEventListener('df:assets-changed', onAssetsChanged); + return () => { + clearInterval(id); + window.removeEventListener('df:assets-changed', onAssetsChanged); + }; + }, [hasLive, refreshAssets]); + + // Dismiss the context menu on any outside click (capture phase so clicking + // a menu item still fires before the menu unmounts). + React.useEffect(() => { + if (!ctxMenu) return; + const close = () => setCtxMenu(null); + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + window.removeEventListener('scroll', close, true); + }; + }, [ctxMenu]); + + const openCtx = (asset, e) => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ asset, x: e.clientX, y: e.clientY }); + }; + + // Drag-and-drop: asset → bin + const onAssetDragStart = function(assetId, e) { + e.dataTransfer.setData('text/plain', assetId); + e.dataTransfer.effectAllowed = 'move'; + setDraggingAssetId(assetId); + }; + + const onBinDragOver = function(binId, e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverBinId !== binId) setDragOverBinId(binId); + }; + + const onBinDragLeave = function() { + setDragOverBinId(null); + }; + + const onBinDrop = function(binId, e) { + e.preventDefault(); + setDraggingAssetId(null); + var assetId = e.dataTransfer.getData('text/plain'); + if (!assetId) return; + // Guard against cross-project moves + var asset = allAssets.find(function(a) { return a.id === assetId; }); + var targetBin = bins.find(function(b) { return b.id === binId; }); + if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) { + alert('Cannot move asset to a bin in a different project.'); + return; + } + window.ZAMPP_API.fetch('/assets/' + assetId, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) + .then(function() { + setRecentlyMovedId(assetId); + refreshAssets(); + window.dispatchEvent(new Event('df:bins-changed')); + setTimeout(function() { setRecentlyMovedId(null); }, 2000); + }) + .catch(function(e2) { alert('Move failed: ' + e2.message); }); + }; + + // Project rename + const renameProject = function(p) { setRenamingProject(p); }; + + // Close drag state when drag ends anywhere + React.useEffect(function() { + if (!draggingAssetId) return; + var onEnd = function() { setDraggingAssetId(null); }; + window.addEventListener('dragend', onEnd); + return function() { window.removeEventListener('dragend', onEnd); }; + }, [draggingAssetId]); + + // Project context menu state + var [projectCtx, setProjectCtx] = React.useState(null); + React.useEffect(function() { + if (!projectCtx) return; + var close = function() { setProjectCtx(null); }; + // #49: add contextmenu + scroll dismiss so right-clicking another project + // or scrolling away doesn't leave the menu orphaned + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + window.addEventListener('scroll', close, true); + return function() { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + window.removeEventListener('scroll', close, true); + }; + }, [projectCtx]); + + var openProjectCtx = function(p, e) { + e.preventDefault(); + e.stopPropagation(); + setProjectCtx({ project: p, x: e.clientX, y: e.clientY }); + }; + + const [selectedBinId, setSelectedBinId] = React.useState(null); + // Clear bin filter on project change so a stale id doesn't hide everything. + React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]); + let assets = openProject + ? allAssets.filter(function(a) { return a.project_id === openProject.id; }) + : allAssets; + const ALL_ASSETS = allAssets; + if (filter === 'recent') { + assets = assets.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }); + } else 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()); }); + if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; }); + + const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null; + const displayTitle = activeBin + ? (openProject ? openProject.name + ' · ' : '') + activeBin.name + : (openProject ? openProject.name : 'All Assets'); + 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; - const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; return ( -
- {thumbUrl - ? {altText} - : } +
+ {confirmModal} + + +
+
+

{displayTitle}

+ · {assets.length} assets +
+ {selectionMode && selectedAssets.size > 0 && ( +
+ {selectedAssets.size} selected +
+ + +
+ {BINS.length > 0 && ( + + )} + +
+ )} + +
+ + +
+
+ {['all', 'ready', 'processing', 'live', 'error'].map(function(f) { + return ( + + ); + })} +
+
+ + +
+ +
+ + {assets.length === 0 ? ( +
No assets match this filter.
+ ) : view === 'grid' ? ( +
+ {assets.map(function(a) { + return ; + })} +
+ ) : ( +
+
+ {selectionMode &&
} +
Name
Duration
Resolution
Codec
Size
Updated
+
+ {assets.map(function(a) { + return ( +
+ {selectionMode && ( +
+ +
+ )} +
+
+
{a.name}
+
+ + {a.status} +
+
+
{a.duration}
+
{a.res}
+
{a.codec || '·'}
+
{a.size}
+
{a.updated}
+ +
+ ); + })} +
+ )} +
+ {ctxMenu && ( + + )} + {pendingDownload && ( + + )} + {renamingAsset && ( + + )} + {projectCtx && ( + + )} + {renamingProject && window.RenameProjectModal && ( + React.createElement(window.RenameProjectModal, { + project: renamingProject, + onClose: function() { setRenamingProject(null); }, + onSaved: function() { + setRenamingProject(null); + // Re-fetch projects and update ZAMPP_DATA so the rail refreshes + window.ZAMPP_API.fetch('/projects').then(function(list) { + if (Array.isArray(list)) { + window.ZAMPP_DATA.PROJECTS = list.map(function(p, i) { + return { + ...p, + color: (window.ZAMPP_DATA.PROJECTS.find(function(x) { return x.id === p.id; }) || {}).color + || window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)] + || 'var(--accent)', + assets: (window.ZAMPP_DATA?.ASSETS || []).filter(function(a) { return a.project_id === p.id; }).length, + updated: window.ZAMPP_API.fmtRelative(p.updated_at), + }; + }); + setProjVersion(function(v) { return v + 1; }); + } + }).catch(function() {}); + } + }) + )}
); } -Both files written. Summary of changes: +// Hi-res download trigger shared by the card and the context menu. Resolves +// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext }) +// and clicks a hidden anchor so the browser does the download. The download +// itself is direct S3 - never proxied through mam-api - so big files don't +// touch the API container. +function runDownload(asset) { + if (!asset || !asset.id) return; + return window.ZAMPP_API.fetch('/assets/' + asset.id + '/hires') + .then(function(r) { + if (!r || !r.url) { alert('No hi-res source available for this asset.'); return; } + const a = document.createElement('a'); + a.href = r.url; + a.download = r.filename || ((asset.display_name || asset.name || asset.id) + '.' + (r.ext || 'mov')); + a.target = '_blank'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }) + .catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); }); +} -**screens-library.jsx** -- `AssetCard` gains `onMigrate` prop; all `AssetCard` usages in grid pass `onMigrate={refreshAssets}` -- `AssetCard` thumb-status block adds `pending_migration` badge (`Pending Migration`) -- `AssetCard` adds `.thumb-migrate-btn` button (position absolute, bottom 8px, left/right 8px) calling `POST /assets/:id/migrate-to-library` -- List view actions column gains inline migrate button for `pending_migration` rows -- Filter tab-group gains "Pending" tab (`pending_migration`) -- Smart filters rail gains "Pending migration" entry (visible only when count > 0) -- `hasLive` memo includes `pending_migration` so 4s poll activates when any such asset exists +function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload, onDelete }) { + const ref = React.useRef(null); + // Pin the menu inside the viewport even if the user right-clicked near + // the bottom-right edge of the grid. + const [pos, setPos] = React.useState({ left: x, top: y }); + React.useLayoutEffect(() => { + if (!ref.current) return; + const r = ref.current.getBoundingClientRect(); + const margin = 8; + let nx = x, ny = y; + if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin; + if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin; + setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) }); + }, [x, y]); -**visuals.jsx** -- `AssetThumb` handles `pending_migration` before the generic fallback: renders static placeholder with upload icon + "Awaiting migration" label, no video/HLS -- `StatusDot` map gains `pending_migration: { color: 'var(--warning)', pulse: false }` + const rename = function() { if (onRename) onRename(asset); else onClose(); }; -Files at: -- `/home/node/workspace/dragonflight/services/web-ui/public/screens-library.jsx` -- `/home/node/workspace/dragonflight/services/web-ui/public/visuals.jsx` + const moveToBin = function(binId) { + onClose(); + window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) + .then(function() { onChanged(); window.dispatchEvent(new Event('df:bins-changed')); }) + .catch(function(e) { alert('Move failed: ' + e.message); }); + }; + + const copyId = function() { + onClose(); + if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {}); + }; + + const remove = function() { + if (onDelete) { onDelete(asset); return; } + onClose(); + }; + + return ( +
+
{asset.display_name || asset.name}
+ + + {asset.original_s3_key && onDownload && ( + + )} +
+ {(bins && bins.length > 0) ? ( + <> +
Move to bin
+ {bins + .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; }) + .slice(0, 10) + .map(function(b) { + const isCurrent = asset.bin_id === b.id; + return ( + + ); + })} + {asset.bin_id && ( + + )} + + ) : ( +
No bins: create one inside a project
+ )} +
+ + +
+ ); +} + +function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable, selectionMode, isSelected, onToggleSelect }) { + 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) { + 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 ( +
+
+ {selectionMode && ( +
+ +
+ )} + + {showVideo && ( +
+
+
{asset.name}
+
+ {asset.res} + · + {asset.size} +
+
+
+ ); +} + +function ProjectContextMenu({ project, x, y, onClose, onRename }) { + var ref = React.useRef(null); + var [pos, setPos] = React.useState({ left: x, top: y }); + React.useLayoutEffect(function() { + if (!ref.current) return; + var r = ref.current.getBoundingClientRect(); + var margin = 8; + var nx = x, ny = y; + if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin; + if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin; + setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) }); + }, [x, y]); + + return ( +
+
{project.name}
+ + +
+ ); +} + +function binIcon(name) { + return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder'; +} + +function RenameAssetModal({ asset, onClose, onSaved }) { + const [name, setName] = React.useState(asset.display_name || asset.name || ''); + const [saving, setSaving] = React.useState(false); + const [err, setErr] = React.useState(null); + const original = asset.display_name || asset.name || ''; + const submit = function() { + const trimmed = name.trim(); + if (!trimmed || trimmed === original) { onClose(); return; } + setSaving(true); setErr(null); + window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) }) + .then(onSaved) + .catch(function(e) { setSaving(false); setErr(e.message); }); + }; + return ( +
+
+
+
Rename asset
+ +
+
+
+ + +
+ {err &&
{err}
} +
+
+ + +
+
+
+ ); +} + +// First-run-style warning before a hi-res download. Tells the operator the +// file is full-length, may be multi-GB, and that speed depends on their +// connection. "Don't show again on this device" persists the dismissal so +// subsequent downloads (and every Download click after dismissal) skip +// straight to the file. Settings → Account exposes a control to re-enable. +function DownloadWarningModal({ asset, onClose, onConfirm }) { + const [dismiss, setDismiss] = React.useState(false); + const name = asset.display_name || asset.name || asset.id; + return ( +
+
+
+
Download original ingest
+ +
+
+
+ You're about to download the full-length original ingest for + {name}. +
+
+ Originals are often multi-gigabyte. Download speed depends on + your network connection; don't start this on a metered link + or a busy uplink. +
+ +
+
+ + +
+
+
+ ); +} + +window.Library = Library; +window.AssetCard = AssetCard; diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx index 9e8170d..5382aa6 100644 --- a/services/web-ui/public/visuals.jsx +++ b/services/web-ui/public/visuals.jsx @@ -25,6 +25,10 @@ function AssetThumb({ asset, size = 'md' }) { ); } + // Live/recording assets: once the capture sidecar has published a poster + // thumbnail (first frame of the recording), show that static frame instead + // of the HLS "connecting…" player. Until the poster exists (the brief window + // before the first segment is grabbed), fall back to the live HLS preview. if (asset.status === 'live' && asset.id) { if (asset.thumbnail_s3_key || thumbUrl) { const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail'; @@ -33,6 +37,7 @@ function AssetThumb({ asset, size = 'md' }) { {thumbUrl ? {altText} : } + {/* Keep the pulsing LIVE border so it still reads as recording */}
); @@ -40,19 +45,6 @@ function AssetThumb({ asset, size = 'md' }) { return ; } - if (asset.status === 'pending_migration') { - return ( -
-
- - Awaiting migration -
-
- ); - } - const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; return (
@@ -63,6 +55,10 @@ function AssetThumb({ asset, size = 'md' }) { ); } +// Muted inline HLS preview for a live/recording asset tile. Attaches hls.js +// (or native HLS on Safari) to show the live feed inside the library card. +// Shows a "connecting…" spinner while the manifest loads, falls back to a +// placeholder with a record icon if hls.js is unavailable or playback fails. function LiveThumb({ assetId, aspect }) { const videoRef = React.useRef(null); const [ready, setReady] = React.useState(false); @@ -133,6 +129,7 @@ function LiveThumb({ assetId, aspect }) { autoPlay style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> + {/* Pulsing red border while connecting or playing, matches LIVE badge colour */}
{!ready && !failed && (
; @@ -254,6 +250,19 @@ function Elapsed({ seconds, live = false }) { return {String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')}; } +// ───────────────────────────────────────────────────────────────────────── +// ConfirmModal + useConfirm — in-page replacement for window.confirm(). +// +// Usage in a component: +// const [confirm, confirmModal] = useConfirm(); +// ... +// if (!(await confirm({ title: 'Delete user?', message: '…' }))) return; +// ... +// return (<>{confirmModal} ...rest of UI... ); +// +// confirm(opts) returns a Promise. Options: +// title, message, confirmLabel (default 'Delete'), cancelLabel ('Cancel'), +// danger (default true → red confirm button). function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = 'Cancel', danger = true, onConfirm, onCancel }) { React.useEffect(() => { const onKey = (e) => { @@ -274,7 +283,7 @@ function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = '
{typeof message === 'string' ? message.split('\n').map((line, i) => ( -
{line || ' '}
+
{line || ' '}
)) :
{message}
}
@@ -288,7 +297,7 @@ function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = ' } function useConfirm() { - const [state, setState] = React.useState(null); + const [state, setState] = React.useState(null); // { opts, resolve } | null const confirm = React.useCallback((opts) => { return new Promise((resolve) => {