feat: implement initial UI and core structure for cross-platform media downloader with localization support

This commit is contained in:
Ümit Tunç
2026-05-28 07:33:41 +03:00
parent 67d833d7d7
commit 25b6d693ae
8 changed files with 662 additions and 86 deletions
+3 -3
View File
@@ -80,11 +80,11 @@ ipcMain.handle('get-video-info', async (event, { url, browser }) => {
}); });
// IPC Handler: Download Media // IPC Handler: Download Media
ipcMain.handle('download-media', async (event, { url, format, quality, browser }) => { ipcMain.handle('download-media', async (event, { items, browser }) => {
try { try {
const stats = await downloader.downloadMedia(url, format, quality, selectedOutputDir, browser, (percent, status) => { const stats = await downloader.downloadMedia(items, selectedOutputDir, browser, (progressData) => {
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.send('download-progress', { percent, status }); mainWindow.webContents.send('download-progress', progressData);
} }
}); });
return { success: true, stats }; return { success: true, stats };
+1 -1
View File
@@ -4,7 +4,7 @@ contextBridge.exposeInMainWorld('api', {
checkDependencies: () => ipcRenderer.invoke('check-dependencies'), checkDependencies: () => ipcRenderer.invoke('check-dependencies'),
onDependencyStatus: (callback) => ipcRenderer.on('dependency-status', (_, data) => callback(data)), onDependencyStatus: (callback) => ipcRenderer.on('dependency-status', (_, data) => callback(data)),
getVideoInfo: (url, browser) => ipcRenderer.invoke('get-video-info', { url, browser }), 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)), onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_, data) => callback(data)),
selectDirectory: () => ipcRenderer.invoke('select-directory'), selectDirectory: () => ipcRenderer.invoke('select-directory'),
getDownloadsFolder: () => ipcRenderer.invoke('get-downloads-folder'), getDownloadsFolder: () => ipcRenderer.invoke('get-downloads-folder'),
+17
View File
@@ -135,6 +135,23 @@
<!-- Scrollable Track List Area --> <!-- Scrollable Track List Area -->
<div class="track-list-scroll"> <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"> <div id="track-list" class="track-list">
<!-- Dynamically populated by Javascript --> <!-- Dynamically populated by Javascript -->
</div> </div>
+7 -1
View File
@@ -55,5 +55,11 @@
"quality_best": "Highest", "quality_best": "Highest",
"quality_standard": "Standard", "quality_standard": "Standard",
"sec_suffix": "seconds", "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
View File
@@ -55,5 +55,11 @@
"quality_best": "En Yüksek", "quality_best": "En Yüksek",
"quality_standard": "Standart", "quality_standard": "Standart",
"sec_suffix": "saniye", "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
View File
@@ -39,6 +39,15 @@ let currentFormat = 'mp3'; // 'mp3' or 'mp4'
let currentQuality = '320'; // Selected quality value let currentQuality = '320'; // Selected quality value
let currentVideoData = null; 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 // Localization State
let currentLang = 'tr'; let currentLang = 'tr';
let translations = {}; let translations = {};
@@ -134,6 +143,41 @@ window.addEventListener('DOMContentLoaded', async () => {
await loadTranslations(e.target.value); 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 // Get and load persisted or default language
const savedLang = localStorage.getItem('vibe_lang') || 'tr'; const savedLang = localStorage.getItem('vibe_lang') || 'tr';
languageSelect.value = savedLang; languageSelect.value = savedLang;
@@ -195,6 +239,7 @@ formatMp3Btn.addEventListener('click', () => {
formatMp4Btn.classList.remove('active'); formatMp4Btn.classList.remove('active');
currentQuality = '320'; // Reset to default best quality for MP3 currentQuality = '320'; // Reset to default best quality for MP3
renderQualityPills(); renderQualityPills();
applyBottomSettingsToSelected();
}); });
formatMp4Btn.addEventListener('click', () => { formatMp4Btn.addEventListener('click', () => {
@@ -204,6 +249,7 @@ formatMp4Btn.addEventListener('click', () => {
formatMp3Btn.classList.remove('active'); formatMp3Btn.classList.remove('active');
currentQuality = '1080'; // Reset to default best quality for MP4 currentQuality = '1080'; // Reset to default best quality for MP4
renderQualityPills(); renderQualityPills();
applyBottomSettingsToSelected();
}); });
// 4. Quality Pills Renderer // 4. Quality Pills Renderer
@@ -225,12 +271,50 @@ function renderQualityPills() {
document.querySelectorAll('.quality-pill').forEach(p => p.classList.remove('active')); document.querySelectorAll('.quality-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active'); pill.classList.add('active');
currentQuality = opt.value; currentQuality = opt.value;
applyBottomSettingsToSelected();
}); });
qualityPillsContainer.appendChild(pill); 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) // 5. Back Button (Step 2 to Step 1 Transition)
backBtn.addEventListener('click', () => { backBtn.addEventListener('click', () => {
// Reset any error card // Reset any error card
@@ -239,6 +323,8 @@ backBtn.addEventListener('click', () => {
step2.classList.remove('active'); step2.classList.remove('active');
step1.classList.add('active'); step1.classList.add('active');
currentVideoData = null; currentVideoData = null;
playlistTracks = [];
focusedTrackId = null;
urlInput.focus(); urlInput.focus();
}); });
@@ -263,6 +349,8 @@ analyzeBtn.addEventListener('click', async () => {
if (result.success) { if (result.success) {
analyzedUrl = url; analyzedUrl = url;
currentVideoData = result.info; currentVideoData = result.info;
playlistTracks = [];
focusedTrackId = null;
// Render the fetched video details inside the scrollable list container // Render the fetched video details inside the scrollable list container
renderTrackList(result.info); renderTrackList(result.info);
@@ -284,30 +372,174 @@ analyzeBtn.addEventListener('click', async () => {
// 7. Render Track List Function // 7. Render Track List Function
function renderTrackList(info) { function renderTrackList(info) {
trackList.innerHTML = ` trackList.innerHTML = '';
<div class="track-item">
<div class="track-thumb"> 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"> <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> <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> <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg> </svg>
<img src="${info.thumbnail || ''}" onerror="this.style.display='none';" alt="Thumbnail"> <img src="${track.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>
`; `;
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 // 8. Media Download Executor
downloadBtn.addEventListener('click', async () => { 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 // Setup download progress panel UI states
downloadProgressPanel.style.display = 'block'; downloadProgressPanel.style.display = 'block';
@@ -316,59 +548,112 @@ downloadBtn.addEventListener('click', async () => {
downloadStatus.textContent = t('status_connecting'); downloadStatus.textContent = t('status_connecting');
downloadPercent.textContent = '0%'; downloadPercent.textContent = '0%';
downloadProgressBar.style.width = '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 // Disable UI interactive components during download
const statusBadge = document.getElementById('track-status-badge'); document.querySelectorAll('.track-checkbox, .format-badge-btn, .quality-pill, .format-btn').forEach(el => {
if (statusBadge) { el.disabled = true;
statusBadge.textContent = t('badge_downloading'); el.style.pointerEvents = 'none';
statusBadge.className = 'status-badge downloading'; });
// 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 // 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; let stat = status;
if (status.includes('İndiriliyor')) { 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')) { } else if (status.includes('İşleniyor') || status.includes('Dönüştürülüyor')) {
stat = t('status_processing'); stat = t('status_processing');
} }
downloadStatus.textContent = stat; downloadStatus.textContent = `[${index + 1}/${total}] ${title}`;
downloadPercent.textContent = `${percent}%`; downloadPercent.textContent = `${percent}%`;
downloadProgressBar.style.width = `${percent}%`; downloadProgressBar.style.width = `${percent}%`;
downloadSubtext.textContent = `${t('swal_file_size')}: ${stat}`;
if (percent > 90) {
downloadSubtext.textContent = currentFormat === 'mp3'
? t('subtext_mp3_convert')
: t('subtext_mp4_merge');
} else {
downloadSubtext.textContent = t('subtext_speed_note');
}
}); });
try { try {
const browser = useCookiesCheckbox.checked ? cookieBrowserSelect.value : null; 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) { if (result.success) {
downloadStatus.textContent = t('status_completed'); downloadStatus.textContent = t('status_completed');
downloadPercent.textContent = '100%'; downloadPercent.textContent = '100%';
downloadProgressBar.style.width = '100%'; downloadProgressBar.style.width = '100%';
downloadSubtext.textContent = t('subtext_success'); 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 // Success gradient transitions
downloadProgressBar.style.background = 'linear-gradient(90deg, #00ff87 0%, #60efff 100%)'; downloadProgressBar.style.background = 'linear-gradient(90deg, #00ff87 0%, #60efff 100%)';
downloadProgressBar.style.boxShadow = '0 0 15px rgba(0, 255, 135, 0.6)'; 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 const localizedDuration = result.stats.duration
.replace('saniye', t('sec_suffix')) .replace('saniye', t('sec_suffix'))
.replace('dakika', t('min_suffix')); .replace('dakika', t('min_suffix'));
@@ -378,11 +663,10 @@ downloadBtn.addEventListener('click', async () => {
title: t('swal_success_title'), title: t('swal_success_title'),
html: ` 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;"> <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>${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;">${currentVideoData.uploader || t('duration_unknown')}</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>${t('swal_file_size')}</strong> <span style="color: #00ff87;">${result.stats.size}</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_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> <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> </div>
`, `,
@@ -399,7 +683,6 @@ downloadBtn.addEventListener('click', async () => {
if (swalResult.isConfirmed) { if (swalResult.isConfirmed) {
window.api.openPath(result.stats.outputDir); window.api.openPath(result.stats.outputDir);
} else { } else {
// Go back to step 1
backBtn.click(); backBtn.click();
urlInput.value = ''; urlInput.value = '';
downloadProgressPanel.style.display = 'none'; downloadProgressPanel.style.display = 'none';
@@ -408,13 +691,6 @@ downloadBtn.addEventListener('click', async () => {
} else { } else {
showError(result.error); showError(result.error);
downloadProgressPanel.style.display = 'none'; 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) { } catch (err) {
showError(err.message); showError(err.message);
@@ -422,6 +698,10 @@ downloadBtn.addEventListener('click', async () => {
} finally { } finally {
downloadBtn.disabled = false; downloadBtn.disabled = false;
backBtn.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
View File
@@ -899,3 +899,144 @@ html[dir="rtl"] .error-card {
flex-direction: row; 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
View File
@@ -168,11 +168,22 @@ async function checkDependencies(onStatusUpdate) {
return true; 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.) // Get video metadata (title, thumbnail, duration, etc.)
function getVideoInfo(url, browser) { function getVideoInfo(url, browser) {
console.log(`[DEBUG] Fetching info for URL: ${url}`); console.log(`[DEBUG] Fetching info for URL: ${url}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let args = ['--no-playlist', '-j']; let args = ['-J', '--flat-playlist'];
if (browser) { if (browser) {
args.push('--cookies-from-browser', 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}`); console.log(`[DEBUG] yt-dlp info process exited with code ${code}`);
if (code !== 0) { if (code !== 0) {
console.error(`[DEBUG] yt-dlp error output:`, stderr); 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; return;
} }
try { try {
const info = JSON.parse(stdout); const info = JSON.parse(stdout);
console.log(`[DEBUG] Metadata fetched successfully for: "${info.title}"`);
resolve({ if (info._type === 'playlist' || info.entries) {
title: info.title, console.log(`[DEBUG] Playlist metadata fetched successfully for: "${info.title}" (${info.entries.length} items)`);
thumbnail: info.thumbnail, const entries = info.entries.map((entry, index) => {
duration: info.duration_string, let thumb = '';
uploader: info.uploader 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) { } catch (err) {
console.error(`[DEBUG] JSON parsing error on yt-dlp output:`, err); console.error(`[DEBUG] JSON parsing error on yt-dlp output:`, err);
reject(err); reject(err);
@@ -210,9 +259,8 @@ function getVideoInfo(url, browser) {
}); });
} }
// Download video or convert to MP3 // Download single item helper
function downloadMedia(url, format, quality, outputDir, browser, onProgress) { function downloadSingle(url, format, quality, outputDir, browser, onProgress) {
console.log(`[DEBUG] Media download requested. Format: ${format}, Quality: ${quality}, Target Dir: ${outputDir}, Browser Cookies: ${browser || 'none'}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let args = []; let args = [];
const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s'); const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s');
@@ -220,7 +268,7 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
let totalSize = 'Bilinmiyor'; let totalSize = 'Bilinmiyor';
if (format === 'mp3') { if (format === 'mp3') {
const q = quality ? `${quality}K` : '320K'; // '320K', '256K', '192K' const q = quality ? `${quality}K` : '320K';
args = [ args = [
'--no-playlist', '--no-playlist',
'--ffmpeg-location', FFMPEG_PATH, '--ffmpeg-location', FFMPEG_PATH,
@@ -230,7 +278,7 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
'-o', outputTemplate '-o', outputTemplate
]; ];
} else { } else {
const res = quality || '1080'; // '1080', '720', '480' const res = quality || '1080';
args = [ args = [
'--no-playlist', '--no-playlist',
'--ffmpeg-location', FFMPEG_PATH, '--ffmpeg-location', FFMPEG_PATH,
@@ -244,13 +292,12 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
} }
args.push(url); 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); const proc = spawn(YTDLP_PATH, args);
proc.stdout.on('data', (data) => { proc.stdout.on('data', (data) => {
const line = data.toString(); 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]+)/); const sizeMatch = line.match(/of\s+(\d+(?:\.\d+)?\s*[a-zA-Z]+)/);
if (sizeMatch) { if (sizeMatch) {
totalSize = sizeMatch[1]; totalSize = sizeMatch[1];
@@ -261,7 +308,6 @@ function downloadMedia(url, format, quality, outputDir, browser, onProgress) {
const percent = parseFloat(match[1]); const percent = parseFloat(match[1]);
onProgress(percent, `İndiriliyor... %${percent}`); onProgress(percent, `İndiriliyor... %${percent}`);
} else if (line.includes('[ExtractAudio]') || line.includes('[Merger]')) { } 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...'); 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) => { 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) { if (code === 0) {
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1); const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
resolve({ resolve({
size: totalSize, size: totalSize,
duration: `${durationSec} saniye`, duration: `${durationSec} saniye`
outputDir: outputDir
}); });
} else { } else {
reject(new Error(`Download exited with code ${code}`)); 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 = { module.exports = {
checkDependencies, checkDependencies,
getVideoInfo, getVideoInfo,