812 lines
29 KiB
Svelte
812 lines
29 KiB
Svelte
<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 (00–20). 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>
|