added download monitor

This commit is contained in:
2026-01-06 19:26:06 +01:00
parent ba05e08e71
commit 8296e8d7da
12 changed files with 616 additions and 30 deletions

View File

@@ -23,6 +23,8 @@ type DownloadStatus = {
error?: string;
startedAt: number;
completedAt?: number;
folderName?: string;
fileName?: string;
};
const activeDownloads = new Map<string, DownloadStatus>();
@@ -50,6 +52,7 @@ type AnimeDownloadParams = {
quality?: string;
subtitles?: Array<{ language: string; url: string }>;
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
totalDuration?: number;
};
type BookDownloadParams = {
@@ -137,10 +140,12 @@ async function getOrCreateEntry(
}
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();
activeDownloads.set(downloadId, {
id: downloadId,
type: 'anime',
@@ -148,11 +153,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
unitNumber: episodeNumber,
status: 'pending',
progress: 0,
startedAt: Date.now()
startedAt: Date.now(),
folderName: entry.folderName,
fileName: fileName
});
const entry = await getOrCreateEntry(anilistId, 'anime');
const exists = await queryOne(
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[entry.id, episodeNumber],
@@ -314,11 +319,21 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
const speedMatch = text.match(/speed=(\S+)/);
if (timeMatch || speedMatch) {
updateDownloadProgress(downloadId, {
timeElapsed: timeMatch?.[1],
speed: speedMatch?.[1]
});
const updates: any = {};
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));
@@ -381,6 +396,12 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
export async function downloadBookChapter(params: BookDownloadParams) {
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();
activeDownloads.set(downloadId, {
@@ -390,12 +411,11 @@ export async function downloadBookChapter(params: BookDownloadParams) {
unitNumber: chapterNumber,
status: 'pending',
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(
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[entry.id, chapterNumber],
@@ -532,3 +552,14 @@ ${content}
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;
}

View File

@@ -28,6 +28,7 @@ type DownloadAnimeBody =
language: string;
url: string;
}[];
duration?: number;
chapters?: {
title: string;
start_time: number;
@@ -38,6 +39,7 @@ type DownloadAnimeBody =
anilist_id: number;
episode_number: number;
stream_url: string;
duration?: number;
is_master: true;
variant: {
resolution: string;
@@ -256,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
anilist_id,
episode_number,
stream_url,
duration,
is_master,
subtitles,
chapters
@@ -283,7 +286,8 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
episodeNumber: episode_number,
streamUrl: proxyUrl,
subtitles: proxiedSubs,
chapters
chapters,
totalDuration: duration
};
if (is_master === true) {

View File

@@ -1779,11 +1779,19 @@ const AnimePlayer = (function() {
btn.disabled = true;
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 = {
anilist_id: parseInt(_animeId),
episode_number: parseInt(_currentEpisode),
stream_url: _rawVideoData.url,
headers: _rawVideoData.headers || {},
duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN
chapters: _skipIntervals.map(i => ({
title: i.type === 'op' ? 'Opening' : 'Ending',
start_time: i.startTime,

View File

@@ -388,6 +388,95 @@ const DashboardApp = {
Library: {
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() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
@@ -584,6 +673,7 @@ const DashboardApp = {
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Gestión de clases activas
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
@@ -593,9 +683,13 @@ const DashboardApp = {
if (sec.id === targetId) sec.classList.add('active');
});
// Lógica específica por pestaña
if (tab.dataset.target === 'local') {
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
}
});
});

View File

@@ -796,3 +796,143 @@
justify-content: flex-end;
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; }
}

View File

@@ -97,6 +97,22 @@
<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="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>

View File

@@ -23,6 +23,8 @@ type DownloadStatus = {
error?: string;
startedAt: number;
completedAt?: number;
folderName?: string;
fileName?: string;
};
const activeDownloads = new Map<string, DownloadStatus>();
@@ -50,6 +52,7 @@ type AnimeDownloadParams = {
quality?: string;
subtitles?: Array<{ language: string; url: string }>;
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
totalDuration?: number;
};
type BookDownloadParams = {
@@ -137,10 +140,12 @@ async function getOrCreateEntry(
}
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();
activeDownloads.set(downloadId, {
id: downloadId,
type: 'anime',
@@ -148,11 +153,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
unitNumber: episodeNumber,
status: 'pending',
progress: 0,
startedAt: Date.now()
startedAt: Date.now(),
folderName: entry.folderName,
fileName: fileName
});
const entry = await getOrCreateEntry(anilistId, 'anime');
const exists = await queryOne(
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[entry.id, episodeNumber],
@@ -314,11 +319,21 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
const speedMatch = text.match(/speed=(\S+)/);
if (timeMatch || speedMatch) {
updateDownloadProgress(downloadId, {
timeElapsed: timeMatch?.[1],
speed: speedMatch?.[1]
});
const updates: any = {};
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));
@@ -381,6 +396,12 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
export async function downloadBookChapter(params: BookDownloadParams) {
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();
activeDownloads.set(downloadId, {
@@ -390,12 +411,11 @@ export async function downloadBookChapter(params: BookDownloadParams) {
unitNumber: chapterNumber,
status: 'pending',
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(
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[entry.id, chapterNumber],
@@ -532,3 +552,14 @@ ${content}
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;
}

View File

@@ -28,6 +28,7 @@ type DownloadAnimeBody =
language: string;
url: string;
}[];
duration?: number;
chapters?: {
title: string;
start_time: number;
@@ -38,6 +39,7 @@ type DownloadAnimeBody =
anilist_id: number;
episode_number: number;
stream_url: string;
duration?: number;
is_master: true;
variant: {
resolution: string;
@@ -256,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
anilist_id,
episode_number,
stream_url,
duration,
is_master,
subtitles,
chapters
@@ -283,7 +286,8 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
episodeNumber: episode_number,
streamUrl: proxyUrl,
subtitles: proxiedSubs,
chapters
chapters,
totalDuration: duration
};
if (is_master === true) {

View File

@@ -1779,11 +1779,19 @@ const AnimePlayer = (function() {
btn.disabled = true;
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 = {
anilist_id: parseInt(_animeId),
episode_number: parseInt(_currentEpisode),
stream_url: _rawVideoData.url,
headers: _rawVideoData.headers || {},
duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN
chapters: _skipIntervals.map(i => ({
title: i.type === 'op' ? 'Opening' : 'Ending',
start_time: i.startTime,

View File

@@ -388,6 +388,95 @@ const DashboardApp = {
Library: {
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() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
@@ -584,6 +673,7 @@ const DashboardApp = {
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Gestión de clases activas
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
@@ -593,9 +683,13 @@ const DashboardApp = {
if (sec.id === targetId) sec.classList.add('active');
});
// Lógica específica por pestaña
if (tab.dataset.target === 'local') {
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
}
});
});

View File

@@ -796,3 +796,143 @@
justify-content: flex-end;
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; }
}

View File

@@ -84,6 +84,22 @@
<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="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>