2026-07-16 14:52:54 +07:00
|
|
|
|
<script lang="ts">
|
|
|
|
|
|
import { logger } from '$lib/core/utils/logger';
|
2026-06-19 17:27:28 +07:00
|
|
|
|
|
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';
|
2026-07-16 14:52:54 +07:00
|
|
|
|
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';
|
2026-07-16 14:52:54 +07:00
|
|
|
|
import ScrcpyDialog from '$lib/components/scrcpy-dialog.svelte';
|
2026-06-09 10:50:59 +07:00
|
|
|
|
|
|
|
|
|
|
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
|
2026-07-14 18:44:06 +07:00
|
|
|
|
const LIST_PROXY_ENDPOINT = '/api/adv-list';
|
|
|
|
|
|
const FILE_PROXY_ENDPOINT = '/api/adv-file';
|
2026-07-15 12:49:03 +07:00
|
|
|
|
const THUMB_PROXY_ENDPOINT = '/api/adv-thumb';
|
2026-06-09 10:50:59 +07:00
|
|
|
|
const ALLOWED_EXTENSIONS = ['.mp4'];
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
2026-07-14 18:44:06 +07:00
|
|
|
|
// 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.
|
2026-06-09 10:50:59 +07:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
2026-07-14 18:44:06 +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
|
2026-07-14 18:44:06 +07:00
|
|
|
|
// 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`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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
|
|
|
|
|
2026-07-14 18:44:06 +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);
|
|
|
|
|
|
|
2026-07-16 14:52:54 +07:00
|
|
|
|
let showScreenMirror = $state(false);
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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.
|
2026-07-16 14:52:54 +07:00
|
|
|
|
const onServerCount = $derived(
|
|
|
|
|
|
slots.filter((s) => serverFilesByName[s.name] !== undefined).length
|
|
|
|
|
|
);
|
2026-07-14 18:44:06 +07:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
// 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
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
2026-07-15 12:49:03 +07:00
|
|
|
|
if (thumbUrls[slot.name]) {
|
|
|
|
|
|
URL.revokeObjectURL(thumbUrls[slot.name]);
|
|
|
|
|
|
const { [slot.name]: _t, ...restThumb } = thumbUrls;
|
|
|
|
|
|
thumbUrls = restThumb;
|
|
|
|
|
|
}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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;
|
|
|
|
|
|
}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
const targets = slots.filter((s) => s.file);
|
2026-06-09 10:50:59 +07:00
|
|
|
|
if (targets.length === 0) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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++) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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++;
|
2026-07-14 18:44:06 +07:00
|
|
|
|
pushProgress = { current: i + 1, total: targets.length, name: slot.name, percent: 100 };
|
2026-06-09 10:50:59 +07:00
|
|
|
|
} else {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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) {
|
2026-06-19 17:27:28 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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;
|
2026-07-14 12:19:57 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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);
|
2026-07-15 12:49:03 +07:00
|
|
|
|
// name -> object URL of the small JPEG poster (thumbnail) for a slot's video.
|
|
|
|
|
|
let thumbUrls = $state<Record<string, string>>({});
|
2026-07-14 18:44:06 +07:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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 {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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;
|
2026-07-15 12:49:03 +07:00
|
|
|
|
// 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) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
logger.error('[Adv] refresh server index error:', error);
|
2026-06-09 10:50:59 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 12:49:03 +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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
async function loadServerVideos() {
|
|
|
|
|
|
loadingServerVideos = true;
|
2026-06-09 10:50:59 +07:00
|
|
|
|
try {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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
|
|
|
|
}
|
2026-07-14 18:44:06 +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) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
logger.error('[Adv] list server videos error:', error);
|
2026-07-16 14:52:54 +07:00
|
|
|
|
addNotification(
|
|
|
|
|
|
`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`
|
|
|
|
|
|
);
|
2026-06-09 10:50:59 +07:00
|
|
|
|
} finally {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
loadingServerVideos = false;
|
2026-06-09 10:50:59 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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
|
|
|
|
}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
|
|
|
|
|
|
}
|
2026-06-09 10:50:59 +07:00
|
|
|
|
|
2026-07-14 18:44:06 +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) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
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
|
|
|
|
}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) };
|
2026-06-09 10:50:59 +07:00
|
|
|
|
} catch (error) {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
logger.error('[Adv] fetch server video error:', error);
|
2026-07-16 14:52:54 +07:00
|
|
|
|
addNotification(
|
|
|
|
|
|
`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`
|
|
|
|
|
|
);
|
2026-06-09 10:50:59 +07:00
|
|
|
|
} finally {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
loadingVideoName = null;
|
2026-06-09 10:50:59 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 18:44:06 +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 () => {
|
2026-07-14 18:44:06 +07:00
|
|
|
|
for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview);
|
|
|
|
|
|
for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url);
|
2026-07-15 12:49:03 +07:00
|
|
|
|
for (const url of Object.values(thumbUrls)) URL.revokeObjectURL(url);
|
2026-06-09 10:50:59 +07:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<div class="flex min-h-screen flex-col bg-muted/20">
|
2026-06-09 10:50:59 +07:00
|
|
|
|
<!-- Header -->
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<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">
|
2026-07-16 14:52:54 +07:00
|
|
|
|
<div
|
|
|
|
|
|
class="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"
|
|
|
|
|
|
>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<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
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<Badge variant={isAdbConnected ? 'default' : 'secondary'} class="gap-1">
|
2026-07-16 14:52:54 +07:00
|
|
|
|
<span
|
|
|
|
|
|
class="h-1.5 w-1.5 rounded-full {isAdbConnected ? 'bg-white' : 'bg-muted-foreground'}"
|
2026-07-14 18:44:06 +07:00
|
|
|
|
></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
|
|
|
|
|
2026-07-14 18:44:06 +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" />
|
2026-07-14 18:44:06 +07:00
|
|
|
|
Pushing {pushProgress.current}/{pushProgress.total}...
|
2026-06-16 11:23:33 +07:00
|
|
|
|
{:else}
|
|
|
|
|
|
<MonitorPlay class="mr-2 h-4 w-4" />
|
2026-07-14 18:44:06 +07:00
|
|
|
|
Push to Machine{filledCount > 0 ? ` (${filledCount})` : ''}
|
2026-06-16 11:23:33 +07:00
|
|
|
|
{/if}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
2026-07-16 14:52:54 +07:00
|
|
|
|
<Button variant="outline" onclick={() => (showScreenMirror = true)}>
|
|
|
|
|
|
Show Android Screen
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<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
|
|
|
|
|
2026-07-14 18:44:06 +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>
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<!-- Progress bar: only while pushing to a machine -->
|
2026-06-09 10:50:59 +07:00
|
|
|
|
{#if pushingToMachine}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<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}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
2026-06-09 10:50:59 +07:00
|
|
|
|
|
2026-07-14 18:44:06 +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
|
2026-07-16 14:52:54 +07:00
|
|
|
|
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"
|
2026-07-14 18:44:06 +07:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<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
|
|
|
|
|
2026-07-14 18:44:06 +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}
|
2026-07-15 12:49:03 +07:00
|
|
|
|
{: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>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
{: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 = '';
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-07-15 12:49:03 +07:00
|
|
|
|
{#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}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
</label>
|
2026-06-09 10:50:59 +07:00
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<!-- 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}
|
2026-07-16 14:52:54 +07:00
|
|
|
|
<span class="text-[10px] text-muted-foreground"
|
|
|
|
|
|
>{formatBytes(slot.file.size)}</span
|
|
|
|
|
|
>
|
2026-06-09 10:50:59 +07:00
|
|
|
|
</div>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
{#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>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
{/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}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
<Eye class="h-4 w-4" />
|
|
|
|
|
|
View video
|
2026-06-09 10:50:59 +07:00
|
|
|
|
{/if}
|
2026-07-14 18:44:06 +07:00
|
|
|
|
</Button>
|
2026-06-09 10:50:59 +07:00
|
|
|
|
</div>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
{/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>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
{/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>
|
2026-07-14 18:44:06 +07:00
|
|
|
|
|
|
|
|
|
|
<!-- 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}
|
2026-07-16 14:52:54 +07:00
|
|
|
|
|
|
|
|
|
|
<ScrcpyDialog bind:open={showScreenMirror} />
|
2026-06-09 10:50:59 +07:00
|
|
|
|
</div>
|