feat: implement initial UI and core structure for cross-platform media downloader with localization support
This commit is contained in:
@@ -80,11 +80,11 @@ ipcMain.handle('get-video-info', async (event, { url, browser }) => {
|
||||
});
|
||||
|
||||
// IPC Handler: Download Media
|
||||
ipcMain.handle('download-media', async (event, { url, format, quality, browser }) => {
|
||||
ipcMain.handle('download-media', async (event, { items, browser }) => {
|
||||
try {
|
||||
const stats = await downloader.downloadMedia(url, format, quality, selectedOutputDir, browser, (percent, status) => {
|
||||
const stats = await downloader.downloadMedia(items, selectedOutputDir, browser, (progressData) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('download-progress', { percent, status });
|
||||
mainWindow.webContents.send('download-progress', progressData);
|
||||
}
|
||||
});
|
||||
return { success: true, stats };
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
checkDependencies: () => ipcRenderer.invoke('check-dependencies'),
|
||||
onDependencyStatus: (callback) => ipcRenderer.on('dependency-status', (_, data) => callback(data)),
|
||||
getVideoInfo: (url, browser) => ipcRenderer.invoke('get-video-info', { url, browser }),
|
||||
downloadMedia: (url, format, quality, browser) => ipcRenderer.invoke('download-media', { url, format, quality, browser }),
|
||||
downloadMedia: (items, browser) => ipcRenderer.invoke('download-media', { items, browser }),
|
||||
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_, data) => callback(data)),
|
||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||
getDownloadsFolder: () => ipcRenderer.invoke('get-downloads-folder'),
|
||||
|
||||
@@ -135,6 +135,23 @@
|
||||
|
||||
<!-- Scrollable Track List Area -->
|
||||
<div class="track-list-scroll">
|
||||
<!-- Playlist Bulk Actions and Select All Header -->
|
||||
<div id="playlist-header" class="playlist-header" style="display: none;">
|
||||
<div class="playlist-select-all">
|
||||
<input type="checkbox" id="select-all-checkbox" checked style="cursor: pointer; accent-color: #7000ff; width: 16px; height: 16px;">
|
||||
<label for="select-all-checkbox" id="select-all-label" data-i18n="playlist_select_all" style="cursor: pointer; color: #fff; font-size: 0.9em; font-weight: 500; user-select: none;">Tümünü Seç</label>
|
||||
</div>
|
||||
<div class="playlist-bulk-actions">
|
||||
<button id="bulk-mp3-btn" class="bulk-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
<span data-i18n="bulk_mp3">Tümünü MP3 Yap</span>
|
||||
</button>
|
||||
<button id="bulk-mp4-btn" class="bulk-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" /><line x1="7" y1="2" x2="7" y2="22" /><line x1="17" y1="2" x2="17" y2="22" /><line x1="2" y1="12" x2="22" y2="12" /></svg>
|
||||
<span data-i18n="bulk_mp4">Tümünü MP4 Yap</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="track-list" class="track-list">
|
||||
<!-- Dynamically populated by Javascript -->
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -55,5 +55,11 @@
|
||||
"quality_best": "Highest",
|
||||
"quality_standard": "Standard",
|
||||
"sec_suffix": "seconds",
|
||||
"min_suffix": "minutes"
|
||||
"min_suffix": "minutes",
|
||||
"playlist_select_all": "Select All",
|
||||
"bulk_mp3": "Bulk MP3",
|
||||
"bulk_mp4": "Bulk MP4",
|
||||
"err_no_items_selected": "Please select at least one file to download.",
|
||||
"playlist_title_label": "Playlist Title:",
|
||||
"downloaded_files_label": "Downloaded Files:"
|
||||
}
|
||||
|
||||
+7
-1
@@ -55,5 +55,11 @@
|
||||
"quality_best": "En Yüksek",
|
||||
"quality_standard": "Standart",
|
||||
"sec_suffix": "saniye",
|
||||
"min_suffix": "dakika"
|
||||
"min_suffix": "dakika",
|
||||
"playlist_select_all": "Tümünü Seç",
|
||||
"bulk_mp3": "Tümünü MP3 Yap",
|
||||
"bulk_mp4": "Tümünü MP4 Yap",
|
||||
"err_no_items_selected": "Lütfen indirmek için en az bir dosya seçin.",
|
||||
"playlist_title_label": "Çalma Listesi:",
|
||||
"downloaded_files_label": "İndirilen Dosyalar:"
|
||||
}
|
||||
|
||||
+334
-54
@@ -39,6 +39,15 @@ let currentFormat = 'mp3'; // 'mp3' or 'mp4'
|
||||
let currentQuality = '320'; // Selected quality value
|
||||
let currentVideoData = null;
|
||||
|
||||
// Playlist State Variables
|
||||
const playlistHeader = document.getElementById('playlist-header');
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const bulkMp3Btn = document.getElementById('bulk-mp3-btn');
|
||||
const bulkMp4Btn = document.getElementById('bulk-mp4-btn');
|
||||
let playlistTracks = [];
|
||||
let focusedTrackId = null;
|
||||
let isPlaylistMode = false;
|
||||
|
||||
// Localization State
|
||||
let currentLang = 'tr';
|
||||
let translations = {};
|
||||
@@ -134,6 +143,41 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadTranslations(e.target.value);
|
||||
});
|
||||
|
||||
// Select All Checkbox Handler
|
||||
selectAllCheckbox.addEventListener('change', () => {
|
||||
const checked = selectAllCheckbox.checked;
|
||||
playlistTracks.forEach(track => {
|
||||
track.checked = checked;
|
||||
});
|
||||
renderTrackList(currentVideoData);
|
||||
});
|
||||
|
||||
// Bulk MP3 Button Handler
|
||||
bulkMp3Btn.addEventListener('click', () => {
|
||||
playlistTracks.forEach(track => {
|
||||
if (track.checked) {
|
||||
track.format = 'mp3';
|
||||
track.quality = '320';
|
||||
}
|
||||
});
|
||||
const focusedTrack = playlistTracks.find(t => t.id === focusedTrackId);
|
||||
if (focusedTrack) syncBottomPanelWithTrack(focusedTrack);
|
||||
renderTrackList(currentVideoData);
|
||||
});
|
||||
|
||||
// Bulk MP4 Button Handler
|
||||
bulkMp4Btn.addEventListener('click', () => {
|
||||
playlistTracks.forEach(track => {
|
||||
if (track.checked) {
|
||||
track.format = 'mp4';
|
||||
track.quality = '1080';
|
||||
}
|
||||
});
|
||||
const focusedTrack = playlistTracks.find(t => t.id === focusedTrackId);
|
||||
if (focusedTrack) syncBottomPanelWithTrack(focusedTrack);
|
||||
renderTrackList(currentVideoData);
|
||||
});
|
||||
|
||||
// Get and load persisted or default language
|
||||
const savedLang = localStorage.getItem('vibe_lang') || 'tr';
|
||||
languageSelect.value = savedLang;
|
||||
@@ -195,6 +239,7 @@ formatMp3Btn.addEventListener('click', () => {
|
||||
formatMp4Btn.classList.remove('active');
|
||||
currentQuality = '320'; // Reset to default best quality for MP3
|
||||
renderQualityPills();
|
||||
applyBottomSettingsToSelected();
|
||||
});
|
||||
|
||||
formatMp4Btn.addEventListener('click', () => {
|
||||
@@ -204,6 +249,7 @@ formatMp4Btn.addEventListener('click', () => {
|
||||
formatMp3Btn.classList.remove('active');
|
||||
currentQuality = '1080'; // Reset to default best quality for MP4
|
||||
renderQualityPills();
|
||||
applyBottomSettingsToSelected();
|
||||
});
|
||||
|
||||
// 4. Quality Pills Renderer
|
||||
@@ -225,12 +271,50 @@ function renderQualityPills() {
|
||||
document.querySelectorAll('.quality-pill').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
currentQuality = opt.value;
|
||||
applyBottomSettingsToSelected();
|
||||
});
|
||||
|
||||
qualityPillsContainer.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
function syncBottomPanelWithTrack(track) {
|
||||
currentFormat = track.format;
|
||||
currentQuality = track.quality;
|
||||
|
||||
if (currentFormat === 'mp3') {
|
||||
formatMp3Btn.classList.add('active');
|
||||
formatMp4Btn.classList.remove('active');
|
||||
} else {
|
||||
formatMp4Btn.classList.add('active');
|
||||
formatMp3Btn.classList.remove('active');
|
||||
}
|
||||
renderQualityPills();
|
||||
}
|
||||
|
||||
function applyBottomSettingsToSelected() {
|
||||
if (!currentVideoData) return;
|
||||
const checkedTracks = playlistTracks.filter(t => t.checked);
|
||||
if (checkedTracks.length > 0) {
|
||||
checkedTracks.forEach(track => {
|
||||
track.format = currentFormat;
|
||||
track.quality = currentQuality;
|
||||
});
|
||||
} else if (focusedTrackId) {
|
||||
const focusedTrack = playlistTracks.find(t => t.id === focusedTrackId);
|
||||
if (focusedTrack) {
|
||||
focusedTrack.format = currentFormat;
|
||||
focusedTrack.quality = currentQuality;
|
||||
}
|
||||
}
|
||||
renderTrackList(currentVideoData);
|
||||
}
|
||||
|
||||
function updateSelectAllCheckboxState() {
|
||||
const checkedCount = playlistTracks.filter(t => t.checked).length;
|
||||
selectAllCheckbox.checked = checkedCount === playlistTracks.length;
|
||||
}
|
||||
|
||||
// 5. Back Button (Step 2 to Step 1 Transition)
|
||||
backBtn.addEventListener('click', () => {
|
||||
// Reset any error card
|
||||
@@ -239,6 +323,8 @@ backBtn.addEventListener('click', () => {
|
||||
step2.classList.remove('active');
|
||||
step1.classList.add('active');
|
||||
currentVideoData = null;
|
||||
playlistTracks = [];
|
||||
focusedTrackId = null;
|
||||
urlInput.focus();
|
||||
});
|
||||
|
||||
@@ -263,6 +349,8 @@ analyzeBtn.addEventListener('click', async () => {
|
||||
if (result.success) {
|
||||
analyzedUrl = url;
|
||||
currentVideoData = result.info;
|
||||
playlistTracks = [];
|
||||
focusedTrackId = null;
|
||||
|
||||
// Render the fetched video details inside the scrollable list container
|
||||
renderTrackList(result.info);
|
||||
@@ -284,30 +372,174 @@ analyzeBtn.addEventListener('click', async () => {
|
||||
|
||||
// 7. Render Track List Function
|
||||
function renderTrackList(info) {
|
||||
trackList.innerHTML = `
|
||||
<div class="track-item">
|
||||
<div class="track-thumb">
|
||||
trackList.innerHTML = '';
|
||||
|
||||
if (info.isPlaylist) {
|
||||
isPlaylistMode = true;
|
||||
playlistHeader.style.display = 'flex';
|
||||
} else {
|
||||
isPlaylistMode = false;
|
||||
playlistHeader.style.display = 'none';
|
||||
}
|
||||
|
||||
if (info.entries) {
|
||||
info.entries.forEach(entry => {
|
||||
const existing = playlistTracks.find(t => t.id === entry.id);
|
||||
if (!existing) {
|
||||
playlistTracks.push({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
thumbnail: entry.thumbnail,
|
||||
duration: entry.duration,
|
||||
uploader: entry.uploader,
|
||||
url: entry.url,
|
||||
format: currentFormat,
|
||||
quality: currentQuality,
|
||||
status: 'ready',
|
||||
progress: 0,
|
||||
checked: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (playlistTracks.length > 0 && !focusedTrackId) {
|
||||
focusedTrackId = playlistTracks[0].id;
|
||||
}
|
||||
|
||||
const focusedTrack = playlistTracks.find(t => t.id === focusedTrackId);
|
||||
if (focusedTrack) {
|
||||
syncBottomPanelWithTrack(focusedTrack);
|
||||
}
|
||||
|
||||
playlistTracks.forEach(track => {
|
||||
const itemEl = document.createElement('div');
|
||||
itemEl.className = `track-item ${track.id === focusedTrackId ? 'focused' : ''}`;
|
||||
itemEl.dataset.id = track.id;
|
||||
|
||||
const checkboxWrapper = document.createElement('div');
|
||||
checkboxWrapper.className = 'track-checkbox-wrapper';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'track-checkbox';
|
||||
checkbox.checked = track.checked;
|
||||
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
track.checked = checkbox.checked;
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
|
||||
checkboxWrapper.appendChild(checkbox);
|
||||
itemEl.appendChild(checkboxWrapper);
|
||||
|
||||
const thumbEl = document.createElement('div');
|
||||
thumbEl.className = 'track-thumb';
|
||||
thumbEl.innerHTML = `
|
||||
<svg class="track-thumb-fallback" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
<img src="${info.thumbnail || ''}" onerror="this.style.display='none';" alt="Thumbnail">
|
||||
</div>
|
||||
<div class="track-details">
|
||||
<h4>${info.title}</h4>
|
||||
<p class="track-uploader">${info.uploader}</p>
|
||||
<span class="track-duration">${t('duration_label')} ${info.duration || t('duration_unknown')}</span>
|
||||
</div>
|
||||
<div class="track-status">
|
||||
<span id="track-status-badge" class="status-badge ready">${t('badge_ready')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<img src="${track.thumbnail || ''}" onerror="this.style.display='none';" alt="Thumbnail">
|
||||
`;
|
||||
itemEl.appendChild(thumbEl);
|
||||
|
||||
const detailsEl = document.createElement('div');
|
||||
detailsEl.className = 'track-details';
|
||||
detailsEl.innerHTML = `
|
||||
<h4>${track.title}</h4>
|
||||
<p class="track-uploader">${track.uploader}</p>
|
||||
<span class="track-duration">${t('duration_label')} ${track.duration || t('duration_unknown')}</span>
|
||||
`;
|
||||
itemEl.appendChild(detailsEl);
|
||||
|
||||
const formatSelectorEl = document.createElement('div');
|
||||
formatSelectorEl.className = 'track-format-selector';
|
||||
|
||||
const formatBadgeBtn = document.createElement('button');
|
||||
formatBadgeBtn.className = `format-badge-btn ${track.format}`;
|
||||
formatBadgeBtn.textContent = track.format.toUpperCase();
|
||||
|
||||
formatBadgeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
track.format = track.format === 'mp3' ? 'mp4' : 'mp3';
|
||||
track.quality = track.format === 'mp3' ? '320' : '1080';
|
||||
|
||||
if (track.id === focusedTrackId) {
|
||||
syncBottomPanelWithTrack(track);
|
||||
}
|
||||
|
||||
renderTrackList(currentVideoData);
|
||||
});
|
||||
|
||||
formatSelectorEl.appendChild(formatBadgeBtn);
|
||||
itemEl.appendChild(formatSelectorEl);
|
||||
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.className = 'track-status';
|
||||
|
||||
let statusText = t('badge_ready');
|
||||
let badgeClass = 'ready';
|
||||
|
||||
if (track.status === 'downloading') {
|
||||
statusText = track.progress > 0 ? `${t('badge_downloading')} %${track.progress}` : t('badge_downloading');
|
||||
badgeClass = 'downloading';
|
||||
} else if (track.status === 'completed') {
|
||||
statusText = t('badge_completed');
|
||||
badgeClass = 'completed';
|
||||
} else if (track.status === 'error') {
|
||||
statusText = t('badge_error');
|
||||
badgeClass = 'error';
|
||||
} else if (track.status === 'waiting') {
|
||||
statusText = t('status_connecting');
|
||||
badgeClass = 'ready';
|
||||
}
|
||||
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.id = `badge-${track.id}`;
|
||||
statusBadge.className = `status-badge ${badgeClass}`;
|
||||
|
||||
if (track.status === 'completed') {
|
||||
statusBadge.style.borderColor = 'rgba(0, 255, 135, 0.3)';
|
||||
statusBadge.style.color = '#00ff87';
|
||||
statusBadge.style.background = 'rgba(0, 255, 135, 0.1)';
|
||||
} else if (track.status === 'error') {
|
||||
statusBadge.style.borderColor = 'rgba(255, 77, 77, 0.3)';
|
||||
statusBadge.style.color = '#ff4d4d';
|
||||
statusBadge.style.background = 'rgba(255, 77, 77, 0.1)';
|
||||
}
|
||||
|
||||
statusBadge.textContent = statusText;
|
||||
statusEl.appendChild(statusBadge);
|
||||
itemEl.appendChild(statusEl);
|
||||
|
||||
itemEl.addEventListener('click', () => {
|
||||
focusedTrackId = track.id;
|
||||
syncBottomPanelWithTrack(track);
|
||||
renderTrackList(currentVideoData);
|
||||
});
|
||||
|
||||
trackList.appendChild(itemEl);
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Media Download Executor
|
||||
downloadBtn.addEventListener('click', async () => {
|
||||
if (!analyzedUrl) return;
|
||||
if (!analyzedUrl || playlistTracks.length === 0) return;
|
||||
|
||||
const selectedItems = playlistTracks.filter(t => t.checked);
|
||||
if (selectedItems.length === 0) {
|
||||
Swal.fire({
|
||||
title: t('badge_error'),
|
||||
text: t('err_no_items_selected') || 'Lütfen indirmek için en az bir dosya seçin.',
|
||||
icon: 'warning',
|
||||
background: 'rgba(23, 20, 38, 0.95)',
|
||||
color: '#fff',
|
||||
confirmButtonColor: '#7000ff'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup download progress panel UI states
|
||||
downloadProgressPanel.style.display = 'block';
|
||||
@@ -316,59 +548,112 @@ downloadBtn.addEventListener('click', async () => {
|
||||
downloadStatus.textContent = t('status_connecting');
|
||||
downloadPercent.textContent = '0%';
|
||||
downloadProgressBar.style.width = '0%';
|
||||
downloadSubtext.textContent = currentFormat === 'mp3' ? t('subtext_mp3_download') : t('subtext_mp4_download');
|
||||
downloadSubtext.textContent = t('subtext_wait');
|
||||
|
||||
// Set track status badge as downloading
|
||||
const statusBadge = document.getElementById('track-status-badge');
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = t('badge_downloading');
|
||||
statusBadge.className = 'status-badge downloading';
|
||||
// Disable UI interactive components during download
|
||||
document.querySelectorAll('.track-checkbox, .format-badge-btn, .quality-pill, .format-btn').forEach(el => {
|
||||
el.disabled = true;
|
||||
el.style.pointerEvents = 'none';
|
||||
});
|
||||
|
||||
// Mark selected items as waiting
|
||||
selectedItems.forEach(track => {
|
||||
track.status = 'waiting';
|
||||
track.progress = 0;
|
||||
|
||||
const badge = document.getElementById(`badge-${track.id}`);
|
||||
if (badge) {
|
||||
badge.textContent = t('status_connecting');
|
||||
badge.className = 'status-badge ready';
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to progress events from the main process
|
||||
window.api.onDownloadProgress(({ percent, status }) => {
|
||||
window.api.onDownloadProgress(({ itemId, index, total, percent, status, title, failed }) => {
|
||||
const track = playlistTracks.find(t => t.id === itemId);
|
||||
if (track) {
|
||||
if (percent === 100) {
|
||||
track.status = 'completed';
|
||||
track.progress = 100;
|
||||
} else if (failed) {
|
||||
track.status = 'error';
|
||||
} else {
|
||||
track.status = 'downloading';
|
||||
track.progress = percent;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic row updates
|
||||
const badge = document.getElementById(`badge-${itemId}`);
|
||||
if (badge) {
|
||||
if (percent === 100) {
|
||||
badge.textContent = t('badge_completed');
|
||||
badge.className = 'status-badge completed';
|
||||
badge.style.borderColor = 'rgba(0, 255, 135, 0.3)';
|
||||
badge.style.color = '#00ff87';
|
||||
badge.style.background = 'rgba(0, 255, 135, 0.1)';
|
||||
} else if (failed) {
|
||||
badge.textContent = t('badge_error');
|
||||
badge.className = 'status-badge error';
|
||||
badge.style.borderColor = 'rgba(255, 77, 77, 0.3)';
|
||||
badge.style.color = '#ff4d4d';
|
||||
badge.style.background = 'rgba(255, 77, 77, 0.1)';
|
||||
} else {
|
||||
badge.textContent = `${t('badge_downloading')} %${percent}`;
|
||||
badge.className = 'status-badge downloading';
|
||||
}
|
||||
}
|
||||
|
||||
// Update main footer progress panel
|
||||
let stat = status;
|
||||
if (status.includes('İndiriliyor')) {
|
||||
stat = t('status_downloading', { percent });
|
||||
stat = `${t('btn_downloading')}... %${percent}`;
|
||||
} else if (status.includes('İşleniyor') || status.includes('Dönüştürülüyor')) {
|
||||
stat = t('status_processing');
|
||||
}
|
||||
|
||||
downloadStatus.textContent = stat;
|
||||
downloadStatus.textContent = `[${index + 1}/${total}] ${title}`;
|
||||
downloadPercent.textContent = `${percent}%`;
|
||||
downloadProgressBar.style.width = `${percent}%`;
|
||||
|
||||
if (percent > 90) {
|
||||
downloadSubtext.textContent = currentFormat === 'mp3'
|
||||
? t('subtext_mp3_convert')
|
||||
: t('subtext_mp4_merge');
|
||||
} else {
|
||||
downloadSubtext.textContent = t('subtext_speed_note');
|
||||
}
|
||||
downloadSubtext.textContent = `${t('swal_file_size')}: ${stat}`;
|
||||
});
|
||||
|
||||
try {
|
||||
const browser = useCookiesCheckbox.checked ? cookieBrowserSelect.value : null;
|
||||
const result = await window.api.downloadMedia(analyzedUrl, currentFormat, currentQuality, browser);
|
||||
|
||||
// Map selected items into a simplified API task schema
|
||||
const downloadTasks = selectedItems.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
url: t.url,
|
||||
format: t.format,
|
||||
quality: t.quality
|
||||
}));
|
||||
|
||||
const result = await window.api.downloadMedia(downloadTasks, browser);
|
||||
|
||||
if (result.success) {
|
||||
downloadStatus.textContent = t('status_completed');
|
||||
downloadPercent.textContent = '100%';
|
||||
downloadProgressBar.style.width = '100%';
|
||||
downloadSubtext.textContent = t('subtext_success');
|
||||
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = t('badge_completed');
|
||||
statusBadge.className = 'status-badge ready';
|
||||
statusBadge.style.borderColor = 'rgba(0, 255, 135, 0.3)';
|
||||
statusBadge.style.color = '#00ff87';
|
||||
statusBadge.style.background = 'rgba(0, 255, 135, 0.1)';
|
||||
}
|
||||
|
||||
// Success gradient transitions
|
||||
downloadProgressBar.style.background = 'linear-gradient(90deg, #00ff87 0%, #60efff 100%)';
|
||||
downloadProgressBar.style.boxShadow = '0 0 15px rgba(0, 255, 135, 0.6)';
|
||||
|
||||
// Translate duration to localized representation
|
||||
const displayTitle = isPlaylistMode
|
||||
? `${currentVideoData.playlistTitle || 'Playlist'} (${result.stats.completed}/${result.stats.totalItems})`
|
||||
: selectedItems[0].title;
|
||||
|
||||
const displayUploader = isPlaylistMode
|
||||
? 'Playlist Queue'
|
||||
: (selectedItems[0].uploader || t('duration_unknown'));
|
||||
|
||||
const displaySize = isPlaylistMode
|
||||
? `${result.stats.completed} ${t('badge_completed').toLowerCase()}`
|
||||
: (result.stats.results[0]?.size || 'Bilinmiyor');
|
||||
|
||||
const localizedDuration = result.stats.duration
|
||||
.replace('saniye', t('sec_suffix'))
|
||||
.replace('dakika', t('min_suffix'));
|
||||
@@ -378,11 +663,10 @@ downloadBtn.addEventListener('click', async () => {
|
||||
title: t('swal_success_title'),
|
||||
html: `
|
||||
<div style="text-align: ${currentLang === 'ar' ? 'right' : 'left'}; direction: ${currentLang === 'ar' ? 'rtl' : 'ltr'}; font-family: 'Outfit', sans-serif; color: #fff; line-height: 1.6; font-size: 14px;">
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_file_name')}</strong> <span style="color: #60efff;">${currentVideoData.title}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_platform_uploader')}</strong> <span style="color: #60efff;">${currentVideoData.uploader || t('duration_unknown')}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_file_size')}</strong> <span style="color: #00ff87;">${result.stats.size}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${isPlaylistMode ? t('playlist_title_label') || 'Çalma Listesi:' : t('swal_file_name')}</strong> <span style="color: #60efff;">${displayTitle}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_platform_uploader')}</strong> <span style="color: #60efff;">${displayUploader}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${isPlaylistMode ? t('downloaded_files_label') || 'İndirilen Dosyalar:' : t('swal_file_size')}</strong> <span style="color: #00ff87;">${displaySize}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_duration')}</strong> <span style="color: #00ff87;">${localizedDuration}</span></p>
|
||||
<p style="margin-bottom: 8px;"><strong>${t('swal_format_quality')}</strong> <span style="color: #ff4da6; text-transform: uppercase;">${currentFormat} (${currentQuality === '320' || currentQuality === '1080' ? t('quality_best') : currentQuality})</span></p>
|
||||
<p style="margin-top: 15px; font-size: 12px; opacity: 0.8; word-break: break-all;"><strong>${t('swal_output_dir')}</strong> <br>${result.stats.outputDir}</p>
|
||||
</div>
|
||||
`,
|
||||
@@ -399,7 +683,6 @@ downloadBtn.addEventListener('click', async () => {
|
||||
if (swalResult.isConfirmed) {
|
||||
window.api.openPath(result.stats.outputDir);
|
||||
} else {
|
||||
// Go back to step 1
|
||||
backBtn.click();
|
||||
urlInput.value = '';
|
||||
downloadProgressPanel.style.display = 'none';
|
||||
@@ -408,13 +691,6 @@ downloadBtn.addEventListener('click', async () => {
|
||||
} else {
|
||||
showError(result.error);
|
||||
downloadProgressPanel.style.display = 'none';
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = t('badge_error');
|
||||
statusBadge.className = 'status-badge ready';
|
||||
statusBadge.style.borderColor = 'rgba(255, 77, 77, 0.3)';
|
||||
statusBadge.style.color = '#ff4d4d';
|
||||
statusBadge.style.background = 'rgba(255, 77, 77, 0.1)';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
@@ -422,6 +698,10 @@ downloadBtn.addEventListener('click', async () => {
|
||||
} finally {
|
||||
downloadBtn.disabled = false;
|
||||
backBtn.disabled = false;
|
||||
document.querySelectorAll('.track-checkbox, .format-badge-btn, .quality-pill, .format-btn').forEach(el => {
|
||||
el.disabled = false;
|
||||
el.style.pointerEvents = 'auto';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+141
@@ -899,3 +899,144 @@ html[dir="rtl"] .error-card {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Playlist Header Controls */
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.playlist-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playlist-bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-action-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
color: #dcd7eb;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bulk-action-btn:hover {
|
||||
background: rgba(255, 0, 128, 0.1);
|
||||
border-color: rgba(255, 0, 128, 0.3);
|
||||
color: #ff0080;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Premium Playlist Track Selection & Layout */
|
||||
.track-checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #7000ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Make track item interactive and clickable */
|
||||
.track-item {
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Highlight active/focused track */
|
||||
.track-item.focused {
|
||||
background: rgba(112, 0, 255, 0.08);
|
||||
border-color: rgba(112, 0, 255, 0.4);
|
||||
box-shadow: 0 0 15px rgba(112, 0, 255, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Custom interactive format switcher badge inside row */
|
||||
.track-format-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.format-badge-btn {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #9e97b8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.format-badge-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.format-badge-btn.mp3 {
|
||||
background: rgba(255, 0, 128, 0.1);
|
||||
color: #ff0080;
|
||||
border-color: rgba(255, 0, 128, 0.3);
|
||||
}
|
||||
|
||||
.format-badge-btn.mp4 {
|
||||
background: rgba(0, 223, 216, 0.1);
|
||||
color: #00dfd8;
|
||||
border-color: rgba(0, 223, 216, 0.3);
|
||||
}
|
||||
|
||||
/* Disabled states during downloading */
|
||||
.track-item.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .playlist-select-all {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .bulk-action-btn {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .track-format-selector {
|
||||
margin-right: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+145
-19
@@ -168,11 +168,22 @@ async function checkDependencies(onStatusUpdate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (!sec) return '';
|
||||
const hrs = Math.floor(sec / 3600);
|
||||
const mins = Math.floor((sec % 3600) / 60);
|
||||
const secs = Math.floor(sec % 60);
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Get video metadata (title, thumbnail, duration, etc.)
|
||||
function getVideoInfo(url, browser) {
|
||||
console.log(`[DEBUG] Fetching info for URL: ${url}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let args = ['--no-playlist', '-j'];
|
||||
let args = ['-J', '--flat-playlist'];
|
||||
if (browser) {
|
||||
args.push('--cookies-from-browser', browser);
|
||||
}
|
||||
@@ -190,18 +201,56 @@ function getVideoInfo(url, browser) {
|
||||
console.log(`[DEBUG] yt-dlp info process exited with code ${code}`);
|
||||
if (code !== 0) {
|
||||
console.error(`[DEBUG] yt-dlp error output:`, stderr);
|
||||
reject(new Error(stderr || 'Failed to fetch video details'));
|
||||
reject(new Error(stderr || 'Failed to fetch details'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = JSON.parse(stdout);
|
||||
console.log(`[DEBUG] Metadata fetched successfully for: "${info.title}"`);
|
||||
resolve({
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration_string,
|
||||
uploader: info.uploader
|
||||
|
||||
if (info._type === 'playlist' || info.entries) {
|
||||
console.log(`[DEBUG] Playlist metadata fetched successfully for: "${info.title}" (${info.entries.length} items)`);
|
||||
const entries = info.entries.map((entry, index) => {
|
||||
let thumb = '';
|
||||
if (entry.thumbnail) thumb = entry.thumbnail;
|
||||
else if (entry.thumbnails && entry.thumbnails.length > 0) thumb = entry.thumbnails[0].url;
|
||||
|
||||
let durStr = '';
|
||||
if (entry.duration_string) durStr = entry.duration_string;
|
||||
else if (entry.duration) durStr = formatDuration(entry.duration);
|
||||
|
||||
return {
|
||||
id: entry.id || `track-${index}`,
|
||||
title: entry.title || `Track ${index + 1}`,
|
||||
thumbnail: thumb,
|
||||
duration: durStr,
|
||||
uploader: entry.uploader || info.uploader || 'Playlist',
|
||||
url: entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||
};
|
||||
});
|
||||
|
||||
resolve({
|
||||
isPlaylist: true,
|
||||
playlistTitle: info.title,
|
||||
entries: entries
|
||||
});
|
||||
} else {
|
||||
console.log(`[DEBUG] Single video metadata fetched successfully for: "${info.title}"`);
|
||||
let thumb = '';
|
||||
if (info.thumbnail) thumb = info.thumbnail;
|
||||
else if (info.thumbnails && info.thumbnails.length > 0) thumb = info.thumbnails[0].url;
|
||||
|
||||
resolve({
|
||||
isPlaylist: false,
|
||||
entries: [{
|
||||
id: info.id || 'video',
|
||||
title: info.title,
|
||||
thumbnail: thumb,
|
||||
duration: info.duration_string || (info.duration ? formatDuration(info.duration) : ''),
|
||||
uploader: info.uploader || 'Bilinmiyor',
|
||||
url: url
|
||||
}]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DEBUG] JSON parsing error on yt-dlp output:`, err);
|
||||
reject(err);
|
||||
@@ -210,9 +259,8 @@ function getVideoInfo(url, browser) {
|
||||
});
|
||||
}
|
||||
|
||||
// Download video or convert to MP3
|
||||
function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
console.log(`[DEBUG] Media download requested. Format: ${format}, Quality: ${quality}, Target Dir: ${outputDir}, Browser Cookies: ${browser || 'none'}`);
|
||||
// Download single item helper
|
||||
function downloadSingle(url, format, quality, outputDir, browser, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let args = [];
|
||||
const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s');
|
||||
@@ -220,7 +268,7 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
let totalSize = 'Bilinmiyor';
|
||||
|
||||
if (format === 'mp3') {
|
||||
const q = quality ? `${quality}K` : '320K'; // '320K', '256K', '192K'
|
||||
const q = quality ? `${quality}K` : '320K';
|
||||
args = [
|
||||
'--no-playlist',
|
||||
'--ffmpeg-location', FFMPEG_PATH,
|
||||
@@ -230,7 +278,7 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
'-o', outputTemplate
|
||||
];
|
||||
} else {
|
||||
const res = quality || '1080'; // '1080', '720', '480'
|
||||
const res = quality || '1080';
|
||||
args = [
|
||||
'--no-playlist',
|
||||
'--ffmpeg-location', FFMPEG_PATH,
|
||||
@@ -244,13 +292,12 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
}
|
||||
args.push(url);
|
||||
|
||||
console.log(`[DEBUG] Spawning downloader process: ${YTDLP_PATH} ${args.join(' ')}`);
|
||||
console.log(`[DEBUG] Spawning single downloader process: ${YTDLP_PATH} ${args.join(' ')}`);
|
||||
const proc = spawn(YTDLP_PATH, args);
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
const line = data.toString();
|
||||
|
||||
// Parse total size: e.g. [download] 1.2% of 8.37MiB at...
|
||||
const sizeMatch = line.match(/of\s+(\d+(?:\.\d+)?\s*[a-zA-Z]+)/);
|
||||
if (sizeMatch) {
|
||||
totalSize = sizeMatch[1];
|
||||
@@ -261,7 +308,6 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
const percent = parseFloat(match[1]);
|
||||
onProgress(percent, `İndiriliyor... %${percent}`);
|
||||
} else if (line.includes('[ExtractAudio]') || line.includes('[Merger]')) {
|
||||
console.log('[DEBUG] Audio extraction or Video merger phase started.');
|
||||
onProgress(95, 'İşleniyor/Dönüştürülüyor...');
|
||||
}
|
||||
});
|
||||
@@ -271,13 +317,12 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
console.log(`[DEBUG] Downloader process exited with code ${code}`);
|
||||
console.log(`[DEBUG] Single downloader process exited with code ${code}`);
|
||||
if (code === 0) {
|
||||
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
resolve({
|
||||
size: totalSize,
|
||||
duration: `${durationSec} saniye`,
|
||||
outputDir: outputDir
|
||||
duration: `${durationSec} saniye`
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`Download exited with code ${code}`));
|
||||
@@ -286,6 +331,87 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
|
||||
});
|
||||
}
|
||||
|
||||
// Download list of items sequentially
|
||||
async function downloadMedia(items, outputDir, browser, onProgress) {
|
||||
console.log(`[DEBUG] Media download requested for ${items.length} items.`);
|
||||
const results = [];
|
||||
const startTime = Date.now();
|
||||
let completedCount = 0;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
console.log(`[DEBUG] Queue processing [${i+1}/${items.length}]: "${item.title}"`);
|
||||
|
||||
// Notify start of this item
|
||||
onProgress({
|
||||
itemId: item.id,
|
||||
index: i,
|
||||
total: items.length,
|
||||
percent: 0,
|
||||
status: `İndiriliyor...`,
|
||||
title: item.title
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await downloadSingle(item.url, item.format, item.quality, outputDir, browser, (percent, status) => {
|
||||
onProgress({
|
||||
itemId: item.id,
|
||||
index: i,
|
||||
total: items.length,
|
||||
percent: percent,
|
||||
status: status,
|
||||
title: item.title
|
||||
});
|
||||
});
|
||||
|
||||
completedCount++;
|
||||
results.push({
|
||||
success: true,
|
||||
item,
|
||||
size: result.size,
|
||||
duration: result.duration
|
||||
});
|
||||
|
||||
// Notify completion of this item
|
||||
onProgress({
|
||||
itemId: item.id,
|
||||
index: i,
|
||||
total: items.length,
|
||||
percent: 100,
|
||||
status: `Tamamlandı`,
|
||||
title: item.title
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[DEBUG] Queue error on item "${item.title}":`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
item,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
onProgress({
|
||||
itemId: item.id,
|
||||
index: i,
|
||||
total: items.length,
|
||||
percent: 0,
|
||||
status: `Hata`,
|
||||
title: item.title,
|
||||
failed: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
return {
|
||||
totalItems: items.length,
|
||||
completed: completedCount,
|
||||
duration: `${durationSec} saniye`,
|
||||
outputDir: outputDir,
|
||||
results: results
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkDependencies,
|
||||
getVideoInfo,
|
||||
|
||||
Reference in New Issue
Block a user