added download monitor
This commit is contained in:
@@ -23,6 +23,8 @@ type DownloadStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
|
folderName?: string;
|
||||||
|
fileName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeDownloads = new Map<string, DownloadStatus>();
|
const activeDownloads = new Map<string, DownloadStatus>();
|
||||||
@@ -50,6 +52,7 @@ type AnimeDownloadParams = {
|
|||||||
quality?: string;
|
quality?: string;
|
||||||
subtitles?: Array<{ language: string; url: string }>;
|
subtitles?: Array<{ language: string; url: string }>;
|
||||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||||
|
totalDuration?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BookDownloadParams = {
|
type BookDownloadParams = {
|
||||||
@@ -137,10 +140,12 @@ async function getOrCreateEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
const { anilistId, episodeNumber, streamUrl, subtitles, chapters, totalDuration } = params;
|
||||||
|
|
||||||
|
const entry: any = await getOrCreateEntry(anilistId, 'anime');
|
||||||
|
const fileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`;
|
||||||
|
|
||||||
const downloadId = crypto.randomUUID();
|
const downloadId = crypto.randomUUID();
|
||||||
|
|
||||||
activeDownloads.set(downloadId, {
|
activeDownloads.set(downloadId, {
|
||||||
id: downloadId,
|
id: downloadId,
|
||||||
type: 'anime',
|
type: 'anime',
|
||||||
@@ -148,11 +153,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
unitNumber: episodeNumber,
|
unitNumber: episodeNumber,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
startedAt: Date.now()
|
startedAt: Date.now(),
|
||||||
|
folderName: entry.folderName,
|
||||||
|
fileName: fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
|
||||||
|
|
||||||
const exists = await queryOne(
|
const exists = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, episodeNumber],
|
[entry.id, episodeNumber],
|
||||||
@@ -314,11 +319,21 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
const speedMatch = text.match(/speed=(\S+)/);
|
const speedMatch = text.match(/speed=(\S+)/);
|
||||||
|
|
||||||
if (timeMatch || speedMatch) {
|
if (timeMatch || speedMatch) {
|
||||||
updateDownloadProgress(downloadId, {
|
const updates: any = {};
|
||||||
timeElapsed: timeMatch?.[1],
|
|
||||||
speed: speedMatch?.[1]
|
if (timeMatch) updates.timeElapsed = timeMatch[1];
|
||||||
});
|
if (speedMatch) updates.speed = speedMatch[1];
|
||||||
|
|
||||||
|
if (timeMatch && totalDuration && totalDuration > 0) {
|
||||||
|
const elapsedSeconds = parseFFmpegTime(timeMatch[1]);
|
||||||
|
updates.progress = Math.min(
|
||||||
|
99,
|
||||||
|
Math.round((elapsedSeconds / totalDuration) * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateDownloadProgress(downloadId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ff.on('error', (error) => reject(error));
|
ff.on('error', (error) => reject(error));
|
||||||
@@ -381,6 +396,12 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
export async function downloadBookChapter(params: BookDownloadParams) {
|
export async function downloadBookChapter(params: BookDownloadParams) {
|
||||||
const { anilistId, chapterNumber, format, content, images } = params;
|
const { anilistId, chapterNumber, format, content, images } = params;
|
||||||
|
|
||||||
|
const type = format === 'manga' ? 'manga' : 'novels';
|
||||||
|
const entry = await getOrCreateEntry(anilistId, type);
|
||||||
|
|
||||||
|
const ext = format === 'manga' ? 'cbz' : 'epub';
|
||||||
|
const fileName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.${ext}`;
|
||||||
|
|
||||||
const downloadId = crypto.randomUUID();
|
const downloadId = crypto.randomUUID();
|
||||||
|
|
||||||
activeDownloads.set(downloadId, {
|
activeDownloads.set(downloadId, {
|
||||||
@@ -390,12 +411,11 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
|||||||
unitNumber: chapterNumber,
|
unitNumber: chapterNumber,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
startedAt: Date.now()
|
startedAt: Date.now(),
|
||||||
|
folderName: entry.folderName,
|
||||||
|
fileName: fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
const type = format === 'manga' ? 'manga' : 'novels';
|
|
||||||
const entry = await getOrCreateEntry(anilistId, type);
|
|
||||||
|
|
||||||
const existingFile = await queryOne(
|
const existingFile = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, chapterNumber],
|
[entry.id, chapterNumber],
|
||||||
@@ -531,4 +551,15 @@ ${content}
|
|||||||
(err as any).details = error.message;
|
(err as any).details = error.message;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFFmpegTime(timeStr: string): number {
|
||||||
|
const parts = timeStr.split(':');
|
||||||
|
if (parts.length < 3) return 0;
|
||||||
|
|
||||||
|
const h = parseFloat(parts[0]) || 0;
|
||||||
|
const m = parseFloat(parts[1]) || 0;
|
||||||
|
const s = parseFloat(parts[2]) || 0;
|
||||||
|
|
||||||
|
return (h * 3600) + (m * 60) + s;
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ type DownloadAnimeBody =
|
|||||||
language: string;
|
language: string;
|
||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
|
duration?: number;
|
||||||
chapters?: {
|
chapters?: {
|
||||||
title: string;
|
title: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
@@ -38,6 +39,7 @@ type DownloadAnimeBody =
|
|||||||
anilist_id: number;
|
anilist_id: number;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
stream_url: string;
|
stream_url: string;
|
||||||
|
duration?: number;
|
||||||
is_master: true;
|
is_master: true;
|
||||||
variant: {
|
variant: {
|
||||||
resolution: string;
|
resolution: string;
|
||||||
@@ -256,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
|||||||
anilist_id,
|
anilist_id,
|
||||||
episode_number,
|
episode_number,
|
||||||
stream_url,
|
stream_url,
|
||||||
|
duration,
|
||||||
is_master,
|
is_master,
|
||||||
subtitles,
|
subtitles,
|
||||||
chapters
|
chapters
|
||||||
@@ -283,7 +286,8 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
|||||||
episodeNumber: episode_number,
|
episodeNumber: episode_number,
|
||||||
streamUrl: proxyUrl,
|
streamUrl: proxyUrl,
|
||||||
subtitles: proxiedSubs,
|
subtitles: proxiedSubs,
|
||||||
chapters
|
chapters,
|
||||||
|
totalDuration: duration
|
||||||
};
|
};
|
||||||
|
|
||||||
if (is_master === true) {
|
if (is_master === true) {
|
||||||
|
|||||||
@@ -1779,11 +1779,19 @@ const AnimePlayer = (function() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||||
|
|
||||||
|
// --- CAMBIO AQUÍ: Calcular duración del video actual ---
|
||||||
|
let totalDuration = 0;
|
||||||
|
if (els.video && isFinite(els.video.duration) && els.video.duration > 0) {
|
||||||
|
totalDuration = Math.floor(els.video.duration);
|
||||||
|
}
|
||||||
|
// -------------------------------------------------------
|
||||||
|
|
||||||
let body = {
|
let body = {
|
||||||
anilist_id: parseInt(_animeId),
|
anilist_id: parseInt(_animeId),
|
||||||
episode_number: parseInt(_currentEpisode),
|
episode_number: parseInt(_currentEpisode),
|
||||||
stream_url: _rawVideoData.url,
|
stream_url: _rawVideoData.url,
|
||||||
headers: _rawVideoData.headers || {},
|
headers: _rawVideoData.headers || {},
|
||||||
|
duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN
|
||||||
chapters: _skipIntervals.map(i => ({
|
chapters: _skipIntervals.map(i => ({
|
||||||
title: i.type === 'op' ? 'Opening' : 'Ending',
|
title: i.type === 'op' ? 'Opening' : 'Ending',
|
||||||
start_time: i.startTime,
|
start_time: i.startTime,
|
||||||
|
|||||||
@@ -388,6 +388,95 @@ const DashboardApp = {
|
|||||||
|
|
||||||
Library: {
|
Library: {
|
||||||
tempMatchContext: null,
|
tempMatchContext: null,
|
||||||
|
pollInterval: null,
|
||||||
|
updateDownloadStatus: async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/library/downloads/status`, {
|
||||||
|
headers: window.AuthUtils.getSimpleAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.renderDownloadMonitor(data);
|
||||||
|
|
||||||
|
// Si hay descargas completadas nuevas, podríamos recargar la lista de archivos
|
||||||
|
// (Opcional: lógica para detectar cambios y llamar a loadContent)
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error polling downloads:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDownloadMonitor: function(data) {
|
||||||
|
const monitor = document.getElementById('downloads-monitor');
|
||||||
|
const listContainer = document.getElementById('downloads-list-container');
|
||||||
|
const activeCountEl = document.getElementById('dl-stat-active');
|
||||||
|
|
||||||
|
// Datos por defecto
|
||||||
|
const downloads = data.downloads || { list: [], active: 0, failed: 0 };
|
||||||
|
|
||||||
|
// Actualizar contadores cabecera
|
||||||
|
if(activeCountEl) activeCountEl.textContent = `${downloads.active} Active / ${downloads.list.length} Total`;
|
||||||
|
|
||||||
|
// Ocultar si vacío
|
||||||
|
if (downloads.list.length === 0) {
|
||||||
|
monitor.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.classList.remove('hidden');
|
||||||
|
|
||||||
|
listContainer.innerHTML = downloads.list.map(item => {
|
||||||
|
const fileName = item.fileName || `Unknown_File_${item.unitNumber}`;
|
||||||
|
const folderName = item.folderName || 'Unsorted';
|
||||||
|
const status = item.status || 'pending';
|
||||||
|
const progress = item.progress || 0;
|
||||||
|
const speed = item.speed || '0 KB/s';
|
||||||
|
|
||||||
|
const isCompleted = status === 'completed';
|
||||||
|
const isFailed = status === 'failed';
|
||||||
|
|
||||||
|
let statusText = `${progress}%`;
|
||||||
|
if (isCompleted) statusText = 'Done';
|
||||||
|
if (isFailed) statusText = 'Failed';
|
||||||
|
|
||||||
|
const folderIcon = `<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`;
|
||||||
|
|
||||||
|
// ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal
|
||||||
|
return `
|
||||||
|
<div class="dl-item compact">
|
||||||
|
<div class="dl-left-col">
|
||||||
|
<div class="dl-filename" title="${fileName}">${fileName}</div>
|
||||||
|
<div class="dl-folder" title="${folderName}">${folderIcon} ${folderName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-right-col">
|
||||||
|
<div class="dl-meta-info">
|
||||||
|
<span class="dl-speed">${isCompleted ? '' : speed}</span>
|
||||||
|
<span class="dl-status-text ${status}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dl-progress-track">
|
||||||
|
<div class="dl-progress-fill ${status}" style="width: ${progress}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
startPolling: function() {
|
||||||
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
|
this.updateDownloadStatus(); // Primera llamada inmediata
|
||||||
|
this.pollInterval = setInterval(() => this.updateDownloadStatus(), 2000); // Cada 2 segundos
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling: function() {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
loadStats: async function() {
|
loadStats: async function() {
|
||||||
const types = ['anime', 'manga', 'novels'];
|
const types = ['anime', 'manga', 'novels'];
|
||||||
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
||||||
@@ -584,6 +673,7 @@ const DashboardApp = {
|
|||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
|
// Gestión de clases activas
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
|
|
||||||
@@ -593,9 +683,13 @@ const DashboardApp = {
|
|||||||
if (sec.id === targetId) sec.classList.add('active');
|
if (sec.id === targetId) sec.classList.add('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lógica específica por pestaña
|
||||||
if (tab.dataset.target === 'local') {
|
if (tab.dataset.target === 'local') {
|
||||||
DashboardApp.Library.loadStats();
|
DashboardApp.Library.loadStats();
|
||||||
DashboardApp.Library.loadContent('anime');
|
DashboardApp.Library.loadContent(DashboardApp.State.currentLocalType || 'anime');
|
||||||
|
DashboardApp.Library.startPolling(); // <--- INICIAR POLLING
|
||||||
|
} else {
|
||||||
|
DashboardApp.Library.stopPolling(); // <--- DETENER POLLING AL SALIR
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -795,4 +795,144 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
DOWNLOAD MANAGER (COMPACTO)
|
||||||
|
========================================= */
|
||||||
|
.downloads-monitor {
|
||||||
|
background: var(--color-bg-elevated, #18181b);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-header {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.8rem 1.2rem; /* Header más delgado */
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: white;
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-list {
|
||||||
|
padding: 0;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ITEM COMPACTO --- */
|
||||||
|
.dl-item.compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1.2rem; /* Mucho menos padding vertical */
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item.compact:last-child { border-bottom: none; }
|
||||||
|
.dl-item.compact:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
/* Columna Izquierda: Nombres */
|
||||||
|
.dl-left-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1; /* Ocupa el espacio disponible */
|
||||||
|
min-width: 0; /* Permite truncar texto */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-filename {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f4f4f5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-folder {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #71717a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-folder svg {
|
||||||
|
color: var(--color-primary, #8b5cf6);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Columna Derecha: Progreso y Stats */
|
||||||
|
.dl-right-col {
|
||||||
|
width: 40%; /* Ancho fijo para la zona de progreso */
|
||||||
|
max-width: 300px;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-meta-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-status-text.completed { color: #4ade80; }
|
||||||
|
.dl-status-text.failed { color: #ef4444; }
|
||||||
|
|
||||||
|
/* Barra de progreso más fina */
|
||||||
|
.dl-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px; /* Barra delgada */
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary, #8b5cf6);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-progress-fill.completed { background: #4ade80; }
|
||||||
|
.dl-progress-fill.failed { background: #ef4444; }
|
||||||
|
|
||||||
|
/* Responsive para móviles */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dl-item.compact {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.dl-right-col { width: 100%; max-width: none; }
|
||||||
}
|
}
|
||||||
@@ -97,6 +97,22 @@
|
|||||||
|
|
||||||
<div id="section-local" class="tab-section">
|
<div id="section-local" class="tab-section">
|
||||||
|
|
||||||
|
<div id="downloads-monitor" class="downloads-monitor">
|
||||||
|
<div class="monitor-header">
|
||||||
|
<div class="monitor-title">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
<h3>Download Manager</h3>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-stats">
|
||||||
|
<span id="dl-stat-active">0 Active</span>
|
||||||
|
<span class="divider">•</span>
|
||||||
|
<span id="dl-stat-failed">0 Failed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-list-container" class="monitor-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar local-toolbar">
|
<div class="toolbar local-toolbar">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type DownloadStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
|
folderName?: string;
|
||||||
|
fileName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeDownloads = new Map<string, DownloadStatus>();
|
const activeDownloads = new Map<string, DownloadStatus>();
|
||||||
@@ -50,6 +52,7 @@ type AnimeDownloadParams = {
|
|||||||
quality?: string;
|
quality?: string;
|
||||||
subtitles?: Array<{ language: string; url: string }>;
|
subtitles?: Array<{ language: string; url: string }>;
|
||||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||||
|
totalDuration?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BookDownloadParams = {
|
type BookDownloadParams = {
|
||||||
@@ -137,10 +140,12 @@ async function getOrCreateEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
const { anilistId, episodeNumber, streamUrl, subtitles, chapters, totalDuration } = params;
|
||||||
|
|
||||||
|
const entry: any = await getOrCreateEntry(anilistId, 'anime');
|
||||||
|
const fileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`;
|
||||||
|
|
||||||
const downloadId = crypto.randomUUID();
|
const downloadId = crypto.randomUUID();
|
||||||
|
|
||||||
activeDownloads.set(downloadId, {
|
activeDownloads.set(downloadId, {
|
||||||
id: downloadId,
|
id: downloadId,
|
||||||
type: 'anime',
|
type: 'anime',
|
||||||
@@ -148,11 +153,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
unitNumber: episodeNumber,
|
unitNumber: episodeNumber,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
startedAt: Date.now()
|
startedAt: Date.now(),
|
||||||
|
folderName: entry.folderName,
|
||||||
|
fileName: fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
|
||||||
|
|
||||||
const exists = await queryOne(
|
const exists = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, episodeNumber],
|
[entry.id, episodeNumber],
|
||||||
@@ -314,11 +319,21 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
const speedMatch = text.match(/speed=(\S+)/);
|
const speedMatch = text.match(/speed=(\S+)/);
|
||||||
|
|
||||||
if (timeMatch || speedMatch) {
|
if (timeMatch || speedMatch) {
|
||||||
updateDownloadProgress(downloadId, {
|
const updates: any = {};
|
||||||
timeElapsed: timeMatch?.[1],
|
|
||||||
speed: speedMatch?.[1]
|
if (timeMatch) updates.timeElapsed = timeMatch[1];
|
||||||
});
|
if (speedMatch) updates.speed = speedMatch[1];
|
||||||
|
|
||||||
|
if (timeMatch && totalDuration && totalDuration > 0) {
|
||||||
|
const elapsedSeconds = parseFFmpegTime(timeMatch[1]);
|
||||||
|
updates.progress = Math.min(
|
||||||
|
99,
|
||||||
|
Math.round((elapsedSeconds / totalDuration) * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateDownloadProgress(downloadId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ff.on('error', (error) => reject(error));
|
ff.on('error', (error) => reject(error));
|
||||||
@@ -381,6 +396,12 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
export async function downloadBookChapter(params: BookDownloadParams) {
|
export async function downloadBookChapter(params: BookDownloadParams) {
|
||||||
const { anilistId, chapterNumber, format, content, images } = params;
|
const { anilistId, chapterNumber, format, content, images } = params;
|
||||||
|
|
||||||
|
const type = format === 'manga' ? 'manga' : 'novels';
|
||||||
|
const entry = await getOrCreateEntry(anilistId, type);
|
||||||
|
|
||||||
|
const ext = format === 'manga' ? 'cbz' : 'epub';
|
||||||
|
const fileName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.${ext}`;
|
||||||
|
|
||||||
const downloadId = crypto.randomUUID();
|
const downloadId = crypto.randomUUID();
|
||||||
|
|
||||||
activeDownloads.set(downloadId, {
|
activeDownloads.set(downloadId, {
|
||||||
@@ -390,12 +411,11 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
|||||||
unitNumber: chapterNumber,
|
unitNumber: chapterNumber,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
startedAt: Date.now()
|
startedAt: Date.now(),
|
||||||
|
folderName: entry.folderName,
|
||||||
|
fileName: fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
const type = format === 'manga' ? 'manga' : 'novels';
|
|
||||||
const entry = await getOrCreateEntry(anilistId, type);
|
|
||||||
|
|
||||||
const existingFile = await queryOne(
|
const existingFile = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, chapterNumber],
|
[entry.id, chapterNumber],
|
||||||
@@ -531,4 +551,15 @@ ${content}
|
|||||||
(err as any).details = error.message;
|
(err as any).details = error.message;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFFmpegTime(timeStr: string): number {
|
||||||
|
const parts = timeStr.split(':');
|
||||||
|
if (parts.length < 3) return 0;
|
||||||
|
|
||||||
|
const h = parseFloat(parts[0]) || 0;
|
||||||
|
const m = parseFloat(parts[1]) || 0;
|
||||||
|
const s = parseFloat(parts[2]) || 0;
|
||||||
|
|
||||||
|
return (h * 3600) + (m * 60) + s;
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ type DownloadAnimeBody =
|
|||||||
language: string;
|
language: string;
|
||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
|
duration?: number;
|
||||||
chapters?: {
|
chapters?: {
|
||||||
title: string;
|
title: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
@@ -38,6 +39,7 @@ type DownloadAnimeBody =
|
|||||||
anilist_id: number;
|
anilist_id: number;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
stream_url: string;
|
stream_url: string;
|
||||||
|
duration?: number;
|
||||||
is_master: true;
|
is_master: true;
|
||||||
variant: {
|
variant: {
|
||||||
resolution: string;
|
resolution: string;
|
||||||
@@ -256,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
|||||||
anilist_id,
|
anilist_id,
|
||||||
episode_number,
|
episode_number,
|
||||||
stream_url,
|
stream_url,
|
||||||
|
duration,
|
||||||
is_master,
|
is_master,
|
||||||
subtitles,
|
subtitles,
|
||||||
chapters
|
chapters
|
||||||
@@ -283,7 +286,8 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
|||||||
episodeNumber: episode_number,
|
episodeNumber: episode_number,
|
||||||
streamUrl: proxyUrl,
|
streamUrl: proxyUrl,
|
||||||
subtitles: proxiedSubs,
|
subtitles: proxiedSubs,
|
||||||
chapters
|
chapters,
|
||||||
|
totalDuration: duration
|
||||||
};
|
};
|
||||||
|
|
||||||
if (is_master === true) {
|
if (is_master === true) {
|
||||||
|
|||||||
@@ -1779,11 +1779,19 @@ const AnimePlayer = (function() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||||
|
|
||||||
|
// --- CAMBIO AQUÍ: Calcular duración del video actual ---
|
||||||
|
let totalDuration = 0;
|
||||||
|
if (els.video && isFinite(els.video.duration) && els.video.duration > 0) {
|
||||||
|
totalDuration = Math.floor(els.video.duration);
|
||||||
|
}
|
||||||
|
// -------------------------------------------------------
|
||||||
|
|
||||||
let body = {
|
let body = {
|
||||||
anilist_id: parseInt(_animeId),
|
anilist_id: parseInt(_animeId),
|
||||||
episode_number: parseInt(_currentEpisode),
|
episode_number: parseInt(_currentEpisode),
|
||||||
stream_url: _rawVideoData.url,
|
stream_url: _rawVideoData.url,
|
||||||
headers: _rawVideoData.headers || {},
|
headers: _rawVideoData.headers || {},
|
||||||
|
duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN
|
||||||
chapters: _skipIntervals.map(i => ({
|
chapters: _skipIntervals.map(i => ({
|
||||||
title: i.type === 'op' ? 'Opening' : 'Ending',
|
title: i.type === 'op' ? 'Opening' : 'Ending',
|
||||||
start_time: i.startTime,
|
start_time: i.startTime,
|
||||||
|
|||||||
@@ -388,6 +388,95 @@ const DashboardApp = {
|
|||||||
|
|
||||||
Library: {
|
Library: {
|
||||||
tempMatchContext: null,
|
tempMatchContext: null,
|
||||||
|
pollInterval: null,
|
||||||
|
updateDownloadStatus: async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/library/downloads/status`, {
|
||||||
|
headers: window.AuthUtils.getSimpleAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.renderDownloadMonitor(data);
|
||||||
|
|
||||||
|
// Si hay descargas completadas nuevas, podríamos recargar la lista de archivos
|
||||||
|
// (Opcional: lógica para detectar cambios y llamar a loadContent)
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error polling downloads:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDownloadMonitor: function(data) {
|
||||||
|
const monitor = document.getElementById('downloads-monitor');
|
||||||
|
const listContainer = document.getElementById('downloads-list-container');
|
||||||
|
const activeCountEl = document.getElementById('dl-stat-active');
|
||||||
|
|
||||||
|
// Datos por defecto
|
||||||
|
const downloads = data.downloads || { list: [], active: 0, failed: 0 };
|
||||||
|
|
||||||
|
// Actualizar contadores cabecera
|
||||||
|
if(activeCountEl) activeCountEl.textContent = `${downloads.active} Active / ${downloads.list.length} Total`;
|
||||||
|
|
||||||
|
// Ocultar si vacío
|
||||||
|
if (downloads.list.length === 0) {
|
||||||
|
monitor.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.classList.remove('hidden');
|
||||||
|
|
||||||
|
listContainer.innerHTML = downloads.list.map(item => {
|
||||||
|
const fileName = item.fileName || `Unknown_File_${item.unitNumber}`;
|
||||||
|
const folderName = item.folderName || 'Unsorted';
|
||||||
|
const status = item.status || 'pending';
|
||||||
|
const progress = item.progress || 0;
|
||||||
|
const speed = item.speed || '0 KB/s';
|
||||||
|
|
||||||
|
const isCompleted = status === 'completed';
|
||||||
|
const isFailed = status === 'failed';
|
||||||
|
|
||||||
|
let statusText = `${progress}%`;
|
||||||
|
if (isCompleted) statusText = 'Done';
|
||||||
|
if (isFailed) statusText = 'Failed';
|
||||||
|
|
||||||
|
const folderIcon = `<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`;
|
||||||
|
|
||||||
|
// ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal
|
||||||
|
return `
|
||||||
|
<div class="dl-item compact">
|
||||||
|
<div class="dl-left-col">
|
||||||
|
<div class="dl-filename" title="${fileName}">${fileName}</div>
|
||||||
|
<div class="dl-folder" title="${folderName}">${folderIcon} ${folderName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-right-col">
|
||||||
|
<div class="dl-meta-info">
|
||||||
|
<span class="dl-speed">${isCompleted ? '' : speed}</span>
|
||||||
|
<span class="dl-status-text ${status}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dl-progress-track">
|
||||||
|
<div class="dl-progress-fill ${status}" style="width: ${progress}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
startPolling: function() {
|
||||||
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
|
this.updateDownloadStatus(); // Primera llamada inmediata
|
||||||
|
this.pollInterval = setInterval(() => this.updateDownloadStatus(), 2000); // Cada 2 segundos
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling: function() {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
loadStats: async function() {
|
loadStats: async function() {
|
||||||
const types = ['anime', 'manga', 'novels'];
|
const types = ['anime', 'manga', 'novels'];
|
||||||
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
|
||||||
@@ -584,6 +673,7 @@ const DashboardApp = {
|
|||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
|
// Gestión de clases activas
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
|
|
||||||
@@ -593,9 +683,13 @@ const DashboardApp = {
|
|||||||
if (sec.id === targetId) sec.classList.add('active');
|
if (sec.id === targetId) sec.classList.add('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lógica específica por pestaña
|
||||||
if (tab.dataset.target === 'local') {
|
if (tab.dataset.target === 'local') {
|
||||||
DashboardApp.Library.loadStats();
|
DashboardApp.Library.loadStats();
|
||||||
DashboardApp.Library.loadContent('anime');
|
DashboardApp.Library.loadContent(DashboardApp.State.currentLocalType || 'anime');
|
||||||
|
DashboardApp.Library.startPolling(); // <--- INICIAR POLLING
|
||||||
|
} else {
|
||||||
|
DashboardApp.Library.stopPolling(); // <--- DETENER POLLING AL SALIR
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -795,4 +795,144 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
DOWNLOAD MANAGER (COMPACTO)
|
||||||
|
========================================= */
|
||||||
|
.downloads-monitor {
|
||||||
|
background: var(--color-bg-elevated, #18181b);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-header {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.8rem 1.2rem; /* Header más delgado */
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: white;
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-list {
|
||||||
|
padding: 0;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ITEM COMPACTO --- */
|
||||||
|
.dl-item.compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1.2rem; /* Mucho menos padding vertical */
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item.compact:last-child { border-bottom: none; }
|
||||||
|
.dl-item.compact:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
/* Columna Izquierda: Nombres */
|
||||||
|
.dl-left-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1; /* Ocupa el espacio disponible */
|
||||||
|
min-width: 0; /* Permite truncar texto */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-filename {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f4f4f5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-folder {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #71717a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-folder svg {
|
||||||
|
color: var(--color-primary, #8b5cf6);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Columna Derecha: Progreso y Stats */
|
||||||
|
.dl-right-col {
|
||||||
|
width: 40%; /* Ancho fijo para la zona de progreso */
|
||||||
|
max-width: 300px;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-meta-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-status-text.completed { color: #4ade80; }
|
||||||
|
.dl-status-text.failed { color: #ef4444; }
|
||||||
|
|
||||||
|
/* Barra de progreso más fina */
|
||||||
|
.dl-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px; /* Barra delgada */
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary, #8b5cf6);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-progress-fill.completed { background: #4ade80; }
|
||||||
|
.dl-progress-fill.failed { background: #ef4444; }
|
||||||
|
|
||||||
|
/* Responsive para móviles */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dl-item.compact {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.dl-right-col { width: 100%; max-width: none; }
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,22 @@
|
|||||||
|
|
||||||
<div id="section-local" class="tab-section">
|
<div id="section-local" class="tab-section">
|
||||||
|
|
||||||
|
<div id="downloads-monitor" class="downloads-monitor">
|
||||||
|
<div class="monitor-header">
|
||||||
|
<div class="monitor-title">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
<h3>Download Manager</h3>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-stats">
|
||||||
|
<span id="dl-stat-active">0 Active</span>
|
||||||
|
<span class="divider">•</span>
|
||||||
|
<span id="dl-stat-failed">0 Failed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-list-container" class="monitor-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar local-toolbar">
|
<div class="toolbar local-toolbar">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user