feat: init vlc-sender
This commit is contained in:
277
src/index.ts
Normal file
277
src/index.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import FormData from 'form-data';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const SUBNET_PREFIX = '192.168.1.';
|
||||
|
||||
// Parse volumes: "label1:/path1,label2:/path2" or just "/path1,/path2"
|
||||
interface Volume {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function parseVolumes(): Volume[] {
|
||||
const raw = process.env.MEDIA_PATHS || process.env.MEDIA_PATH || '/media';
|
||||
return raw
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const colonIdx = entry.indexOf(':');
|
||||
// If has label (e.g. "Torrents:/data/torrents")
|
||||
// But skip Windows-like paths or bare /paths
|
||||
if (colonIdx > 0 && !entry.startsWith('/')) {
|
||||
return {
|
||||
label: entry.substring(0, colonIdx),
|
||||
path: entry.substring(colonIdx + 1),
|
||||
};
|
||||
}
|
||||
// Just a path — use directory name as label
|
||||
return {
|
||||
label: path.basename(entry) || entry,
|
||||
path: entry,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const VOLUMES = parseVolumes();
|
||||
|
||||
function findVolume(volumeLabel: string): Volume | undefined {
|
||||
return VOLUMES.find((v) => v.label === volumeLabel);
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
app.get('/api/files', (req: Request, res: Response): void => {
|
||||
const relativePath = (req.query.path as string) || '';
|
||||
|
||||
// Root level — list volumes
|
||||
if (!relativePath) {
|
||||
const volumes: FileEntry[] = VOLUMES.map((v) => ({
|
||||
name: v.label,
|
||||
size: 0,
|
||||
isDir: true,
|
||||
}));
|
||||
res.json(volumes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse: first segment is volume label, rest is subpath
|
||||
const parts = relativePath.split('/');
|
||||
const volumeLabel = parts[0];
|
||||
const subPath = parts.slice(1).join('/');
|
||||
const volume = findVolume(volumeLabel);
|
||||
|
||||
if (!volume) {
|
||||
res.status(404).json({ error: `Volume not found: ${volumeLabel}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(volume.path, subPath);
|
||||
const resolved = path.resolve(fullPath);
|
||||
|
||||
if (!resolved.startsWith(path.resolve(volume.path))) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
console.error(`[files] Path does not exist: ${resolved}`);
|
||||
res.status(404).json({ error: `Directory not found: ${resolved}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
||||
const files: FileEntry[] = entries
|
||||
.filter((e) => !e.name.startsWith('.'))
|
||||
.map((e) => {
|
||||
const entryPath = path.join(resolved, e.name);
|
||||
try {
|
||||
const stat = fs.statSync(entryPath);
|
||||
return {
|
||||
name: e.name,
|
||||
size: stat.size,
|
||||
isDir: e.isDirectory(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is FileEntry => e !== null)
|
||||
.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
console.log(`[files] ${resolved} → ${files.length} entries`);
|
||||
res.json(files);
|
||||
} catch (err) {
|
||||
console.error(`[files] Error reading ${resolved}:`, err);
|
||||
res.status(500).json({ error: `Failed to read directory: ${String(err)}` });
|
||||
}
|
||||
});
|
||||
|
||||
interface SendRequest {
|
||||
ip: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function resolveFilePath(filePath: string): string | null {
|
||||
const parts = filePath.split('/');
|
||||
const volumeLabel = parts[0];
|
||||
const subPath = parts.slice(1).join('/');
|
||||
const volume = findVolume(volumeLabel);
|
||||
if (!volume) return null;
|
||||
|
||||
const full = path.resolve(path.join(volume.path, subPath));
|
||||
if (!full.startsWith(path.resolve(volume.path))) return null;
|
||||
return full;
|
||||
}
|
||||
|
||||
function sendFileToVLC(
|
||||
filePath: string,
|
||||
vlcHost: string,
|
||||
): Promise<{ file: string; success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) {
|
||||
resolve({ file: filePath, success: false, error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
resolve({ file: filePath, success: false, error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
const fileStream = fs.createReadStream(resolved);
|
||||
form.append('file', fileStream, {
|
||||
filename: path.basename(resolved),
|
||||
knownLength: stat.size,
|
||||
});
|
||||
|
||||
const reqOptions: http.RequestOptions = {
|
||||
hostname: vlcHost,
|
||||
port: 80,
|
||||
path: '/upload.json',
|
||||
method: 'POST',
|
||||
headers: form.getHeaders(),
|
||||
};
|
||||
|
||||
const req = http.request(reqOptions, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ file: filePath, success: true });
|
||||
} else {
|
||||
resolve({
|
||||
file: filePath,
|
||||
success: false,
|
||||
error: `VLC returned ${res.statusCode}: ${body}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err: Error) => {
|
||||
resolve({ file: filePath, success: false, error: err.message });
|
||||
});
|
||||
|
||||
form.pipe(req);
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/api/send', (req: Request, res: Response): void => {
|
||||
const { ip, files } = req.body as SendRequest;
|
||||
|
||||
if (!ip || !files || !files.length) {
|
||||
res.status(400).json({ error: 'ip and files are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ipNum = parseInt(ip, 10);
|
||||
if (isNaN(ipNum) || ipNum < 1 || ipNum > 254) {
|
||||
res.status(400).json({ error: 'Invalid IP suffix' });
|
||||
return;
|
||||
}
|
||||
|
||||
const vlcHost = `${SUBNET_PREFIX}${ip}`;
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const sendProgress = (data: Record<string, unknown>) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completed = 0;
|
||||
|
||||
const processFiles = async () => {
|
||||
for (const file of files) {
|
||||
const fileName = path.basename(file);
|
||||
const resolved = resolveFilePath(file);
|
||||
|
||||
let fileSize = 0;
|
||||
if (resolved) {
|
||||
try {
|
||||
fileSize = fs.statSync(resolved).size;
|
||||
} catch {
|
||||
// will be caught by sendFileToVLC
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress({
|
||||
type: 'start',
|
||||
file: fileName,
|
||||
index: completed,
|
||||
total: totalFiles,
|
||||
size: fileSize,
|
||||
});
|
||||
|
||||
const result = await sendFileToVLC(file, vlcHost);
|
||||
completed++;
|
||||
|
||||
sendProgress({
|
||||
type: result.success ? 'done' : 'error',
|
||||
file: fileName,
|
||||
index: completed - 1,
|
||||
total: totalFiles,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
sendProgress({ type: 'complete', total: totalFiles });
|
||||
res.end();
|
||||
};
|
||||
|
||||
processFiles();
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`VLC Sender running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Volumes:`);
|
||||
VOLUMES.forEach((v) => console.log(` ${v.label} → ${v.path}`));
|
||||
});
|
||||
468
src/public/index.html
Normal file
468
src/public/index.html
Normal file
@@ -0,0 +1,468 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VLC Sender</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 16px;
|
||||
color: #ff8c00;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.ip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.ip-group span { color: #888; font-size: 0.9rem; }
|
||||
.ip-group input {
|
||||
width: 50px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-send { background: #ff8c00; color: #000; }
|
||||
.btn-back { background: #333; color: #eee; }
|
||||
.btn-select { background: #16213e; color: #eee; border: 1px solid #444; font-size: 0.8rem; padding: 6px 10px; }
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #ff8c00;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
.breadcrumb span { color: #666; }
|
||||
|
||||
.file-list { list-style: none; }
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #222;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.file-item:hover { background: #16213e; }
|
||||
.file-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #ff8c00;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-icon { font-size: 1.2rem; flex-shrink: 0; }
|
||||
.file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-size { color: #888; font-size: 0.8rem; flex-shrink: 0; }
|
||||
|
||||
.progress-panel {
|
||||
margin-top: 16px;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: none;
|
||||
}
|
||||
.progress-panel.active { display: block; }
|
||||
.progress-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-item:last-child { margin-bottom: 0; }
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
width: 0%;
|
||||
}
|
||||
.progress-fill.sending { background: #ff8c00; }
|
||||
.progress-fill.done { background: #4caf50; width: 100%; }
|
||||
.progress-fill.error { background: #f44336; width: 100%; }
|
||||
|
||||
.status-msg {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
.status-msg.success { color: #4caf50; }
|
||||
.status-msg.error { color: #f44336; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>VLC Sender</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="ip-group">
|
||||
<span>192.168.1.</span>
|
||||
<input type="text" id="ipInput" placeholder="___" maxlength="3" inputmode="numeric">
|
||||
</div>
|
||||
<button class="btn btn-send" id="sendBtn" disabled>Отправить</button>
|
||||
<button class="btn btn-select" id="selectAllBtn">Выбрать все</button>
|
||||
<span class="selected-count" id="selectedCount"></span>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
<ul class="file-list" id="fileList"></ul>
|
||||
|
||||
<div class="progress-panel" id="progressPanel"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const fileList = document.getElementById('fileList');
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
const ipInput = document.getElementById('ipInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const selectAllBtn = document.getElementById('selectAllBtn');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
const progressPanel = document.getElementById('progressPanel');
|
||||
|
||||
let currentPath = '';
|
||||
let selectedFiles = new Set();
|
||||
let currentFiles = [];
|
||||
let sending = false;
|
||||
|
||||
// Restore last IP
|
||||
const savedIp = localStorage.getItem('vlc-ip');
|
||||
if (savedIp) ipInput.value = savedIp;
|
||||
|
||||
ipInput.addEventListener('input', () => {
|
||||
localStorage.setItem('vlc-ip', ipInput.value);
|
||||
updateSendBtn();
|
||||
});
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function getFileIcon(name, isDir) {
|
||||
if (isDir) return '\u{1F4C1}';
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
const video = ['mkv', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'ts', 'm4v'];
|
||||
const audio = ['mp3', 'flac', 'aac', 'ogg', 'wav', 'wma', 'm4a'];
|
||||
const sub = ['srt', 'ass', 'ssa', 'sub', 'vtt'];
|
||||
if (video.includes(ext)) return '\u{1F3AC}';
|
||||
if (audio.includes(ext)) return '\u{1F3B5}';
|
||||
if (sub.includes(ext)) return '\u{1F4DD}';
|
||||
return '\u{1F4C4}';
|
||||
}
|
||||
|
||||
function updateSendBtn() {
|
||||
const ip = ipInput.value.trim();
|
||||
const ipNum = parseInt(ip, 10);
|
||||
const validIp = ip && !isNaN(ipNum) && ipNum >= 1 && ipNum <= 254;
|
||||
sendBtn.disabled = sending || selectedFiles.size === 0 || !validIp;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
if (selectedFiles.size > 0) {
|
||||
selectedCount.textContent = 'Выбрано: ' + selectedFiles.size;
|
||||
} else {
|
||||
selectedCount.textContent = '';
|
||||
}
|
||||
updateSendBtn();
|
||||
}
|
||||
|
||||
function renderBreadcrumb() {
|
||||
const parts = currentPath ? currentPath.split('/') : [];
|
||||
let html = '<a data-path="">root</a>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += (accumulated ? '/' : '') + part;
|
||||
html += '<span>/</span><a data-path="' + accumulated + '">' + part + '</a>';
|
||||
}
|
||||
breadcrumb.innerHTML = html;
|
||||
breadcrumb.querySelectorAll('a').forEach(a => {
|
||||
a.addEventListener('click', () => {
|
||||
navigateTo(a.dataset.path);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFiles(dirPath) {
|
||||
try {
|
||||
const res = await fetch('/api/files?path=' + encodeURIComponent(dirPath));
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'HTTP ' + res.status);
|
||||
}
|
||||
return { files: await res.json(), error: null };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { files: [], error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateTo(dirPath) {
|
||||
currentPath = dirPath;
|
||||
selectedFiles.clear();
|
||||
updateSelectedCount();
|
||||
renderBreadcrumb();
|
||||
|
||||
const { files, error } = await loadFiles(dirPath);
|
||||
currentFiles = files;
|
||||
if (error) {
|
||||
fileList.innerHTML = '<div class="empty" style="color:#f44336">Ошибка: ' + error + '</div>';
|
||||
} else {
|
||||
renderFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files.length) {
|
||||
fileList.innerHTML = '<div class="empty">Пусто</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
fileList.innerHTML = '';
|
||||
for (const f of files) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'file-item';
|
||||
|
||||
const filePath = currentPath ? currentPath + '/' + f.name : f.name;
|
||||
|
||||
if (f.isDir) {
|
||||
li.innerHTML =
|
||||
'<span class="file-icon">' + getFileIcon(f.name, true) + '</span>' +
|
||||
'<span class="file-name">' + f.name + '</span>' +
|
||||
'<span class="file-size">' + formatSize(f.size) + '</span>';
|
||||
li.addEventListener('click', () => navigateTo(filePath));
|
||||
} else {
|
||||
const checked = selectedFiles.has(filePath) ? 'checked' : '';
|
||||
li.innerHTML =
|
||||
'<input type="checkbox" data-path="' + filePath + '" ' + checked + '>' +
|
||||
'<span class="file-icon">' + getFileIcon(f.name, false) + '</span>' +
|
||||
'<span class="file-name">' + f.name + '</span>' +
|
||||
'<span class="file-size">' + formatSize(f.size) + '</span>';
|
||||
|
||||
const cb = li.querySelector('input[type="checkbox"]');
|
||||
cb.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
if (cb.checked) {
|
||||
selectedFiles.add(filePath);
|
||||
} else {
|
||||
selectedFiles.delete(filePath);
|
||||
}
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
li.addEventListener('click', (e) => {
|
||||
if (e.target === cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
});
|
||||
}
|
||||
|
||||
fileList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
const nonDirFiles = currentFiles.filter(f => !f.isDir);
|
||||
const allSelected = nonDirFiles.length > 0 && nonDirFiles.every(f => {
|
||||
const p = currentPath ? currentPath + '/' + f.name : f.name;
|
||||
return selectedFiles.has(p);
|
||||
});
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all
|
||||
for (const f of nonDirFiles) {
|
||||
const p = currentPath ? currentPath + '/' + f.name : f.name;
|
||||
selectedFiles.delete(p);
|
||||
}
|
||||
} else {
|
||||
// Select all
|
||||
for (const f of nonDirFiles) {
|
||||
const p = currentPath ? currentPath + '/' + f.name : f.name;
|
||||
selectedFiles.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
renderFiles(currentFiles);
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', () => {
|
||||
if (sending || selectedFiles.size === 0) return;
|
||||
const ip = ipInput.value.trim();
|
||||
if (!ip) return;
|
||||
|
||||
sending = true;
|
||||
updateSendBtn();
|
||||
sendBtn.textContent = 'Отправка...';
|
||||
|
||||
const files = Array.from(selectedFiles);
|
||||
progressPanel.classList.add('active');
|
||||
progressPanel.innerHTML = files.map((f, i) => {
|
||||
const name = f.split('/').pop();
|
||||
return '<div class="progress-item" id="prog-' + i + '">' +
|
||||
'<div class="progress-label"><span>' + name + '</span><span class="prog-status">Ожидание</span></div>' +
|
||||
'<div class="progress-bar"><div class="progress-fill" id="fill-' + i + '"></div></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
const body = JSON.stringify({ ip, files });
|
||||
fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
}).then(response => {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
function read() {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
sending = false;
|
||||
sendBtn.textContent = 'Отправить';
|
||||
updateSendBtn();
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handleProgress(data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
read();
|
||||
});
|
||||
}
|
||||
read();
|
||||
}).catch(err => {
|
||||
sending = false;
|
||||
sendBtn.textContent = 'Отправить';
|
||||
updateSendBtn();
|
||||
|
||||
const statusDiv = document.createElement('div');
|
||||
statusDiv.className = 'status-msg error';
|
||||
statusDiv.textContent = 'Ошибка соединения: ' + err.message;
|
||||
progressPanel.appendChild(statusDiv);
|
||||
});
|
||||
});
|
||||
|
||||
function handleProgress(data) {
|
||||
const { type, file, index, total, error } = data;
|
||||
if (type === 'start') {
|
||||
const item = document.getElementById('prog-' + index);
|
||||
if (item) {
|
||||
item.querySelector('.prog-status').textContent = 'Отправка...';
|
||||
const fill = document.getElementById('fill-' + index);
|
||||
fill.className = 'progress-fill sending';
|
||||
fill.style.width = '50%';
|
||||
}
|
||||
} else if (type === 'done') {
|
||||
const item = document.getElementById('prog-' + index);
|
||||
if (item) {
|
||||
item.querySelector('.prog-status').textContent = 'Готово';
|
||||
const fill = document.getElementById('fill-' + index);
|
||||
fill.className = 'progress-fill done';
|
||||
}
|
||||
} else if (type === 'error') {
|
||||
const item = document.getElementById('prog-' + index);
|
||||
if (item) {
|
||||
item.querySelector('.prog-status').textContent = 'Ошибка';
|
||||
const fill = document.getElementById('fill-' + index);
|
||||
fill.className = 'progress-fill error';
|
||||
}
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'status-msg error';
|
||||
errDiv.textContent = file + ': ' + (error || 'Unknown error');
|
||||
progressPanel.appendChild(errDiv);
|
||||
} else if (type === 'complete') {
|
||||
const statusDiv = document.createElement('div');
|
||||
statusDiv.className = 'status-msg success';
|
||||
statusDiv.textContent = 'Отправка завершена (' + total + ' файлов)';
|
||||
progressPanel.appendChild(statusDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
navigateTo('');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user