Supra_App/src/routes/(authed)/tools/adv-upload/+page.svelte

812 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">import { logger } from '$lib/core/utils/logger';
import { auth } from '$lib/core/stores/auth';
import { addNotification } from '$lib/core/stores/noti';
import Button from '$lib/components/ui/button/button.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { Upload, X, CheckCircle, AlertCircle, RefreshCw, Play, Video, MonitorPlay, Eye } from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
import { AdbInstance } from '../../../state.svelte';
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
const LIST_PROXY_ENDPOINT = '/api/adv-list';
const FILE_PROXY_ENDPOINT = '/api/adv-file';
const ALLOWED_EXTENSIONS = ['.mp4'];
// ─────────────────────────────────────────────────────────────────────────
// Slot-based upload. There are 21 fixed slots (0020). Each slot has a fixed
// TARGET filename — the source file's own name is ignored; it is renamed to
// the slot's name (taobin_adv_menu_NN.mp4) right before upload. Every upload
// asks the backend to regenerate sync_1.file from the FTP listing.
// ─────────────────────────────────────────────────────────────────────────
const SLOT_COUNT = 21; // 00 .. 20
const MENU_SPEC = { width: 1080, height: 380 }; // taobin_adv_menu_* banner size
function slotFilename(i: number): string {
return `taobin_adv_menu_${String(i).padStart(2, '0')}.mp4`;
}
// Only Thailand is enabled for now (other countries need their SFTP config on
// the backend before being added).
const COUNTRIES = [{ value: 'tha', label: 'Thailand (tha)' }];
let selectedCountry = $state('tha');
// adv folder on the machine. Domestic Thailand uses the flat folder; every
// international country uses inter/<country>/adv (matches the on-machine layout).
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
function machineAdvDir(country: string): string {
return country === 'tha'
? `${MACHINE_PROJECT_DIR}/adv`
: `${MACHINE_PROJECT_DIR}/inter/${country}/adv`;
}
interface Slot {
index: number;
name: string; // fixed target filename
file: File | null;
preview: string | null; // object URL of the chosen file
status: 'empty' | 'ready' | 'uploading' | 'success' | 'error';
error?: string;
width?: number;
height?: number;
dimChecked: boolean;
dimOk: boolean;
}
function emptySlot(i: number): Slot {
return {
index: i,
name: slotFilename(i),
file: null,
preview: null,
status: 'empty',
dimChecked: false,
dimOk: false
};
}
let slots = $state<Slot[]>(Array.from({ length: SLOT_COUNT }, (_, i) => emptySlot(i)));
let uploadingAll = $state(false);
const filledCount = $derived(slots.filter((s) => s.file).length);
const pendingCount = $derived(slots.filter((s) => s.file && s.status !== 'success').length);
// Push-to-machine (ADB) — preview the videos on a connected machine.
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let pushingToMachine = $state(false);
let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 });
let connecting = $state(false);
const anyBusy = $derived(
uploadingAll || pushingToMachine || slots.some((s) => s.status === 'uploading')
);
// How many of the 21 slot names currently have a video on the server.
const onServerCount = $derived(slots.filter((s) => serverFilesByName[s.name] !== undefined).length);
// Left-accent colour that encodes a slot's state at a glance.
function slotAccent(s: Slot): string {
if (s.status === 'success') return 'border-l-green-500';
if (s.status === 'error') return 'border-l-red-500';
if (s.file) return 'border-l-primary';
// No local file yet, but a video already lives on the server for this slot.
if (serverFilesByName[s.name] !== undefined) return 'border-l-green-500';
return 'border-l-border';
}
async function authHeaders(): Promise<Record<string, string>> {
const token = await $auth?.getIdToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function readVideoDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
const url = URL.createObjectURL(file);
video.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve({ width: video.videoWidth, height: video.videoHeight });
};
video.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Cannot read video metadata'));
};
video.src = url;
});
}
// Assign a chosen file to a slot. The source name is irrelevant — only .mp4 is
// required. Dimensions are checked against the banner spec as a soft warning.
function pickSlotFile(index: number, fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const ext = '.' + (file.name.split('.').pop()?.toLowerCase() ?? '');
if (!ALLOWED_EXTENSIONS.includes(ext)) {
addNotification(`WARN:${file.name} - Only .mp4 allowed`);
return;
}
const prev = slots[index];
if (prev.preview) URL.revokeObjectURL(prev.preview);
slots[index] = {
...emptySlot(index),
file,
preview: URL.createObjectURL(file),
status: 'ready'
};
readVideoDimensions(file)
.then(({ width, height }) => {
if (slots[index].file !== file) return; // slot changed meanwhile
slots[index].width = width;
slots[index].height = height;
slots[index].dimChecked = true;
slots[index].dimOk = width === MENU_SPEC.width && height === MENU_SPEC.height;
})
.catch(() => {
if (slots[index].file !== file) return;
slots[index].dimChecked = true;
slots[index].dimOk = false;
});
}
function clearSlot(index: number) {
const slot = slots[index];
if (slot.preview) URL.revokeObjectURL(slot.preview);
slots[index] = emptySlot(index);
}
// Upload a single slot: rename the file to the slot's fixed name, then POST.
async function uploadSlot(index: number): Promise<boolean> {
const currentUser = $auth;
if (!currentUser) {
addNotification('ERR:Not logged in');
return false;
}
const slot = slots[index];
if (!slot.file) return false;
slots[index].status = 'uploading';
slots[index].error = undefined;
try {
// Ignore the source filename — send it under the slot's target name.
const renamed = new File([slot.file], slot.name, { type: 'video/mp4' });
const formData = new FormData();
formData.append('country', selectedCountry);
formData.append('uid', currentUser.uid);
formData.append('displayName', currentUser.displayName || 'unknown');
formData.append('email', currentUser.email || 'unknown@email.com');
formData.append('file', renamed);
// regenerate=true → backend rebuilds sync_1.file from the FTP listing.
formData.append('regenerate', 'true');
const res = await fetch(UPLOAD_PROXY_ENDPOINT, {
method: 'POST',
headers: await authHeaders(),
body: formData
});
if (!res.ok) {
const e = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(e.detail || e.message || 'Upload failed');
}
slots[index].status = 'success';
addNotification(`INFO:Uploaded ${slot.name}`);
// The server now has (a new) file for this slot — drop the stale preview
// blob and refresh the indicator so "View current" shows the fresh one.
if (videoBlobUrls[slot.name]) {
URL.revokeObjectURL(videoBlobUrls[slot.name]);
const { [slot.name]: _drop, ...rest } = videoBlobUrls;
videoBlobUrls = rest;
}
refreshServerIndex();
return true;
} catch (error) {
slots[index].status = 'error';
slots[index].error = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[Adv] slot ${index} upload error:`, error);
return false;
}
}
// Upload every filled slot that hasn't succeeded yet, one at a time.
async function uploadAllFilled() {
if (!$auth) {
addNotification('ERR:Not logged in');
return;
}
const targets = slots.filter((s) => s.file && s.status !== 'success');
if (targets.length === 0) {
addNotification('WARN:No slots to upload');
return;
}
uploadingAll = true;
try {
let ok = 0;
let fail = 0;
for (const slot of targets) {
const success = await uploadSlot(slot.index);
success ? ok++ : fail++;
}
if (fail === 0) addNotification(`INFO:Uploaded ${ok} slot(s) successfully`);
else addNotification(`WARN:Uploaded ${ok}, failed ${fail}`);
} finally {
uploadingAll = false;
}
}
async function connectMachine() {
if (AdbInstance.instance) {
addNotification('INFO:Machine already connected');
return;
}
connecting = true;
try {
await adb.connnectViaWebUSB(false);
addNotification(AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected');
} catch (error) {
logger.error('[Adv] connect error:', error);
addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
connecting = false;
}
}
// Push every filled slot's video to the connected machine's adv folder (under
// the slot's fixed name) so it can be previewed on the machine before upload.
async function pushToMachine() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected (ADB)');
return;
}
const targets = slots.filter((s) => s.file);
if (targets.length === 0) {
addNotification('WARN:No slots to push');
return;
}
const targetDir = machineAdvDir(selectedCountry);
pushingToMachine = true;
pushProgress = { current: 0, total: targets.length, name: '', percent: 0 };
let success = 0;
try {
for (let i = 0; i < targets.length; i++) {
const slot = targets[i];
pushProgress = { current: i, total: targets.length, name: slot.name, percent: 0 };
const bytes = new Uint8Array(await slot.file!.arrayBuffer());
const ok = await adb.pushBinary(`${targetDir}/${slot.name}`, bytes, (sent, total) => {
pushProgress = {
current: i,
total: targets.length,
name: slot.name,
percent: total > 0 ? Math.round((sent / total) * 100) : 0
};
});
if (ok) {
success++;
pushProgress = { current: i + 1, total: targets.length, name: slot.name, percent: 100 };
} else {
addNotification(`ERR:Push failed: ${slot.name}`);
}
}
if (success > 0) {
addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`);
}
} catch (error) {
logger.error('[Adv] push to machine error:', error);
addNotification(`ERR:Push error: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
pushingToMachine = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Preview adv videos already on the server (FTP).
// Adv videos live only on the FTP server — no nginx/git copy — so we fetch
// each one through the auth-guarded proxy as a Blob and play it via an object
// URL. Lets you verify an upload actually landed correctly.
// ─────────────────────────────────────────────────────────────────────────
interface ServerVideo {
name: string;
size: number;
mtime: number;
}
let serverVideos = $state<ServerVideo[]>([]);
let loadingServerVideos = $state(false);
let serverVideosLoaded = $state(false);
// name -> object URL (created automatically after the list loads)
let videoBlobUrls = $state<Record<string, string>>({});
let loadingVideoName = $state<string | null>(null);
let videoLoadProgress = $state({ current: 0, total: 0 });
// Lightweight name→size map of what is on the server RIGHT NOW, used to show a
// per-slot "on server" indicator (so you can preview the current video before
// overwriting a slot). Refreshed on mount, on country change, and after upload.
let serverFilesByName = $state<Record<string, number>>({});
// The server video currently open in the preview lightbox (or null).
let viewingName = $state<string | null>(null);
function formatBytes(bytes: number): string {
if (!bytes) return '0 MB';
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// List-only fetch (no blobs) — cheap, just to know what each slot currently has.
async function refreshServerIndex() {
try {
const res = await fetch(LIST_PROXY_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await authHeaders()) },
body: JSON.stringify({ country: selectedCountry })
});
if (!res.ok) return;
const data = await res.json();
const map: Record<string, number> = {};
for (const f of (data.files ?? []) as ServerVideo[]) map[f.name] = f.size;
serverFilesByName = map;
} catch (error) {
logger.error('[Adv] refresh server index error:', error);
}
}
// 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
if (videoBlobUrls[name]) viewingName = name;
}
async function loadServerVideos() {
loadingServerVideos = true;
try {
const res = await fetch(LIST_PROXY_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await authHeaders()) },
body: JSON.stringify({ country: selectedCountry })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'Failed to list videos');
}
const data = await res.json();
// Reset any previously loaded blobs (folder/country may have changed).
for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url);
videoBlobUrls = {};
serverVideos = (data.files ?? []) as ServerVideo[];
serverVideosLoaded = true;
// Auto-fetch every video so each shows a ready-to-play player without a
// manual click. Limited concurrency keeps it from hammering the SFTP.
await autoLoadAllVideos();
} catch (error) {
logger.error('[Adv] list server videos error:', error);
addNotification(`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
loadingServerVideos = false;
}
}
// Fetch every server video's blob with a small concurrency pool (each request
// opens its own SFTP session on the backend, so we don't want 25 at once).
async function autoLoadAllVideos() {
const names = serverVideos.map((v) => v.name).filter((n) => !videoBlobUrls[n]);
videoLoadProgress = { current: 0, total: names.length };
if (names.length === 0) return;
const CONCURRENCY = 3;
let next = 0;
async function worker() {
while (next < names.length) {
const i = next++;
await playServerVideo(names[i]);
videoLoadProgress = { ...videoLoadProgress, current: videoLoadProgress.current + 1 };
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
}
async function playServerVideo(name: string) {
if (videoBlobUrls[name]) return; // already loaded
loadingVideoName = name;
try {
const url = `${FILE_PROXY_ENDPOINT}?country=${encodeURIComponent(selectedCountry)}&filename=${encodeURIComponent(name)}`;
const res = await fetch(url, { headers: await authHeaders() });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'Failed to fetch video');
}
const blob = await res.blob();
videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) };
} catch (error) {
logger.error('[Adv] fetch server video error:', error);
addNotification(`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
loadingVideoName = null;
}
}
// Refresh the per-slot "on server" indicators on mount and whenever the
// selected country changes.
$effect(() => {
void selectedCountry;
refreshServerIndex();
});
$effect(() => {
return () => {
for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview);
for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url);
};
});
</script>
<div class="flex min-h-screen flex-col bg-muted/20">
<!-- Header -->
<header class="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
<div class="mx-auto flex max-w-6xl flex-col gap-4 px-8 py-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Video class="h-6 w-6" />
</div>
<div>
<h1 class="text-xl font-bold tracking-tight">Advertisement Upload</h1>
<!-- <p class="text-sm text-muted-foreground">21 fixed slots · menu banner {MENU_SPEC.width}×{MENU_SPEC.height}</p> -->
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant={isAdbConnected ? 'default' : 'secondary'} class="gap-1">
<span class="h-1.5 w-1.5 rounded-full {isAdbConnected ? 'bg-white' : 'bg-muted-foreground'}"
></span>
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
</Badge>
{#if !isAdbConnected}
<Button variant="outline" onclick={connectMachine} disabled={connecting}>
{#if connecting}
<Spinner class="mr-2 h-4 w-4" />
Connecting...
{:else}
<MonitorPlay class="mr-2 h-4 w-4" />
Connect
{/if}
</Button>
{/if}
<Button
variant="outline"
onclick={pushToMachine}
disabled={anyBusy || !isAdbConnected || filledCount === 0}
>
{#if pushingToMachine}
<Spinner class="mr-2 h-4 w-4" />
Pushing {pushProgress.current}/{pushProgress.total}...
{:else}
<MonitorPlay class="mr-2 h-4 w-4" />
Push to Machine{filledCount > 0 ? ` (${filledCount})` : ''}
{/if}
</Button>
<Select.Root type="single" bind:value={selectedCountry}>
<Select.Trigger class="h-9 w-40">
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Country'}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES as country}
<Select.Item value={country.value}>{country.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button onclick={uploadAllFilled} disabled={anyBusy || pendingCount === 0}>
{#if uploadingAll}
<Spinner class="mr-2 h-4 w-4" />
Uploading...
{:else}
<Upload class="mr-2 h-4 w-4" />
Upload all{pendingCount > 0 ? ` (${pendingCount})` : ''}
{/if}
</Button>
</div>
</div>
<!-- Progress bar: only while pushing to a machine -->
{#if pushingToMachine}
<div class="flex items-center gap-3">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
style="width: {pushProgress.total > 0
? (pushProgress.current / pushProgress.total) * 100
: 0}%"
></div>
</div>
<span class="shrink-0 text-xs font-medium text-muted-foreground">
Pushing {pushProgress.name} · {pushProgress.percent}%
</span>
</div>
{/if}
</div>
</header>
<!-- Content -->
<div class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-6xl space-y-8 px-8 py-8">
<!-- Slots -->
<section>
<div class="mb-3 flex items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Slots</h2>
<p class="text-sm text-muted-foreground">
Choose a video per slot — it's renamed to the slot's fixed name on upload.
</p>
</div>
{#if onServerCount > 0}
<Badge
class="gap-1 whitespace-nowrap border-green-500/40 bg-green-500/15 text-green-600 hover:bg-green-500/15 dark:text-green-400"
>
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
{onServerCount} on server
</Badge>
{/if}
</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-2">
{#each slots as slot (slot.index)}
<div
class="group flex items-center gap-3 rounded-xl border border-l-4 bg-card p-3 shadow-sm transition-shadow hover:shadow-md {slotAccent(
slot
)}"
>
<!-- Slot number -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold tabular-nums {slot.file
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted-foreground'}"
>
{String(slot.index).padStart(2, '0')}
</div>
<!-- Thumbnail / picker -->
<div class="relative aspect-video h-16 shrink-0 overflow-hidden rounded-lg bg-black">
{#if slot.preview}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={slot.preview} class="h-full w-full object-cover" muted></video>
{#if slot.status === 'uploading'}
<div class="absolute inset-0 flex items-center justify-center bg-black/60">
<Spinner class="h-5 w-5 text-white" />
</div>
{:else if slot.status === 'success'}
<div class="absolute inset-0 flex items-center justify-center bg-green-500/25">
<CheckCircle class="h-6 w-6 text-white drop-shadow" />
</div>
{/if}
{: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"
>
<input
type="file"
accept=".mp4,video/mp4"
class="hidden"
onchange={(e) => {
const el = e.currentTarget as HTMLInputElement;
pickSlotFile(slot.index, el.files);
el.value = '';
}}
/>
<Upload class="h-4 w-4" />
<span class="text-[10px] font-medium">Choose</span>
</label>
{/if}
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-xs font-semibold" title={slot.name}>
{slot.name}
</p>
{#if slot.file}
<div class="mt-1 flex flex-wrap items-center gap-1.5">
{#if !slot.dimChecked}
<Badge variant="secondary" class="text-[10px]">Reading…</Badge>
{:else if slot.dimOk}
<Badge variant="default" class="bg-green-500 text-[10px] hover:bg-green-500">
{slot.width}×{slot.height}
</Badge>
{:else}
<Badge variant="destructive" class="text-[10px]">
{slot.width ?? '?'}×{slot.height ?? '?'} · exp {MENU_SPEC.width}×{MENU_SPEC.height}
</Badge>
{/if}
<span class="text-[10px] text-muted-foreground">{formatBytes(slot.file.size)}</span>
</div>
{#if slot.error}
<p
class="mt-1 flex items-center gap-1 text-[10px] text-red-500"
title={slot.error}
>
<AlertCircle class="h-3 w-3 shrink-0" />
<span class="truncate">{slot.error}</span>
</p>
{/if}
{:else}
<p class="mt-1 text-[11px] text-muted-foreground">Empty · no file selected</p>
{/if}
<!-- What's currently on the server for this slot -->
{#if serverFilesByName[slot.name] !== undefined}
<div class="mt-2 flex items-center gap-2">
<Badge
class="gap-1 border-green-500/40 bg-green-500/15 text-[11px] text-green-600 hover:bg-green-500/15 dark:text-green-400"
>
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
On server · {formatBytes(serverFilesByName[slot.name])}
</Badge>
<Button
size="sm"
variant="secondary"
class="gap-1.5 border border-green-500/40 text-green-600 hover:bg-green-500/15 hover:text-green-600 dark:text-green-400 dark:hover:text-green-400"
onclick={() => viewCurrent(slot.name)}
disabled={loadingVideoName === slot.name}
>
{#if loadingVideoName === slot.name}
<Spinner class="h-4 w-4" />
Loading
{:else}
<Eye class="h-4 w-4" />
View video
{/if}
</Button>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-1">
{#if slot.file}
<Button
size="sm"
variant={slot.status === 'success' ? 'outline' : 'default'}
onclick={() => uploadSlot(slot.index)}
disabled={anyBusy || slot.status === 'uploading'}
>
{#if slot.status === 'uploading'}
<Spinner class="h-4 w-4" />
{:else if slot.status === 'success'}
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
Re-upload
{:else}
<Upload class="mr-1.5 h-3.5 w-3.5" />
Upload
{/if}
</Button>
{#if slot.status !== 'uploading'}
<Button
size="icon"
variant="ghost"
class="h-8 w-8 text-muted-foreground hover:text-red-500"
onclick={() => clearSlot(slot.index)}
aria-label="Remove"
>
<X class="h-4 w-4" />
</Button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
</section>
<!-- Videos on server -->
<section>
<div class="mb-3 flex items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Videos on server</h2>
<p class="text-sm text-muted-foreground">
Currently on the FTP folder for <code class="font-mono">{selectedCountry}</code> — loaded
automatically to verify.
</p>
</div>
<Button variant="outline" onclick={loadServerVideos} disabled={loadingServerVideos}>
{#if loadingServerVideos}
<Spinner class="mr-2 h-4 w-4" />
{videoLoadProgress.total > 0
? `Loading ${videoLoadProgress.current}/${videoLoadProgress.total}...`
: 'Loading...'}
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{serverVideosLoaded ? 'Refresh' : 'Load videos'}
{/if}
</Button>
</div>
{#if !serverVideosLoaded}
<div
class="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed py-12 text-center text-muted-foreground"
>
<Video class="h-8 w-8" />
<p class="text-sm">Click "Load videos" to preview what's on the server.</p>
</div>
{:else if serverVideos.length === 0}
<p class="text-sm text-muted-foreground">No videos found on the server.</p>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each serverVideos as video (video.name)}
<div
class="overflow-hidden rounded-xl border bg-card shadow-sm transition-shadow hover:shadow-md"
>
<div class="flex aspect-video items-center justify-center bg-black">
{#if videoBlobUrls[video.name]}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoBlobUrls[video.name]} controls class="h-full w-full"></video>
{:else}
<Button
variant="secondary"
size="sm"
onclick={() => playServerVideo(video.name)}
disabled={loadingVideoName === video.name}
>
{#if loadingVideoName === video.name}
<Spinner class="mr-2 h-4 w-4" />
Loading...
{:else}
<Play class="mr-2 h-4 w-4" />
Play
{/if}
</Button>
{/if}
</div>
<div class="flex items-center justify-between gap-2 p-3">
<span class="truncate font-mono text-xs" title={video.name}>{video.name}</span>
<Badge variant="secondary" class="shrink-0">{formatBytes(video.size)}</Badge>
</div>
</div>
{/each}
</div>
{/if}
</section>
</div>
</div>
<!-- Lightbox: preview the current server video for a slot -->
{#if viewingName}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onclick={() => (viewingName = null)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl overflow-hidden rounded-xl bg-card shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between gap-2 border-b px-4 py-2">
<span class="truncate font-mono text-sm">{viewingName}</span>
<Button
size="icon"
variant="ghost"
class="h-8 w-8"
onclick={() => (viewingName = null)}
aria-label="Close"
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="bg-black">
{#if videoBlobUrls[viewingName]}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoBlobUrls[viewingName]} controls autoplay class="max-h-[70vh] w-full"
></video>
{/if}
</div>
</div>
</div>
{/if}
</div>