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

920 lines
32 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { logger } from '$lib/core/utils/logger';
2026-06-09 10:50:59 +07:00
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';
2026-06-09 10:50:59 +07:00
import * as adb from '$lib/core/adb/adb';
import { AdbInstance } from '../../../state.svelte';
import ScrcpyDialog from '$lib/components/scrcpy-dialog.svelte';
2026-06-09 10:50:59 +07:00
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';
2026-06-09 10:50:59 +07:00
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.
2026-06-09 10:50:59 +07:00
// ─────────────────────────────────────────────────────────────────────────
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');
2026-06-09 10:50:59 +07:00
// 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';
2026-06-09 10:50:59 +07:00
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';
2026-06-09 10:50:59 +07:00
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);
2026-06-09 10:50:59 +07:00
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.
2026-06-09 10:50:59 +07:00
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let pushingToMachine = $state(false);
let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 });
2026-06-16 11:23:33 +07:00
let connecting = $state(false);
let showScreenMirror = $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';
2026-06-09 10:50:59 +07:00
}
async function authHeaders(): Promise<Record<string, string>> {
const token = await $auth?.getIdToken?.();
return token ? { Authorization: `Bearer ${token}` } : {};
2026-06-09 10:50:59 +07:00
}
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;
2026-06-09 10:50:59 +07:00
const ext = '.' + (file.name.split('.').pop()?.toLowerCase() ?? '');
if (!ALLOWED_EXTENSIONS.includes(ext)) {
addNotification(`WARN:${file.name} - Only .mp4 allowed`);
return;
2026-06-09 10:50:59 +07:00
}
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;
});
2026-06-09 10:50:59 +07:00
}
function clearSlot(index: number) {
const slot = slots[index];
if (slot.preview) URL.revokeObjectURL(slot.preview);
slots[index] = emptySlot(index);
2026-06-09 10:50:59 +07:00
}
// 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;
2026-06-09 10:50:59 +07:00
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');
2026-06-09 10:50:59 +07:00
}
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;
2026-06-09 10:50:59 +07:00
}
if (thumbUrls[slot.name]) {
URL.revokeObjectURL(thumbUrls[slot.name]);
const { [slot.name]: _t, ...restThumb } = thumbUrls;
thumbUrls = restThumb;
}
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;
2026-06-09 10:50:59 +07:00
}
}
// 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;
}
2026-06-09 10:50:59 +07:00
}
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;
}
2026-06-09 10:50:59 +07:00
}
// 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.
2026-06-09 10:50:59 +07:00
async function pushToMachine() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected (ADB)');
return;
}
const targets = slots.filter((s) => s.file);
2026-06-09 10:50:59 +07:00
if (targets.length === 0) {
addNotification('WARN:No slots to push');
2026-06-09 10:50:59 +07:00
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
};
});
2026-06-09 10:50:59 +07:00
if (ok) {
success++;
pushProgress = { current: i + 1, total: targets.length, name: slot.name, percent: 100 };
2026-06-09 10:50:59 +07:00
} else {
addNotification(`ERR:Push failed: ${slot.name}`);
2026-06-09 10:50:59 +07:00
}
}
if (success > 0) {
addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`);
}
} catch (error) {
logger.error('[Adv] push to machine error:', error);
2026-06-09 10:50:59 +07:00
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);
// 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';
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2026-06-09 10:50:59 +07:00
}
// List-only fetch (no blobs) — cheap, just to know what each slot currently has.
async function refreshServerIndex() {
2026-06-09 10:50:59 +07:00
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;
// Fetch a tiny JPEG poster for each slot video so the grid shows a
// thumbnail (backend grabs one frame — no full-video download).
autoLoadSlotThumbs();
2026-06-09 10:50:59 +07:00
} catch (error) {
logger.error('[Adv] refresh server index error:', error);
2026-06-09 10:50:59 +07:00
}
}
// 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
if (videoBlobUrls[name]) viewingName = name;
2026-06-09 10:50:59 +07:00
}
async function loadServerVideos() {
loadingServerVideos = true;
2026-06-09 10:50:59 +07:00
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');
2026-06-09 10:50:59 +07:00
}
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();
2026-06-09 10:50:59 +07:00
} catch (error) {
logger.error('[Adv] list server videos error:', error);
addNotification(
`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`
);
2026-06-09 10:50:59 +07:00
} finally {
loadingServerVideos = false;
2026-06-09 10:50:59 +07:00
}
}
// 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 };
2026-06-09 10:50:59 +07:00
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
}
2026-06-09 10:50:59 +07:00
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() });
2026-06-09 10:50:59 +07:00
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'Failed to fetch video');
2026-06-09 10:50:59 +07:00
}
const blob = await res.blob();
videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) };
2026-06-09 10:50:59 +07:00
} catch (error) {
logger.error('[Adv] fetch server video error:', error);
addNotification(
`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`
);
2026-06-09 10:50:59 +07:00
} finally {
loadingVideoName = null;
2026-06-09 10:50:59 +07:00
}
}
// Refresh the per-slot "on server" indicators on mount and whenever the
// selected country changes.
$effect(() => {
void selectedCountry;
refreshServerIndex();
});
2026-06-09 10:50:59 +07:00
$effect(() => {
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);
2026-06-09 10:50:59 +07:00
};
});
</script>
<div class="flex min-h-screen flex-col bg-muted/20">
2026-06-09 10:50:59 +07:00
<!-- 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>
2026-06-09 10:50:59 +07:00
<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}
2026-06-09 10:50:59 +07:00
<Button
variant="outline"
onclick={pushToMachine}
disabled={anyBusy || !isAdbConnected || filledCount === 0}
>
{#if pushingToMachine}
2026-06-16 11:23:33 +07:00
<Spinner class="mr-2 h-4 w-4" />
Pushing {pushProgress.current}/{pushProgress.total}...
2026-06-16 11:23:33 +07:00
{:else}
<MonitorPlay class="mr-2 h-4 w-4" />
Push to Machine{filledCount > 0 ? ` (${filledCount})` : ''}
2026-06-16 11:23:33 +07:00
{/if}
</Button>
<Button variant="outline" onclick={() => (showScreenMirror = true)}>
Show Android Screen
</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>
2026-06-09 10:50:59 +07:00
<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>
2026-06-09 10:50:59 +07:00
</div>
<!-- Progress bar: only while pushing to a machine -->
2026-06-09 10:50:59 +07:00
{#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>
2026-06-09 10:50:59 +07:00
</div>
{/if}
</div>
</header>
2026-06-09 10:50:59 +07:00
<!-- 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 border-green-500/40 bg-green-500/15 whitespace-nowrap 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}
2026-06-09 10:50:59 +07:00
</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>
2026-06-09 10:50:59 +07:00
<!-- 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 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"
>
<input
type="file"
accept=".mp4,video/mp4"
class="hidden"
onchange={(e) => {
const el = e.currentTarget as HTMLInputElement;
pickSlotFile(slot.index, el.files);
el.value = '';
}}
/>
{#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>
2026-06-09 10:50:59 +07:00
{/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>
2026-06-09 10:50:59 +07:00
{/if}
<span class="text-[10px] text-muted-foreground"
>{formatBytes(slot.file.size)}</span
>
2026-06-09 10:50:59 +07:00
</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>
2026-06-09 10:50:59 +07:00
</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
2026-06-09 10:50:59 +07:00
{:else}
<Eye class="h-4 w-4" />
View video
2026-06-09 10:50:59 +07:00
{/if}
</Button>
2026-06-09 10:50:59 +07:00
</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>
2026-06-09 10:50:59 +07:00
</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>
2026-06-09 10:50:59 +07:00
</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}
<ScrcpyDialog bind:open={showScreenMirror} />
2026-06-09 10:50:59 +07:00
</div>