Merge branch 'dev' into 'master'
feat: adv slot thumbnails via ffmpeg poster + view current See merge request Pakin/supra-app!8
This commit is contained in:
commit
c02af91353
2 changed files with 124 additions and 2 deletions
|
|
@ -25,6 +25,7 @@
|
|||
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
|
||||
const LIST_PROXY_ENDPOINT = '/api/adv-list';
|
||||
const FILE_PROXY_ENDPOINT = '/api/adv-file';
|
||||
const THUMB_PROXY_ENDPOINT = '/api/adv-thumb';
|
||||
const ALLOWED_EXTENSIONS = ['.mp4'];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -219,6 +220,11 @@
|
|||
const { [slot.name]: _drop, ...rest } = videoBlobUrls;
|
||||
videoBlobUrls = rest;
|
||||
}
|
||||
if (thumbUrls[slot.name]) {
|
||||
URL.revokeObjectURL(thumbUrls[slot.name]);
|
||||
const { [slot.name]: _t, ...restThumb } = thumbUrls;
|
||||
thumbUrls = restThumb;
|
||||
}
|
||||
refreshServerIndex();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -345,6 +351,8 @@
|
|||
let serverFilesByName = $state<Record<string, number>>({});
|
||||
// The server video currently open in the preview lightbox (or null).
|
||||
let viewingName = $state<string | null>(null);
|
||||
// name -> object URL of the small JPEG poster (thumbnail) for a slot's video.
|
||||
let thumbUrls = $state<Record<string, string>>({});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 MB';
|
||||
|
|
@ -364,11 +372,47 @@
|
|||
const map: Record<string, number> = {};
|
||||
for (const f of (data.files ?? []) as ServerVideo[]) map[f.name] = f.size;
|
||||
serverFilesByName = map;
|
||||
// Fetch a tiny JPEG poster for each slot video so the grid shows a
|
||||
// thumbnail (backend grabs one frame — no full-video download).
|
||||
autoLoadSlotThumbs();
|
||||
} catch (error) {
|
||||
logger.error('[Adv] refresh server index error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the small JPEG poster for one slot video → object URL for an <img>.
|
||||
async function loadSlotThumb(name: string) {
|
||||
if (thumbUrls[name]) return;
|
||||
try {
|
||||
const url = `${THUMB_PROXY_ENDPOINT}?country=${encodeURIComponent(selectedCountry)}&filename=${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url, { headers: await authHeaders() });
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
thumbUrls = { ...thumbUrls, [name]: URL.createObjectURL(blob) };
|
||||
} catch (error) {
|
||||
logger.error('[Adv] load thumb error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load posters for the slot videos on the server (posters are tiny, so a
|
||||
// larger concurrency pool is fine).
|
||||
async function autoLoadSlotThumbs() {
|
||||
const slotNames = new Set(slots.map((s) => s.name));
|
||||
const names = Object.keys(serverFilesByName).filter(
|
||||
(n) => slotNames.has(n) && !thumbUrls[n]
|
||||
);
|
||||
if (names.length === 0) return;
|
||||
|
||||
const CONCURRENCY = 5;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < names.length) {
|
||||
await loadSlotThumb(names[next++]);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
|
||||
}
|
||||
|
||||
// Open the current server video for a slot in the lightbox.
|
||||
async function viewCurrent(name: string) {
|
||||
await playServerVideo(name); // loads the blob if not already
|
||||
|
|
@ -458,6 +502,7 @@
|
|||
return () => {
|
||||
for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview);
|
||||
for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url);
|
||||
for (const url of Object.values(thumbUrls)) URL.revokeObjectURL(url);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
@ -611,6 +656,32 @@
|
|||
<CheckCircle class="h-6 w-6 text-white drop-shadow" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if thumbUrls[slot.name]}
|
||||
<!-- JPEG poster of the video already on the server; still a picker
|
||||
so clicking replaces it with a new file. -->
|
||||
<label class="group/thumb relative block h-full w-full cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp4,video/mp4"
|
||||
class="hidden"
|
||||
onchange={(e) => {
|
||||
const el = e.currentTarget as HTMLInputElement;
|
||||
pickSlotFile(slot.index, el.files);
|
||||
el.value = '';
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={thumbUrls[slot.name]}
|
||||
alt="Current {slot.name}"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/50 text-white opacity-0 transition-opacity group-hover/thumb:opacity-100"
|
||||
>
|
||||
<Upload class="h-4 w-4" />
|
||||
<span class="text-[10px] font-medium">Replace</span>
|
||||
</div>
|
||||
</label>
|
||||
{:else}
|
||||
<label
|
||||
class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-1 border border-dashed border-muted-foreground/30 text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary"
|
||||
|
|
@ -625,8 +696,13 @@
|
|||
el.value = '';
|
||||
}}
|
||||
/>
|
||||
<Upload class="h-4 w-4" />
|
||||
<span class="text-[10px] font-medium">Choose</span>
|
||||
{#if serverFilesByName[slot.name] !== undefined}
|
||||
<Spinner class="h-4 w-4" />
|
||||
<span class="text-[10px] font-medium">Loading…</span>
|
||||
{:else}
|
||||
<Upload class="h-4 w-4" />
|
||||
<span class="text-[10px] font-medium">Choose</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue