833 lines
28 KiB
Svelte
833 lines
28 KiB
Svelte
<script lang="ts">
|
||
import { auth } from '$lib/core/stores/auth';
|
||
import { addNotification } from '$lib/core/stores/noti';
|
||
import Button from '$lib/components/ui/button/button.svelte';
|
||
import Label from '$lib/components/ui/label/label.svelte';
|
||
import * as Card from '$lib/components/ui/card/index.js';
|
||
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 Progress from '$lib/components/ui/progress/progress.svelte';
|
||
import { Upload, X, Video, CheckCircle, AlertCircle, MonitorPlay } 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 MANIFEST_PROXY_ENDPOINT = '/api/adv-manifest';
|
||
const MANIFEST_FILENAME = 'sync_1.file';
|
||
const ALLOWED_EXTENSIONS = ['.mp4'];
|
||
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// CONFIG — choose how the sync_1.file manifest is built (change this value).
|
||
// Either way the manifest lists ONLY the selected/active set, never the whole
|
||
// FTP folder (production keeps many inactive variants in the folder).
|
||
// 'ftp_listdir' = manifest built (in the browser) from the files you upload
|
||
// this session. No ADB, doesn't touch the machine. Recommended.
|
||
// 'machine' = original flow. On Upload it: rm -rf the machine adv folder,
|
||
// pushes the selected .mp4 (from the browser), then
|
||
// `ls -l > sync_1.file` on the machine, pulls it, uploads it.
|
||
// ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set.
|
||
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
|
||
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
// adv folder on the machine. Domestic Thailand uses the flat folder; every
|
||
// international country uses inter/<country>/adv (matches the on-machine and
|
||
// taobin_project source structure).
|
||
function machineAdvDir(country: string): string {
|
||
return country === 'tha'
|
||
? `${MACHINE_PROJECT_DIR}/adv`
|
||
: `${MACHINE_PROJECT_DIR}/inter/${country}/adv`;
|
||
}
|
||
|
||
// Each country can target a different SFTP host (configured on the backend
|
||
// via ADV_SFTP_COUNTRY_CONFIG). The selected country is sent with the upload.
|
||
const COUNTRIES = [
|
||
{ value: 'tha', label: 'Thailand (tha)' },
|
||
{ value: 'mys', label: 'Malaysia (mys)' },
|
||
{ value: 'aus', label: 'Australia (aus)' },
|
||
{ value: 'sgp', label: 'Singapore (sgp)' },
|
||
{ value: 'hkg', label: 'Hong Kong (hkg)' },
|
||
{ value: 'gbr', label: 'United Kingdom (gbr)' },
|
||
{ value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' },
|
||
{ value: 'ltu', label: 'Lithuania (ltu)' },
|
||
{ value: 'rou', label: 'Romania (rou)' },
|
||
{ value: 'lva', label: 'Latvia (lva)' },
|
||
{ value: 'est', label: 'Estonia (est)' }
|
||
];
|
||
|
||
let selectedCountry = $state('tha');
|
||
|
||
// Expected dimensions are derived from the filename convention.
|
||
const ADV_SPECS = {
|
||
menu: { width: 1080, height: 380, label: 'Menu banner', pattern: /^taobin_adv_menu_[a-z0-9]+\.mp4$/i },
|
||
fullscreen: { width: 1080, height: 608, label: 'Fullscreen / Idle', pattern: /^taobin_adv_[a-z0-9]+\.mp4$/i }
|
||
} as const;
|
||
|
||
type AdvCategory = 'menu' | 'fullscreen' | 'invalid';
|
||
|
||
interface AdvFileItem {
|
||
id: string;
|
||
file: File;
|
||
preview: string;
|
||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||
error?: string;
|
||
category: AdvCategory;
|
||
expectedWidth?: number;
|
||
expectedHeight?: number;
|
||
width?: number;
|
||
height?: number;
|
||
dimChecked: boolean;
|
||
dimOk: boolean;
|
||
}
|
||
|
||
let files = $state<AdvFileItem[]>([]);
|
||
let uploading = $state(false);
|
||
let uploadProgress = $state({ current: 0, total: 0 });
|
||
let dragOver = $state(false);
|
||
|
||
// Push-to-machine (ADB) state
|
||
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||
let pushingToMachine = $state(false);
|
||
let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 });
|
||
|
||
let generatingManifest = $state(false);
|
||
|
||
function generateId() {
|
||
return Math.random().toString(36).substring(2, 9);
|
||
}
|
||
|
||
// Classify by filename. Menu must be checked before fullscreen because a menu
|
||
// name also satisfies the broader fullscreen pattern.
|
||
function classify(name: string): { category: AdvCategory; width?: number; height?: number } {
|
||
const lower = name.toLowerCase();
|
||
if (!lower.endsWith('.mp4')) return { category: 'invalid' };
|
||
if (ADV_SPECS.menu.pattern.test(lower)) {
|
||
return { category: 'menu', width: ADV_SPECS.menu.width, height: ADV_SPECS.menu.height };
|
||
}
|
||
if (ADV_SPECS.fullscreen.pattern.test(lower)) {
|
||
return {
|
||
category: 'fullscreen',
|
||
width: ADV_SPECS.fullscreen.width,
|
||
height: ADV_SPECS.fullscreen.height
|
||
};
|
||
}
|
||
return { category: 'invalid' };
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|
||
|
||
function handleFileSelect(event: Event) {
|
||
const input = event.target as HTMLInputElement;
|
||
if (input.files) {
|
||
addFiles(Array.from(input.files));
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
function handleDrop(event: DragEvent) {
|
||
event.preventDefault();
|
||
dragOver = false;
|
||
if (event.dataTransfer?.files) {
|
||
addFiles(Array.from(event.dataTransfer.files));
|
||
}
|
||
}
|
||
|
||
function handleDragOver(event: DragEvent) {
|
||
event.preventDefault();
|
||
dragOver = true;
|
||
}
|
||
|
||
function handleDragLeave() {
|
||
dragOver = false;
|
||
}
|
||
|
||
function addFiles(newFiles: File[]) {
|
||
const toAdd: AdvFileItem[] = [];
|
||
|
||
for (const file of newFiles) {
|
||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||
addNotification(`WARN:${file.name} - Only .mp4 allowed`);
|
||
continue;
|
||
}
|
||
if (files.some((f) => f.file.name === file.name)) {
|
||
addNotification(`WARN:${file.name} - Already added`);
|
||
continue;
|
||
}
|
||
|
||
const { category, width, height } = classify(file.name);
|
||
if (category === 'invalid') {
|
||
addNotification(
|
||
`WARN:${file.name} - Name must be taobin_adv_*.mp4 or taobin_adv_menu_*.mp4`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
toAdd.push({
|
||
id: generateId(),
|
||
file,
|
||
preview: URL.createObjectURL(file),
|
||
status: 'pending',
|
||
category,
|
||
expectedWidth: width,
|
||
expectedHeight: height,
|
||
dimChecked: false,
|
||
dimOk: false
|
||
});
|
||
}
|
||
|
||
if (toAdd.length === 0) return;
|
||
files = [...files, ...toAdd];
|
||
|
||
// Read each video's real dimensions and validate against the spec.
|
||
for (const item of toAdd) {
|
||
readVideoDimensions(item.file)
|
||
.then(({ width, height }) => {
|
||
const index = files.findIndex((f) => f.id === item.id);
|
||
if (index === -1) return;
|
||
const ok = width === item.expectedWidth && height === item.expectedHeight;
|
||
files[index].width = width;
|
||
files[index].height = height;
|
||
files[index].dimChecked = true;
|
||
files[index].dimOk = ok;
|
||
if (!ok) {
|
||
files[index].status = 'error';
|
||
files[index].error = `Size ${width}x${height}, expected ${item.expectedWidth}x${item.expectedHeight}`;
|
||
}
|
||
})
|
||
.catch(() => {
|
||
const index = files.findIndex((f) => f.id === item.id);
|
||
if (index === -1) return;
|
||
files[index].dimChecked = true;
|
||
files[index].dimOk = false;
|
||
files[index].status = 'error';
|
||
files[index].error = 'Cannot read video dimensions';
|
||
});
|
||
}
|
||
}
|
||
|
||
function removeFile(id: string) {
|
||
const item = files.find((f) => f.id === id);
|
||
if (item) URL.revokeObjectURL(item.preview);
|
||
files = files.filter((f) => f.id !== id);
|
||
}
|
||
|
||
function clearAllFiles() {
|
||
files.forEach((f) => URL.revokeObjectURL(f.preview));
|
||
files = [];
|
||
}
|
||
|
||
// A video that passed name + dimension validation (eligible for push/upload).
|
||
function isValidVideo(item: AdvFileItem) {
|
||
return item.dimChecked && item.dimOk && item.category !== 'invalid';
|
||
}
|
||
|
||
function isUploadable(item: AdvFileItem) {
|
||
return isValidVideo(item) && (item.status === 'pending' || item.status === 'error');
|
||
}
|
||
|
||
const uploadableCount = $derived(files.filter(isUploadable).length);
|
||
const validVideoCount = $derived(files.filter(isValidVideo).length);
|
||
|
||
// Step 1 (mirrors original sync.sh): push the valid videos to the connected
|
||
// machine via ADB so you can preview them before sending to the server.
|
||
async function pushToMachine() {
|
||
if (!AdbInstance.instance) {
|
||
addNotification('ERR:Machine not connected (ADB)');
|
||
return;
|
||
}
|
||
const targets = files.filter(isValidVideo);
|
||
if (targets.length === 0) {
|
||
addNotification('WARN:No valid videos 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 item = targets[i];
|
||
pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 };
|
||
|
||
const bytes = new Uint8Array(await item.file.arrayBuffer());
|
||
const ok = await adb.pushBinary(
|
||
`${targetDir}/${item.file.name}`,
|
||
bytes,
|
||
(sent, total) => {
|
||
pushProgress = {
|
||
current: i,
|
||
total: targets.length,
|
||
name: item.file.name,
|
||
percent: total > 0 ? Math.round((sent / total) * 100) : 0
|
||
};
|
||
}
|
||
);
|
||
if (ok) {
|
||
success++;
|
||
pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 };
|
||
} else {
|
||
addNotification(`ERR:Push failed: ${item.file.name}`);
|
||
}
|
||
}
|
||
|
||
if (success > 0) {
|
||
addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`);
|
||
}
|
||
} catch (error) {
|
||
console.error('[Adv] push to machine error:', error);
|
||
addNotification(`ERR:Push error: ${error instanceof Error ? error.message : 'unknown'}`);
|
||
} finally {
|
||
pushingToMachine = false;
|
||
}
|
||
}
|
||
|
||
async function uploadFiles() {
|
||
const currentUser = $auth;
|
||
if (!currentUser) {
|
||
addNotification('ERR:Not logged in');
|
||
return;
|
||
}
|
||
|
||
const pendingFiles = files.filter(isUploadable);
|
||
if (pendingFiles.length === 0) {
|
||
addNotification('WARN:No valid files to upload');
|
||
return;
|
||
}
|
||
|
||
const uid = currentUser.uid;
|
||
const displayName = currentUser.displayName || 'unknown';
|
||
const email = currentUser.email || 'unknown@email.com';
|
||
|
||
// Method 2 mirrors the original flow: make the machine's adv folder hold
|
||
// EXACTLY the selected set (rm -rf + push) BEFORE its ls -l manifest is
|
||
// generated — so machine = FTP = manifest. The .mp4 still come from the
|
||
// browser (the dragged files), only the manifest comes from the machine.
|
||
if (MANIFEST_MODE === 'machine') {
|
||
const synced = await syncMachineFolder(pendingFiles);
|
||
if (!synced) return;
|
||
}
|
||
|
||
uploading = true;
|
||
uploadProgress = { current: 0, total: pendingFiles.length };
|
||
|
||
for (let i = 0; i < pendingFiles.length; i++) {
|
||
const item = pendingFiles[i];
|
||
const index = files.findIndex((f) => f.id === item.id);
|
||
if (index === -1) continue;
|
||
|
||
files[index].status = 'uploading';
|
||
files[index].error = undefined;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('country', selectedCountry);
|
||
formData.append('uid', uid);
|
||
formData.append('displayName', displayName);
|
||
formData.append('email', email);
|
||
formData.append('file', item.file);
|
||
// Manifest is built from the selected set (method 1) or the machine
|
||
// (method 2) — never from the whole FTP folder. So tell the backend
|
||
// not to rebuild it from the FTP listing.
|
||
formData.append('regenerate', 'false');
|
||
|
||
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||
throw new Error(errorData.detail || errorData.message || 'Upload failed');
|
||
}
|
||
|
||
files[index].status = 'success';
|
||
uploadProgress = { current: i + 1, total: pendingFiles.length };
|
||
} catch (error) {
|
||
files[index].status = 'error';
|
||
files[index].error = error instanceof Error ? error.message : 'Unknown error';
|
||
console.error(`Upload error for ${item.file.name}:`, error);
|
||
}
|
||
}
|
||
|
||
uploading = false;
|
||
|
||
const successCount = files.filter((f) => f.status === 'success').length;
|
||
const errorCount = files.filter((f) => f.status === 'error').length;
|
||
|
||
if (errorCount === 0) {
|
||
addNotification(`INFO:Uploaded ${successCount} adv video(s) successfully`);
|
||
} else {
|
||
addNotification(`WARN:Uploaded ${successCount}, failed ${errorCount}`);
|
||
}
|
||
|
||
// Build the manifest (sync_1.file) — only from the selected/active set,
|
||
// never the whole FTP folder (production keeps many inactive variants there).
|
||
if (successCount > 0) {
|
||
if (MANIFEST_MODE === 'machine') {
|
||
// Method 2: ls -l on the (rm -rf + pushed) machine, then upload.
|
||
await generateMachineManifest(uid, displayName, email);
|
||
} else {
|
||
// Method 1: manifest = the successfully-uploaded files in the UI list.
|
||
await uploadSelectedManifest(uid, displayName, email);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Method 1 — build sync_1.file from the active set the user assembled in the
|
||
// UI (the successfully-uploaded files), NOT the whole FTP folder. The FTP keeps
|
||
// any other/variant files; the manifest lists only what you chose.
|
||
async function uploadSelectedManifest(uid: string, displayName: string, email: string) {
|
||
const active = files.filter((f) => f.status === 'success');
|
||
if (active.length === 0) return;
|
||
generatingManifest = true;
|
||
try {
|
||
const text = buildManifestText(active);
|
||
await uploadManifestText(text, uid, displayName, email);
|
||
addNotification(`INFO:Manifest uploaded (${active.length} active file(s))`);
|
||
} catch (error) {
|
||
console.error('[Adv] selected manifest error:', error);
|
||
addNotification(`ERR:Manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||
} finally {
|
||
generatingManifest = false;
|
||
}
|
||
}
|
||
|
||
// ls -l style line the machine's FileSyncServer parses (size at field 4, name
|
||
// at field 7). sync_1.file lists itself as size 0 so the machine skips it.
|
||
function buildManifestText(items: AdvFileItem[]): string {
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
const fmt = (ms: number) => {
|
||
const d = new Date(ms);
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
};
|
||
const now = Date.now();
|
||
const rows = [
|
||
{ name: MANIFEST_FILENAME, size: 0, mtime: now },
|
||
...items.map((it) => ({
|
||
name: it.file.name,
|
||
size: it.file.size,
|
||
mtime: it.file.lastModified || now
|
||
}))
|
||
].sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
const lines = ['total 0'];
|
||
for (const r of rows) {
|
||
lines.push(`-rw-rw---- 1 root sdcard_rw ${r.size} ${fmt(r.mtime)} ${r.name}`);
|
||
}
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
async function uploadManifestText(
|
||
text: string,
|
||
uid: string,
|
||
displayName: string,
|
||
email: string
|
||
) {
|
||
const fd = new FormData();
|
||
fd.append('country', selectedCountry);
|
||
fd.append('uid', uid);
|
||
fd.append('displayName', displayName);
|
||
fd.append('email', email);
|
||
fd.append('file', new File([text], MANIFEST_FILENAME, { type: 'text/plain' }));
|
||
|
||
const res = await fetch(MANIFEST_PROXY_ENDPOINT, { method: 'POST', body: fd });
|
||
if (!res.ok) {
|
||
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||
throw new Error(e.detail || 'Manifest upload failed');
|
||
}
|
||
}
|
||
|
||
// Method 2 step 1 — make the machine's adv folder hold EXACTLY the selected
|
||
// files (rm -rf + push), like the original `rm -Rf adv` + push of the whole
|
||
// folder. After this, the machine's `ls -l` matches what we upload to the FTP.
|
||
// ⚠️ This WIPES the machine's adv folder — method 2 is a full replace, so the
|
||
// dragged set must be the complete intended adv set.
|
||
async function syncMachineFolder(targets: AdvFileItem[]): Promise<boolean> {
|
||
if (!AdbInstance.instance) {
|
||
addNotification('ERR:Method 2 (machine manifest) needs the machine connected via ADB');
|
||
return false;
|
||
}
|
||
const advDir = machineAdvDir(selectedCountry);
|
||
pushingToMachine = true;
|
||
try {
|
||
// Wipe + recreate so only the selected set remains on the machine.
|
||
await adb.executeCmd(`rm -rf "${advDir}" && mkdir -p "${advDir}"`);
|
||
|
||
for (let i = 0; i < targets.length; i++) {
|
||
const item = targets[i];
|
||
pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 };
|
||
const bytes = new Uint8Array(await item.file.arrayBuffer());
|
||
const ok = await adb.pushBinary(
|
||
`${advDir}/${item.file.name}`,
|
||
bytes,
|
||
(sent, total) => {
|
||
pushProgress = {
|
||
current: i,
|
||
total: targets.length,
|
||
name: item.file.name,
|
||
percent: total > 0 ? Math.round((sent / total) * 100) : 0
|
||
};
|
||
}
|
||
);
|
||
if (!ok) {
|
||
addNotification(`ERR:Push to machine failed: ${item.file.name}`);
|
||
return false;
|
||
}
|
||
pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 };
|
||
}
|
||
addNotification(`INFO:Machine adv folder synced (${targets.length} file(s))`);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('[Adv] machine sync error:', error);
|
||
addNotification(`ERR:Machine sync failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||
return false;
|
||
} finally {
|
||
pushingToMachine = false;
|
||
}
|
||
}
|
||
|
||
// Method 2 step 2 — generate the manifest from the (now synced) machine adv
|
||
// folder (`ls -l > sync_1.file`), pull it, and upload it to the FTP.
|
||
async function generateMachineManifest(uid: string, displayName: string, email: string) {
|
||
if (!AdbInstance.instance) {
|
||
addNotification('ERR:Machine not connected — cannot generate manifest from machine');
|
||
return;
|
||
}
|
||
const advDir = machineAdvDir(selectedCountry);
|
||
generatingManifest = true;
|
||
try {
|
||
// ls -l > sync_1.file on the machine (shell truncates the manifest first,
|
||
// so it lists itself as size 0 — exactly what the original flow produces).
|
||
await adb.executeCmd(`cd ${advDir} && ls -l > ${MANIFEST_FILENAME}`);
|
||
|
||
const manifestText = await adb.pull(`${advDir}/${MANIFEST_FILENAME}`);
|
||
if (!manifestText || manifestText.trim().length === 0) {
|
||
addNotification('ERR:Failed to read sync_1.file from machine');
|
||
return;
|
||
}
|
||
|
||
const fd = new FormData();
|
||
fd.append('country', selectedCountry);
|
||
fd.append('uid', uid);
|
||
fd.append('displayName', displayName);
|
||
fd.append('email', email);
|
||
fd.append(
|
||
'file',
|
||
new File([manifestText], MANIFEST_FILENAME, { type: 'text/plain' })
|
||
);
|
||
|
||
const res = await fetch(MANIFEST_PROXY_ENDPOINT, { method: 'POST', body: fd });
|
||
if (!res.ok) {
|
||
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||
throw new Error(e.detail || 'Manifest upload failed');
|
||
}
|
||
addNotification('INFO:Manifest (from machine) uploaded to server');
|
||
} catch (error) {
|
||
console.error('[Adv] machine manifest error:', error);
|
||
addNotification(`ERR:Machine manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||
} finally {
|
||
generatingManifest = false;
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
return () => {
|
||
files.forEach((f) => URL.revokeObjectURL(f.preview));
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<div class="flex min-h-screen flex-col">
|
||
<!-- Header -->
|
||
<div class="sticky top-0 z-10 border-b bg-background">
|
||
<div class="flex items-center justify-between px-8 py-4">
|
||
<div>
|
||
<h1 class="text-2xl font-bold">Adv Upload</h1>
|
||
<p class="text-sm text-muted-foreground">Upload advertisement videos (.mp4) to the server</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
|
||
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
|
||
</Badge>
|
||
|
||
{#if files.length > 0}
|
||
<Button
|
||
variant="outline"
|
||
onclick={clearAllFiles}
|
||
disabled={uploading || pushingToMachine}
|
||
>
|
||
<X class="mr-2 h-4 w-4" />
|
||
Clear All
|
||
</Button>
|
||
{/if}
|
||
|
||
<!-- Step 1: push to the connected machine to preview -->
|
||
<Button
|
||
variant="outline"
|
||
onclick={pushToMachine}
|
||
disabled={pushingToMachine || uploading || !isAdbConnected || validVideoCount === 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 ({validVideoCount})
|
||
{/if}
|
||
</Button>
|
||
|
||
<!-- Step 2: upload to server -->
|
||
<Button
|
||
onclick={uploadFiles}
|
||
disabled={uploading || pushingToMachine || generatingManifest || uploadableCount === 0}
|
||
>
|
||
{#if uploading}
|
||
<Spinner class="mr-2 h-4 w-4" />
|
||
Uploading {uploadProgress.current}/{uploadProgress.total}...
|
||
{:else}
|
||
<Upload class="mr-2 h-4 w-4" />
|
||
Upload ({uploadableCount})
|
||
{/if}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="flex-1 overflow-y-auto p-8">
|
||
<div class="mx-auto max-w-6xl space-y-6">
|
||
<!-- Settings -->
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<Card.Title>Upload Settings</Card.Title>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="space-y-2 md:max-w-sm">
|
||
<Label>Country</Label>
|
||
<Select.Root type="single" bind:value={selectedCountry}>
|
||
<Select.Trigger class="w-full">
|
||
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'}
|
||
</Select.Trigger>
|
||
<Select.Content>
|
||
{#each COUNTRIES as country}
|
||
<Select.Item value={country.value}>{country.label}</Select.Item>
|
||
{/each}
|
||
</Select.Content>
|
||
</Select.Root>
|
||
<!-- <p class="text-xs text-muted-foreground">
|
||
Machine push path: <code class="font-mono">{machineAdvDir(selectedCountry)}</code>
|
||
</p> -->
|
||
</div>
|
||
|
||
</Card.Content>
|
||
</Card.Root>
|
||
|
||
<!-- Naming guide -->
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<Card.Title>Naming & Size Rules</Card.Title>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<div class="rounded-lg border p-4">
|
||
<p class="font-mono text-sm font-semibold">taobin_adv_menu_*.mp4</p>
|
||
<p class="text-sm text-muted-foreground">Menu banner ad</p>
|
||
<Badge variant="secondary" class="mt-2">1080 × 380</Badge>
|
||
</div>
|
||
<div class="rounded-lg border p-4">
|
||
<p class="font-mono text-sm font-semibold">taobin_adv_*.mp4</p>
|
||
<p class="text-sm text-muted-foreground">Fullscreen / Idle ad</p>
|
||
<Badge variant="secondary" class="mt-2">1080 × 608</Badge>
|
||
</div>
|
||
</div>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
|
||
<!-- Drop Zone -->
|
||
<Card.Root>
|
||
<Card.Content class="p-6">
|
||
<label
|
||
class="flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors {dragOver
|
||
? 'border-primary bg-primary/5'
|
||
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50'}"
|
||
ondrop={handleDrop}
|
||
ondragover={handleDragOver}
|
||
ondragleave={handleDragLeave}
|
||
>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept=".mp4,video/mp4"
|
||
class="hidden"
|
||
onchange={handleFileSelect}
|
||
disabled={uploading}
|
||
/>
|
||
<Video class="mb-4 h-12 w-12 text-muted-foreground" />
|
||
<p class="mb-2 text-lg font-medium">Drop .mp4 videos here or click to browse</p>
|
||
<p class="text-sm text-muted-foreground">
|
||
Filenames must follow the taobin_adv_*.mp4 convention
|
||
</p>
|
||
</label>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
|
||
<!-- Push-to-Machine Progress -->
|
||
{#if pushingToMachine}
|
||
<div class="space-y-2">
|
||
<Progress
|
||
value={pushProgress.total > 0 ? (pushProgress.current / pushProgress.total) * 100 : 0}
|
||
max={100}
|
||
class="h-2"
|
||
/>
|
||
<p class="flex items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||
<Spinner class="h-3.5 w-3.5" />
|
||
Sending to machine ({pushProgress.current}/{pushProgress.total}): {pushProgress.name}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Upload Progress -->
|
||
{#if uploading}
|
||
<div class="space-y-2">
|
||
<Progress
|
||
value={uploadProgress.total > 0
|
||
? (uploadProgress.current / uploadProgress.total) * 100
|
||
: 0}
|
||
max={100}
|
||
class="h-2"
|
||
/>
|
||
<p class="text-center text-sm text-muted-foreground">
|
||
Uploading: {uploadProgress.current} / {uploadProgress.total}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Generating manifest from machine (method 2) -->
|
||
{#if generatingManifest}
|
||
<p class="flex items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||
<Spinner class="h-3.5 w-3.5" />
|
||
Generating sync_1.file on machine and uploading...
|
||
</p>
|
||
{/if}
|
||
|
||
<!-- File List -->
|
||
{#if files.length > 0}
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<Card.Title class="flex items-center justify-between">
|
||
<span>Selected Files ({files.length})</span>
|
||
<div class="flex gap-2">
|
||
{#if files.some((f) => f.status === 'success')}
|
||
<Badge variant="default" class="bg-green-500">
|
||
{files.filter((f) => f.status === 'success').length} uploaded
|
||
</Badge>
|
||
{/if}
|
||
{#if files.some((f) => f.status === 'error')}
|
||
<Badge variant="destructive">
|
||
{files.filter((f) => f.status === 'error').length} invalid/failed
|
||
</Badge>
|
||
{/if}
|
||
</div>
|
||
</Card.Title>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{#each files as item (item.id)}
|
||
<div
|
||
class="group relative overflow-hidden rounded-lg border bg-muted/30 transition-shadow hover:shadow-md"
|
||
>
|
||
<!-- Video preview -->
|
||
<div class="relative aspect-video bg-black">
|
||
<!-- svelte-ignore a11y_media_has_caption -->
|
||
<video src={item.preview} class="h-full w-full object-contain" muted controls
|
||
></video>
|
||
|
||
{#if item.status === 'uploading'}
|
||
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||
<Spinner class="h-8 w-8 text-white" />
|
||
</div>
|
||
{:else if item.status === 'success'}
|
||
<div
|
||
class="absolute inset-0 flex items-center justify-center bg-green-500/20"
|
||
>
|
||
<CheckCircle class="h-10 w-10 text-green-500" />
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Remove button -->
|
||
{#if item.status !== 'uploading'}
|
||
<button
|
||
class="absolute top-2 right-2 rounded-full bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||
onclick={() => removeFile(item.id)}
|
||
disabled={uploading}
|
||
>
|
||
<X class="h-4 w-4" />
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- File info -->
|
||
<div class="space-y-1 p-3">
|
||
<p class="truncate text-sm font-medium" title={item.file.name}>
|
||
{item.file.name}
|
||
</p>
|
||
<div class="flex flex-wrap items-center gap-1.5">
|
||
<Badge variant="outline" class="text-[11px]">
|
||
{item.category === 'menu' ? 'Menu banner' : 'Fullscreen'}
|
||
</Badge>
|
||
{#if !item.dimChecked}
|
||
<Badge variant="secondary" class="text-[11px]">Reading…</Badge>
|
||
{:else if item.dimOk}
|
||
<Badge variant="default" class="bg-green-500 text-[11px]">
|
||
{item.width}×{item.height}
|
||
</Badge>
|
||
{:else}
|
||
<Badge variant="destructive" class="text-[11px]">
|
||
{item.width ?? '?'}×{item.height ?? '?'}
|
||
</Badge>
|
||
{/if}
|
||
<span class="text-[11px] text-muted-foreground">
|
||
{(item.file.size / (1024 * 1024)).toFixed(1)} MB
|
||
</span>
|
||
</div>
|
||
<p class="text-[11px] text-muted-foreground">
|
||
Expected {item.expectedWidth}×{item.expectedHeight}
|
||
</p>
|
||
{#if item.error}
|
||
<p class="flex items-center gap-1 text-[11px] text-red-500" title={item.error}>
|
||
<AlertCircle class="h-3 w-3 shrink-0" />
|
||
<span class="truncate">{item.error}</span>
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</Card.Content>
|
||
</Card.Root>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|