app.js 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375
  1. // GrabZilla 2.1 - Application Entry Point
  2. // Modular architecture with clear separation of concerns
  3. class GrabZillaApp {
  4. constructor() {
  5. this.state = null;
  6. this.eventBus = null;
  7. this.initialized = false;
  8. this.modules = new Map();
  9. }
  10. // Initialize the application
  11. async init() {
  12. try {
  13. console.log('🚀 Initializing GrabZilla 2.1...');
  14. // Initialize event bus
  15. this.eventBus = window.eventBus;
  16. if (!this.eventBus) {
  17. throw new Error('EventBus not available');
  18. }
  19. // Initialize application state
  20. this.state = new window.AppState();
  21. if (!this.state) {
  22. throw new Error('AppState not available');
  23. }
  24. // Set up error handling
  25. this.setupErrorHandling();
  26. // Initialize UI components
  27. await this.initializeUI();
  28. // Set up event listeners
  29. this.setupEventListeners();
  30. // Load saved state if available
  31. await this.loadState();
  32. // Ensure save directory exists
  33. await this.ensureSaveDirectoryExists();
  34. // Check binary status and validate
  35. await this.checkAndValidateBinaries();
  36. // Initialize keyboard navigation
  37. this.initializeKeyboardNavigation();
  38. this.initialized = true;
  39. console.log('✅ GrabZilla 2.1 initialized successfully');
  40. // Notify that the app is ready
  41. this.eventBus.emit('app:ready', { app: this });
  42. } catch (error) {
  43. console.error('❌ Failed to initialize GrabZilla:', error);
  44. this.handleInitializationError(error);
  45. }
  46. }
  47. // Set up global error handling
  48. setupErrorHandling() {
  49. // Handle unhandled errors
  50. window.addEventListener('error', (event) => {
  51. console.error('Global error:', event.error);
  52. this.eventBus.emit('app:error', {
  53. type: 'global',
  54. error: event.error,
  55. filename: event.filename,
  56. lineno: event.lineno
  57. });
  58. });
  59. // Handle unhandled promise rejections
  60. window.addEventListener('unhandledrejection', (event) => {
  61. console.error('Unhandled promise rejection:', event.reason);
  62. this.eventBus.emit('app:error', {
  63. type: 'promise',
  64. error: event.reason
  65. });
  66. });
  67. // Listen for application errors
  68. this.eventBus.on('app:error', (errorData) => {
  69. // Handle errors appropriately
  70. this.displayError(errorData);
  71. });
  72. }
  73. // Initialize UI components
  74. async initializeUI() {
  75. // Update save path display
  76. this.updateSavePathDisplay();
  77. // Initialize dropdown values
  78. this.initializeDropdowns();
  79. // Set up video list
  80. this.initializeVideoList();
  81. // Set up status display
  82. this.updateStatusMessage('Ready to download videos');
  83. }
  84. // Set up main event listeners
  85. setupEventListeners() {
  86. // State change listeners
  87. this.state.on('videoAdded', (data) => this.onVideoAdded(data));
  88. this.state.on('videoRemoved', (data) => this.onVideoRemoved(data));
  89. this.state.on('videoUpdated', (data) => this.onVideoUpdated(data));
  90. this.state.on('videosReordered', (data) => this.onVideosReordered(data));
  91. this.state.on('videosCleared', (data) => this.onVideosCleared(data));
  92. this.state.on('configUpdated', (data) => this.onConfigUpdated(data));
  93. // UI event listeners
  94. this.setupButtonEventListeners();
  95. this.setupInputEventListeners();
  96. this.setupVideoListEventListeners();
  97. }
  98. // Set up button event listeners
  99. setupButtonEventListeners() {
  100. // Add Video button
  101. const addVideoBtn = document.getElementById('addVideoBtn');
  102. if (addVideoBtn) {
  103. addVideoBtn.addEventListener('click', () => this.handleAddVideo());
  104. }
  105. // Import URLs button
  106. const importUrlsBtn = document.getElementById('importUrlsBtn');
  107. if (importUrlsBtn) {
  108. importUrlsBtn.addEventListener('click', () => this.handleImportUrls());
  109. }
  110. // Save Path button
  111. const savePathBtn = document.getElementById('savePathBtn');
  112. if (savePathBtn) {
  113. savePathBtn.addEventListener('click', () => this.handleSelectSavePath());
  114. }
  115. // Cookie File button
  116. const cookieFileBtn = document.getElementById('cookieFileBtn');
  117. if (cookieFileBtn) {
  118. cookieFileBtn.addEventListener('click', () => this.handleSelectCookieFile());
  119. }
  120. // Control panel buttons
  121. const clearListBtn = document.getElementById('clearListBtn');
  122. if (clearListBtn) {
  123. clearListBtn.addEventListener('click', () => this.handleClearList());
  124. }
  125. const downloadVideosBtn = document.getElementById('downloadVideosBtn');
  126. if (downloadVideosBtn) {
  127. downloadVideosBtn.addEventListener('click', () => this.handleDownloadVideos());
  128. }
  129. const cancelDownloadsBtn = document.getElementById('cancelDownloadsBtn');
  130. if (cancelDownloadsBtn) {
  131. cancelDownloadsBtn.addEventListener('click', () => this.handleCancelDownloads());
  132. }
  133. const updateDepsBtn = document.getElementById('updateDepsBtn');
  134. if (updateDepsBtn) {
  135. updateDepsBtn.addEventListener('click', () => this.handleUpdateDependencies());
  136. }
  137. }
  138. // Set up input event listeners
  139. setupInputEventListeners() {
  140. // URL input - no paste handler needed, user clicks "Add Video" button
  141. const urlInput = document.getElementById('urlInput');
  142. if (urlInput) {
  143. // Optional: could add real-time validation feedback here
  144. }
  145. // Configuration inputs
  146. const defaultQuality = document.getElementById('defaultQuality');
  147. if (defaultQuality) {
  148. defaultQuality.addEventListener('change', (e) => {
  149. this.state.updateConfig({ defaultQuality: e.target.value });
  150. });
  151. }
  152. const defaultFormat = document.getElementById('defaultFormat');
  153. if (defaultFormat) {
  154. defaultFormat.addEventListener('change', (e) => {
  155. this.state.updateConfig({ defaultFormat: e.target.value });
  156. });
  157. }
  158. const filenamePattern = document.getElementById('filenamePattern');
  159. if (filenamePattern) {
  160. filenamePattern.addEventListener('change', (e) => {
  161. this.state.updateConfig({ filenamePattern: e.target.value });
  162. });
  163. }
  164. }
  165. // Set up video list event listeners
  166. setupVideoListEventListeners() {
  167. const videoList = document.getElementById('videoList');
  168. if (videoList) {
  169. videoList.addEventListener('click', (e) => this.handleVideoListClick(e));
  170. videoList.addEventListener('change', (e) => this.handleVideoListChange(e));
  171. this.setupDragAndDrop(videoList);
  172. }
  173. }
  174. // Set up drag-and-drop reordering
  175. setupDragAndDrop(videoList) {
  176. let draggedElement = null;
  177. let draggedVideoId = null;
  178. videoList.addEventListener('dragstart', (e) => {
  179. const videoItem = e.target.closest('.video-item');
  180. if (!videoItem) return;
  181. draggedElement = videoItem;
  182. draggedVideoId = videoItem.dataset.videoId;
  183. videoItem.classList.add('opacity-50');
  184. e.dataTransfer.effectAllowed = 'move';
  185. e.dataTransfer.setData('text/html', videoItem.innerHTML);
  186. });
  187. videoList.addEventListener('dragover', (e) => {
  188. e.preventDefault();
  189. const videoItem = e.target.closest('.video-item');
  190. if (!videoItem || videoItem === draggedElement) return;
  191. e.dataTransfer.dropEffect = 'move';
  192. // Visual feedback - show where it will drop
  193. const rect = videoItem.getBoundingClientRect();
  194. const midpoint = rect.top + rect.height / 2;
  195. if (e.clientY < midpoint) {
  196. videoItem.classList.add('border-t-2', 'border-[#155dfc]');
  197. videoItem.classList.remove('border-b-2');
  198. } else {
  199. videoItem.classList.add('border-b-2', 'border-[#155dfc]');
  200. videoItem.classList.remove('border-t-2');
  201. }
  202. });
  203. videoList.addEventListener('dragleave', (e) => {
  204. const videoItem = e.target.closest('.video-item');
  205. if (videoItem) {
  206. videoItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  207. }
  208. });
  209. videoList.addEventListener('drop', (e) => {
  210. e.preventDefault();
  211. const targetItem = e.target.closest('.video-item');
  212. if (!targetItem || !draggedVideoId) return;
  213. const targetVideoId = targetItem.dataset.videoId;
  214. // Calculate drop position
  215. const rect = targetItem.getBoundingClientRect();
  216. const midpoint = rect.top + rect.height / 2;
  217. const dropBefore = e.clientY < midpoint;
  218. // Reorder in state
  219. this.handleVideoReorder(draggedVideoId, targetVideoId, dropBefore);
  220. // Clean up visual feedback
  221. targetItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  222. });
  223. videoList.addEventListener('dragend', (e) => {
  224. const videoItem = e.target.closest('.video-item');
  225. if (videoItem) {
  226. videoItem.classList.remove('opacity-50');
  227. }
  228. // Clean up all visual feedback
  229. document.querySelectorAll('.video-item').forEach(item => {
  230. item.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  231. });
  232. draggedElement = null;
  233. draggedVideoId = null;
  234. });
  235. }
  236. handleVideoReorder(draggedId, targetId, insertBefore) {
  237. const videos = this.state.getVideos();
  238. const draggedIndex = videos.findIndex(v => v.id === draggedId);
  239. const targetIndex = videos.findIndex(v => v.id === targetId);
  240. if (draggedIndex === -1 || targetIndex === -1) return;
  241. let newIndex = targetIndex;
  242. if (draggedIndex < targetIndex && !insertBefore) {
  243. newIndex = targetIndex;
  244. } else if (draggedIndex > targetIndex && insertBefore) {
  245. newIndex = targetIndex;
  246. } else if (insertBefore) {
  247. newIndex = targetIndex;
  248. } else {
  249. newIndex = targetIndex + 1;
  250. }
  251. this.state.reorderVideos(draggedIndex, newIndex);
  252. }
  253. // Handle clicks in video list (checkboxes, delete buttons)
  254. handleVideoListClick(event) {
  255. const target = event.target;
  256. const videoItem = target.closest('.video-item');
  257. if (!videoItem) return;
  258. const videoId = videoItem.dataset.videoId;
  259. if (!videoId) return;
  260. // Handle checkbox click
  261. if (target.closest('.video-checkbox')) {
  262. event.preventDefault();
  263. this.toggleVideoSelection(videoId);
  264. return;
  265. }
  266. // Handle delete button click (if we add one later)
  267. if (target.closest('.delete-video-btn')) {
  268. event.preventDefault();
  269. this.handleRemoveVideo(videoId);
  270. return;
  271. }
  272. }
  273. // Handle dropdown changes in video list (quality, format)
  274. handleVideoListChange(event) {
  275. const target = event.target;
  276. const videoItem = target.closest('.video-item');
  277. if (!videoItem) return;
  278. const videoId = videoItem.dataset.videoId;
  279. if (!videoId) return;
  280. // Handle quality dropdown change
  281. if (target.classList.contains('quality-select')) {
  282. const quality = target.value;
  283. this.state.updateVideo(videoId, { quality });
  284. console.log(`Updated video ${videoId} quality to ${quality}`);
  285. return;
  286. }
  287. // Handle format dropdown change
  288. if (target.classList.contains('format-select')) {
  289. const format = target.value;
  290. this.state.updateVideo(videoId, { format });
  291. console.log(`Updated video ${videoId} format to ${format}`);
  292. return;
  293. }
  294. }
  295. // Toggle video selection
  296. toggleVideoSelection(videoId) {
  297. this.state.toggleVideoSelection(videoId);
  298. this.updateVideoCheckbox(videoId);
  299. }
  300. // Update checkbox visual state
  301. updateVideoCheckbox(videoId) {
  302. const videoItem = document.querySelector(`[data-video-id="${videoId}"]`);
  303. if (!videoItem) return;
  304. const checkbox = videoItem.querySelector('.video-checkbox');
  305. if (!checkbox) return;
  306. const isSelected = this.state.ui.selectedVideos.includes(videoId);
  307. checkbox.setAttribute('aria-checked', isSelected ? 'true' : 'false');
  308. // Update checkbox SVG
  309. const svg = checkbox.querySelector('svg');
  310. if (svg) {
  311. if (isSelected) {
  312. svg.innerHTML = `<rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="currentColor" rx="2" />
  313. <path d="M5 8L7 10L11 6" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
  314. } else {
  315. svg.innerHTML = `<rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="none" rx="2" />`;
  316. }
  317. }
  318. }
  319. // Remove video from list
  320. handleRemoveVideo(videoId) {
  321. try {
  322. const video = this.state.getVideo(videoId);
  323. if (video && confirm(`Remove "${video.getDisplayName()}"?`)) {
  324. this.state.removeVideo(videoId);
  325. this.updateStatusMessage('Video removed');
  326. }
  327. } catch (error) {
  328. console.error('Error removing video:', error);
  329. this.showError(`Failed to remove video: ${error.message}`);
  330. }
  331. }
  332. // Event handlers
  333. async handleAddVideo() {
  334. const urlInput = document.getElementById('urlInput');
  335. const inputText = urlInput?.value.trim();
  336. if (!inputText) {
  337. this.showError('Please enter a URL');
  338. return;
  339. }
  340. try {
  341. this.updateStatusMessage('Adding videos...');
  342. // Validate URLs
  343. const validation = window.URLValidator.validateMultipleUrls(inputText);
  344. if (validation.invalid.length > 0) {
  345. this.showError(`Invalid URLs found: ${validation.invalid.join(', ')}`);
  346. return;
  347. }
  348. if (validation.valid.length === 0) {
  349. this.showError('No valid URLs found');
  350. return;
  351. }
  352. // Add videos to state
  353. const results = await this.state.addVideosFromUrls(validation.valid);
  354. // Clear input on success
  355. if (urlInput) {
  356. urlInput.value = '';
  357. }
  358. // Show results
  359. const successCount = results.successful.length;
  360. const duplicateCount = results.duplicates.length;
  361. const failedCount = results.failed.length;
  362. let message = `Added ${successCount} video(s)`;
  363. if (duplicateCount > 0) {
  364. message += `, ${duplicateCount} duplicate(s) skipped`;
  365. }
  366. if (failedCount > 0) {
  367. message += `, ${failedCount} failed`;
  368. }
  369. this.updateStatusMessage(message);
  370. } catch (error) {
  371. console.error('Error adding videos:', error);
  372. this.showError(`Failed to add videos: ${error.message}`);
  373. }
  374. }
  375. async handleImportUrls() {
  376. if (!window.electronAPI) {
  377. this.showError('File import requires Electron environment');
  378. return;
  379. }
  380. try {
  381. // Implementation would use Electron file dialog
  382. this.updateStatusMessage('Import URLs functionality coming soon');
  383. } catch (error) {
  384. this.showError(`Failed to import URLs: ${error.message}`);
  385. }
  386. }
  387. async handleSelectSavePath() {
  388. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  389. this.showError('Path selection requires Electron environment');
  390. return;
  391. }
  392. try {
  393. this.updateStatusMessage('Select download directory...');
  394. const result = await window.IPCManager.selectSaveDirectory();
  395. if (result && result.success && result.path) {
  396. this.state.updateConfig({ savePath: result.path });
  397. await this.ensureSaveDirectoryExists(); // Auto-create directory
  398. this.updateSavePathDisplay();
  399. this.updateStatusMessage(`Save path set to: ${result.path}`);
  400. } else if (result && result.error) {
  401. this.showError(result.error);
  402. } else {
  403. this.updateStatusMessage('No directory selected');
  404. }
  405. } catch (error) {
  406. console.error('Error selecting save path:', error);
  407. this.showError(`Failed to select save path: ${error.message}`);
  408. }
  409. }
  410. async handleSelectCookieFile() {
  411. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  412. this.showError('File selection requires Electron environment');
  413. return;
  414. }
  415. try {
  416. this.updateStatusMessage('Select cookie file...');
  417. const result = await window.IPCManager.selectCookieFile();
  418. if (result && result.success && result.path) {
  419. this.state.updateConfig({ cookieFile: result.path });
  420. this.updateStatusMessage(`Cookie file set: ${result.path}`);
  421. } else if (result && result.error) {
  422. this.showError(result.error);
  423. } else {
  424. this.updateStatusMessage('No file selected');
  425. }
  426. } catch (error) {
  427. console.error('Error selecting cookie file:', error);
  428. this.showError(`Failed to select cookie file: ${error.message}`);
  429. }
  430. }
  431. handleClearList() {
  432. if (this.state.getVideos().length === 0) {
  433. this.updateStatusMessage('No videos to clear');
  434. return;
  435. }
  436. const removedVideos = this.state.clearVideos();
  437. this.updateStatusMessage(`Cleared ${removedVideos.length} video(s)`);
  438. }
  439. async handleDownloadVideos() {
  440. // Check if IPC is available
  441. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  442. this.showError('Download functionality requires Electron environment');
  443. return;
  444. }
  445. // Get downloadable videos (either selected or all ready videos)
  446. const selectedVideos = this.state.getSelectedVideos().filter(v => v.isDownloadable());
  447. const videos = selectedVideos.length > 0
  448. ? selectedVideos
  449. : this.state.getVideos().filter(v => v.isDownloadable());
  450. if (videos.length === 0) {
  451. this.showError('No videos ready for download');
  452. return;
  453. }
  454. // Validate save path
  455. if (!this.state.config.savePath) {
  456. this.showError('Please select a save directory first');
  457. return;
  458. }
  459. this.state.updateUI({ isDownloading: true });
  460. this.updateStatusMessage(`Starting download of ${videos.length} video(s)...`);
  461. // Set up download progress listener
  462. window.IPCManager.onDownloadProgress('app', (progressData) => {
  463. this.handleDownloadProgress(progressData);
  464. });
  465. // Download videos sequentially
  466. let successCount = 0;
  467. let failedCount = 0;
  468. for (const video of videos) {
  469. try {
  470. // Update video status to downloading
  471. this.state.updateVideo(video.id, { status: 'downloading', progress: 0 });
  472. const result = await window.IPCManager.downloadVideo({
  473. videoId: video.id,
  474. url: video.url,
  475. quality: video.quality,
  476. format: video.format,
  477. savePath: this.state.config.savePath,
  478. cookieFile: this.state.config.cookieFile
  479. });
  480. if (result.success) {
  481. this.state.updateVideo(video.id, {
  482. status: 'completed',
  483. progress: 100,
  484. filename: result.filename
  485. });
  486. successCount++;
  487. // Show notification for successful download
  488. this.showDownloadNotification(video, 'success');
  489. } else {
  490. this.state.updateVideo(video.id, {
  491. status: 'error',
  492. error: result.error || 'Download failed'
  493. });
  494. failedCount++;
  495. // Show notification for failed download
  496. this.showDownloadNotification(video, 'error', result.error);
  497. }
  498. } catch (error) {
  499. console.error(`Error downloading video ${video.id}:`, error);
  500. this.state.updateVideo(video.id, {
  501. status: 'error',
  502. error: error.message
  503. });
  504. failedCount++;
  505. }
  506. }
  507. // Clean up progress listener
  508. window.IPCManager.removeDownloadProgressListener('app');
  509. this.state.updateUI({ isDownloading: false });
  510. // Show final status
  511. let message = `Download complete: ${successCount} succeeded`;
  512. if (failedCount > 0) {
  513. message += `, ${failedCount} failed`;
  514. }
  515. this.updateStatusMessage(message);
  516. }
  517. // Handle download progress updates from IPC
  518. handleDownloadProgress(progressData) {
  519. const { url, progress, status, stage, message } = progressData;
  520. // Find video by URL
  521. const video = this.state.getVideos().find(v => v.url === url);
  522. if (!video) return;
  523. // Update video progress
  524. this.state.updateVideo(video.id, {
  525. progress: Math.round(progress),
  526. status: status || 'downloading'
  527. });
  528. }
  529. // Show download notification
  530. async showDownloadNotification(video, type, errorMessage = null) {
  531. if (!window.electronAPI) return;
  532. try {
  533. const notificationOptions = {
  534. title: type === 'success' ? 'Download Complete' : 'Download Failed',
  535. message: type === 'success'
  536. ? `${video.getDisplayName()}`
  537. : `${video.getDisplayName()}: ${errorMessage || 'Unknown error'}`,
  538. sound: true
  539. };
  540. await window.electronAPI.showNotification(notificationOptions);
  541. } catch (error) {
  542. console.warn('Failed to show notification:', error);
  543. }
  544. }
  545. async handleCancelDownloads() {
  546. const activeDownloads = this.state.getVideosByStatus('downloading').length +
  547. this.state.getVideosByStatus('converting').length;
  548. if (activeDownloads === 0) {
  549. this.updateStatusMessage('No active downloads to cancel');
  550. return;
  551. }
  552. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  553. this.showError('Cancel functionality requires Electron environment');
  554. return;
  555. }
  556. try {
  557. this.updateStatusMessage(`Cancelling ${activeDownloads} active download(s)...`);
  558. // Cancel all conversions via IPC
  559. await window.electronAPI.cancelAllConversions();
  560. // Update video statuses to ready
  561. const downloadingVideos = this.state.getVideosByStatus('downloading');
  562. const convertingVideos = this.state.getVideosByStatus('converting');
  563. [...downloadingVideos, ...convertingVideos].forEach(video => {
  564. this.state.updateVideo(video.id, {
  565. status: 'ready',
  566. progress: 0,
  567. error: 'Cancelled by user'
  568. });
  569. });
  570. this.state.updateUI({ isDownloading: false });
  571. this.updateStatusMessage('Downloads cancelled');
  572. } catch (error) {
  573. console.error('Error cancelling downloads:', error);
  574. this.showError(`Failed to cancel downloads: ${error.message}`);
  575. }
  576. }
  577. async handleUpdateDependencies() {
  578. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  579. this.showError('Update functionality requires Electron environment');
  580. return;
  581. }
  582. const btn = document.getElementById('updateDepsBtn');
  583. const originalBtnHTML = btn ? btn.innerHTML : '';
  584. try {
  585. // Show loading state
  586. this.updateStatusMessage('Checking binary versions...');
  587. if (btn) {
  588. btn.disabled = true;
  589. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy" class="animate-spin">Checking...';
  590. }
  591. const versions = await window.IPCManager.checkBinaryVersions();
  592. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  593. const ytdlp = versions.ytDlp || versions.ytdlp;
  594. const ffmpeg = versions.ffmpeg;
  595. if (versions && (ytdlp || ffmpeg)) {
  596. // Update both button status and version display
  597. const ytdlpMissing = !ytdlp || !ytdlp.available;
  598. const ffmpegMissing = !ffmpeg || !ffmpeg.available;
  599. if (ytdlpMissing || ffmpegMissing) {
  600. this.updateDependenciesButtonStatus('missing');
  601. this.updateBinaryVersionDisplay(null);
  602. } else {
  603. this.updateDependenciesButtonStatus('ok');
  604. // Normalize the format for display
  605. const normalizedVersions = {
  606. ytdlp: ytdlp,
  607. ffmpeg: ffmpeg
  608. };
  609. this.updateBinaryVersionDisplay(normalizedVersions);
  610. // Show dialog if updates are available
  611. if (ytdlp.updateAvailable) {
  612. this.showInfo({
  613. title: 'Update Available',
  614. message: `A newer version of yt-dlp is available:\nInstalled: ${ytdlp.version}\nLatest: ${ytdlp.latestVersion || 'newer version'}\n\nPlease run 'npm run setup' to update.`
  615. });
  616. }
  617. }
  618. } else {
  619. this.showError('Could not check binary versions');
  620. }
  621. } catch (error) {
  622. console.error('Error checking dependencies:', error);
  623. this.showError(`Failed to check dependencies: ${error.message}`);
  624. } finally {
  625. // Restore button state
  626. if (btn) {
  627. btn.disabled = false;
  628. btn.innerHTML = originalBtnHTML || '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">Check for Updates';
  629. }
  630. }
  631. }
  632. // State change handlers
  633. onVideoAdded(data) {
  634. this.renderVideoList();
  635. this.updateStatsDisplay();
  636. }
  637. onVideoRemoved(data) {
  638. this.renderVideoList();
  639. this.updateStatsDisplay();
  640. }
  641. onVideoUpdated(data) {
  642. this.updateVideoElement(data.video);
  643. this.updateStatsDisplay();
  644. }
  645. onVideosReordered(data) {
  646. // Re-render entire list to reflect new order
  647. this.renderVideoList();
  648. console.log('Video order updated:', data);
  649. }
  650. onVideosCleared(data) {
  651. this.renderVideoList();
  652. this.updateStatsDisplay();
  653. }
  654. onConfigUpdated(data) {
  655. this.updateConfigUI(data.config);
  656. }
  657. // UI update methods
  658. updateSavePathDisplay() {
  659. const savePathElement = document.getElementById('savePath');
  660. if (savePathElement) {
  661. savePathElement.textContent = this.state.config.savePath;
  662. }
  663. }
  664. initializeDropdowns() {
  665. // Set dropdown values from config
  666. const defaultQuality = document.getElementById('defaultQuality');
  667. if (defaultQuality) {
  668. defaultQuality.value = this.state.config.defaultQuality;
  669. }
  670. const defaultFormat = document.getElementById('defaultFormat');
  671. if (defaultFormat) {
  672. defaultFormat.value = this.state.config.defaultFormat;
  673. }
  674. const filenamePattern = document.getElementById('filenamePattern');
  675. if (filenamePattern) {
  676. filenamePattern.value = this.state.config.filenamePattern;
  677. }
  678. }
  679. initializeVideoList() {
  680. this.renderVideoList();
  681. }
  682. renderVideoList() {
  683. const videoList = document.getElementById('videoList');
  684. if (!videoList) return;
  685. const videos = this.state.getVideos();
  686. // Clear all existing videos (including mockups)
  687. videoList.innerHTML = '';
  688. // If no videos, show empty state
  689. if (videos.length === 0) {
  690. videoList.innerHTML = `
  691. <div class="text-center py-12 text-[#90a1b9]">
  692. <p class="text-lg mb-2">No videos yet</p>
  693. <p class="text-sm">Paste YouTube or Vimeo URLs above to get started</p>
  694. </div>
  695. `;
  696. return;
  697. }
  698. // Render each video
  699. videos.forEach(video => {
  700. const videoElement = this.createVideoElement(video);
  701. videoList.appendChild(videoElement);
  702. });
  703. }
  704. createVideoElement(video) {
  705. const div = document.createElement('div');
  706. div.className = 'video-item grid grid-cols-[40px_40px_1fr_120px_100px_120px_100px_40px] gap-4 items-center p-2 rounded bg-[#314158] hover:bg-[#3a4a68] transition-colors duration-200';
  707. div.dataset.videoId = video.id;
  708. div.setAttribute('draggable', 'true'); // Make video item draggable
  709. div.innerHTML = `
  710. <!-- Checkbox -->
  711. <div class="flex items-center justify-center">
  712. <button class="video-checkbox w-6 h-6 rounded flex items-center justify-center hover:bg-[#45556c] transition-colors"
  713. role="checkbox" aria-checked="false" aria-label="Select ${video.getDisplayName()}">
  714. <svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="text-white">
  715. <rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="none" rx="2" />
  716. </svg>
  717. </button>
  718. </div>
  719. <!-- Drag Handle -->
  720. <div class="flex items-center justify-center text-[#90a1b9] hover:text-white cursor-grab transition-colors">
  721. <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
  722. <circle cx="4" cy="4" r="1" />
  723. <circle cx="4" cy="8" r="1" />
  724. <circle cx="4" cy="12" r="1" />
  725. <circle cx="8" cy="4" r="1" />
  726. <circle cx="8" cy="8" r="1" />
  727. <circle cx="8" cy="12" r="1" />
  728. <circle cx="12" cy="4" r="1" />
  729. <circle cx="12" cy="8" r="1" />
  730. <circle cx="12" cy="12" r="1" />
  731. </svg>
  732. </div>
  733. <!-- Video Info -->
  734. <div class="flex items-center gap-3 min-w-0">
  735. <div class="w-16 h-12 bg-[#45556c] rounded overflow-hidden flex-shrink-0">
  736. ${video.isFetchingMetadata ?
  737. `<div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  738. <svg class="animate-spin h-5 w-5 text-[#155dfc]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
  739. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  740. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
  741. </svg>
  742. </div>` :
  743. video.thumbnail ?
  744. `<img src="${video.thumbnail}" alt="${video.getDisplayName()}" class="w-full h-full object-cover">` :
  745. `<div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  746. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-[#90a1b9]">
  747. <path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
  748. </svg>
  749. </div>`
  750. }
  751. </div>
  752. <div class="min-w-0 flex-1">
  753. <div class="text-sm text-white truncate font-medium">${video.getDisplayName()}</div>
  754. ${video.isFetchingMetadata ?
  755. `<div class="text-xs text-[#155dfc] animate-pulse">Fetching info...</div>` :
  756. ''
  757. }
  758. </div>
  759. </div>
  760. <!-- Duration -->
  761. <div class="text-sm text-[#cad5e2] text-center">${video.duration || '--:--'}</div>
  762. <!-- Quality Dropdown -->
  763. <div class="flex justify-center">
  764. <select class="quality-select bg-[#314158] border border-[#45556c] text-[#cad5e2] px-2 py-1 rounded text-xs font-medium min-w-0 w-full text-center"
  765. aria-label="Quality for ${video.getDisplayName()}">
  766. <option value="4K" ${video.quality === '4K' ? 'selected' : ''}>4K</option>
  767. <option value="1440p" ${video.quality === '1440p' ? 'selected' : ''}>1440p</option>
  768. <option value="1080p" ${video.quality === '1080p' ? 'selected' : ''}>1080p</option>
  769. <option value="720p" ${video.quality === '720p' ? 'selected' : ''}>720p</option>
  770. </select>
  771. </div>
  772. <!-- Format Dropdown -->
  773. <div class="flex justify-center">
  774. <select class="format-select bg-[#314158] border border-[#45556c] text-[#cad5e2] px-2 py-1 rounded text-xs font-medium min-w-0 w-full text-center"
  775. aria-label="Format for ${video.getDisplayName()}">
  776. <option value="None" ${video.format === 'None' ? 'selected' : ''}>None</option>
  777. <option value="H264" ${video.format === 'H264' ? 'selected' : ''}>H264</option>
  778. <option value="ProRes" ${video.format === 'ProRes' ? 'selected' : ''}>ProRes</option>
  779. <option value="DNxHR" ${video.format === 'DNxHR' ? 'selected' : ''}>DNxHR</option>
  780. <option value="Audio only" ${video.format === 'Audio only' ? 'selected' : ''}>Audio only</option>
  781. </select>
  782. </div>
  783. <!-- Status Badge -->
  784. <div class="flex justify-center status-column">
  785. <span class="status-badge ${video.status}" role="status" aria-live="polite">
  786. ${this.getStatusText(video)}
  787. </span>
  788. </div>
  789. <!-- Delete Button -->
  790. <div class="flex items-center justify-center">
  791. <button class="delete-video-btn w-6 h-6 rounded flex items-center justify-center hover:bg-red-600 hover:text-white text-[#90a1b9] transition-colors duration-200"
  792. aria-label="Delete ${video.getDisplayName()}" title="Remove from queue">
  793. <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
  794. <path d="M3 4h10M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v4M10 7v4M4 4l1 9a1 1 0 001 1h4a1 1 0 001-1l1-9"
  795. stroke-linecap="round" stroke-linejoin="round"/>
  796. </svg>
  797. </button>
  798. </div>
  799. `;
  800. return div;
  801. }
  802. getStatusText(video) {
  803. switch (video.status) {
  804. case 'downloading':
  805. return `Downloading ${video.progress || 0}%`;
  806. case 'converting':
  807. return `Converting ${video.progress || 0}%`;
  808. case 'completed':
  809. return 'Completed';
  810. case 'error':
  811. return 'Error';
  812. case 'ready':
  813. default:
  814. return 'Ready';
  815. }
  816. }
  817. updateVideoElement(video) {
  818. const videoElement = document.querySelector(`[data-video-id="${video.id}"]`);
  819. if (!videoElement) return;
  820. // Update thumbnail - show loading spinner if fetching metadata
  821. const thumbnailContainer = videoElement.querySelector('.w-16.h-12');
  822. if (thumbnailContainer) {
  823. if (video.isFetchingMetadata) {
  824. thumbnailContainer.innerHTML = `
  825. <div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  826. <svg class="animate-spin h-5 w-5 text-[#155dfc]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
  827. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  828. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
  829. </svg>
  830. </div>`;
  831. } else if (video.thumbnail) {
  832. thumbnailContainer.innerHTML = `<img src="${video.thumbnail}" alt="${video.getDisplayName()}" class="w-full h-full object-cover">`;
  833. } else {
  834. thumbnailContainer.innerHTML = `
  835. <div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  836. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-[#90a1b9]">
  837. <path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
  838. </svg>
  839. </div>`;
  840. }
  841. }
  842. // Update title and loading message
  843. const titleContainer = videoElement.querySelector('.min-w-0.flex-1');
  844. if (titleContainer) {
  845. const titleElement = titleContainer.querySelector('.text-sm.text-white.truncate');
  846. if (titleElement) {
  847. titleElement.textContent = video.getDisplayName();
  848. }
  849. // Update or remove "Fetching info..." message
  850. const existingLoadingMsg = titleContainer.querySelector('.text-xs.text-\\[\\#155dfc\\]');
  851. if (video.isFetchingMetadata && !existingLoadingMsg) {
  852. const loadingMsg = document.createElement('div');
  853. loadingMsg.className = 'text-xs text-[#155dfc] animate-pulse';
  854. loadingMsg.textContent = 'Fetching info...';
  855. titleContainer.appendChild(loadingMsg);
  856. } else if (!video.isFetchingMetadata && existingLoadingMsg) {
  857. existingLoadingMsg.remove();
  858. }
  859. }
  860. // Update duration
  861. const durationElement = videoElement.querySelector('.text-sm.text-\\[\\#cad5e2\\].text-center');
  862. if (durationElement) {
  863. durationElement.textContent = video.duration || '--:--';
  864. }
  865. // Update quality dropdown
  866. const qualitySelect = videoElement.querySelector('.quality-select');
  867. if (qualitySelect) {
  868. qualitySelect.value = video.quality;
  869. }
  870. // Update format dropdown
  871. const formatSelect = videoElement.querySelector('.format-select');
  872. if (formatSelect) {
  873. formatSelect.value = video.format;
  874. }
  875. // Update status badge with progress
  876. const statusBadge = videoElement.querySelector('.status-badge');
  877. if (statusBadge) {
  878. statusBadge.className = `status-badge ${video.status}`;
  879. statusBadge.textContent = this.getStatusText(video);
  880. // Add progress bar for downloading/converting states
  881. if (video.status === 'downloading' || video.status === 'converting') {
  882. const progress = video.progress || 0;
  883. statusBadge.style.background = `linear-gradient(to right, #155dfc ${progress}%, #314158 ${progress}%)`;
  884. } else {
  885. statusBadge.style.background = '';
  886. }
  887. }
  888. }
  889. updateStatsDisplay() {
  890. const stats = this.state.getStats();
  891. // Update UI with current statistics
  892. }
  893. updateConfigUI(config) {
  894. this.updateSavePathDisplay();
  895. this.initializeDropdowns();
  896. }
  897. updateStatusMessage(message) {
  898. const statusElement = document.getElementById('statusMessage');
  899. if (statusElement) {
  900. statusElement.textContent = message;
  901. }
  902. // Auto-clear success messages
  903. if (!message.toLowerCase().includes('error') && !message.toLowerCase().includes('failed')) {
  904. setTimeout(() => {
  905. if (statusElement && statusElement.textContent === message) {
  906. statusElement.textContent = 'Ready to download videos';
  907. }
  908. }, 5000);
  909. }
  910. }
  911. showError(message) {
  912. this.updateStatusMessage(`Error: ${message}`);
  913. console.error('App Error:', message);
  914. this.eventBus.emit('app:error', { type: 'user', message });
  915. }
  916. displayError(errorData) {
  917. const message = errorData.error?.message || errorData.message || 'An error occurred';
  918. this.updateStatusMessage(`Error: ${message}`);
  919. }
  920. // Keyboard navigation
  921. initializeKeyboardNavigation() {
  922. // Basic keyboard navigation setup
  923. document.addEventListener('keydown', (e) => {
  924. if (e.ctrlKey || e.metaKey) {
  925. switch (e.key) {
  926. case 'a':
  927. e.preventDefault();
  928. this.state.selectAllVideos();
  929. break;
  930. case 'd':
  931. e.preventDefault();
  932. this.handleDownloadVideos();
  933. break;
  934. }
  935. }
  936. });
  937. }
  938. // Ensure save directory exists
  939. async ensureSaveDirectoryExists() {
  940. const savePath = this.state.config.savePath;
  941. if (!savePath || !window.electronAPI) return;
  942. try {
  943. const result = await window.electronAPI.createDirectory(savePath);
  944. if (!result.success) {
  945. console.warn('Failed to create save directory:', result.error);
  946. } else {
  947. console.log('Save directory ready:', result.path);
  948. }
  949. } catch (error) {
  950. console.error('Error creating directory:', error);
  951. }
  952. }
  953. // Check binary status and validate with blocking dialog if missing
  954. async checkAndValidateBinaries() {
  955. if (!window.IPCManager || !window.IPCManager.isAvailable()) return;
  956. try {
  957. const versions = await window.IPCManager.checkBinaryVersions();
  958. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  959. const ytdlp = versions.ytDlp || versions.ytdlp;
  960. const ffmpeg = versions.ffmpeg;
  961. if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) {
  962. this.updateDependenciesButtonStatus('missing');
  963. this.updateBinaryVersionDisplay(null);
  964. // Show blocking dialog to warn user
  965. await this.showMissingBinariesDialog(ytdlp, ffmpeg);
  966. } else {
  967. this.updateDependenciesButtonStatus('ok');
  968. // Normalize the format for display
  969. const normalizedVersions = {
  970. ytdlp: ytdlp,
  971. ffmpeg: ffmpeg
  972. };
  973. this.updateBinaryVersionDisplay(normalizedVersions);
  974. }
  975. } catch (error) {
  976. console.error('Error checking binary status:', error);
  977. // Set missing status on error
  978. this.updateDependenciesButtonStatus('missing');
  979. this.updateBinaryVersionDisplay(null);
  980. // Show dialog on error too
  981. await this.showMissingBinariesDialog(null, null);
  982. }
  983. }
  984. // Show blocking dialog when binaries are missing
  985. async showMissingBinariesDialog(ytdlp, ffmpeg) {
  986. // Determine which binaries are missing
  987. const missingBinaries = [];
  988. if (!ytdlp || !ytdlp.available) missingBinaries.push('yt-dlp');
  989. if (!ffmpeg || !ffmpeg.available) missingBinaries.push('ffmpeg');
  990. const missingList = missingBinaries.length > 0
  991. ? missingBinaries.join(', ')
  992. : 'yt-dlp and ffmpeg';
  993. if (window.electronAPI && window.electronAPI.showErrorDialog) {
  994. // Use native Electron dialog
  995. await window.electronAPI.showErrorDialog({
  996. title: 'Required Binaries Missing',
  997. message: `The following required binaries are missing: ${missingList}`,
  998. detail: 'Please run "npm run setup" in the terminal to download the required binaries.\n\n' +
  999. 'Without these binaries, GrabZilla cannot download or convert videos.\n\n' +
  1000. 'After running "npm run setup", restart the application.'
  1001. });
  1002. } else {
  1003. // Fallback to browser alert
  1004. alert(
  1005. `⚠️ Required Binaries Missing\n\n` +
  1006. `Missing: ${missingList}\n\n` +
  1007. `Please run "npm run setup" to download the required binaries.\n\n` +
  1008. `Without these binaries, GrabZilla cannot download or convert videos.`
  1009. );
  1010. }
  1011. }
  1012. // Check binary status and update UI (non-blocking version for updates)
  1013. async checkBinaryStatus() {
  1014. if (!window.IPCManager || !window.IPCManager.isAvailable()) return;
  1015. try {
  1016. const versions = await window.IPCManager.checkBinaryVersions();
  1017. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  1018. const ytdlp = versions.ytDlp || versions.ytdlp;
  1019. const ffmpeg = versions.ffmpeg;
  1020. if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) {
  1021. this.updateDependenciesButtonStatus('missing');
  1022. this.updateBinaryVersionDisplay(null);
  1023. } else {
  1024. this.updateDependenciesButtonStatus('ok');
  1025. // Normalize the format for display
  1026. const normalizedVersions = {
  1027. ytdlp: ytdlp,
  1028. ffmpeg: ffmpeg
  1029. };
  1030. this.updateBinaryVersionDisplay(normalizedVersions);
  1031. }
  1032. } catch (error) {
  1033. console.error('Error checking binary status:', error);
  1034. // Set missing status on error
  1035. this.updateDependenciesButtonStatus('missing');
  1036. this.updateBinaryVersionDisplay(null);
  1037. }
  1038. }
  1039. updateBinaryVersionDisplay(versions) {
  1040. const statusMessage = document.getElementById('statusMessage');
  1041. const ytdlpVersionNumber = document.getElementById('ytdlpVersionNumber');
  1042. const ytdlpUpdateBadge = document.getElementById('ytdlpUpdateBadge');
  1043. const ffmpegVersionNumber = document.getElementById('ffmpegVersionNumber');
  1044. const lastUpdateCheck = document.getElementById('lastUpdateCheck');
  1045. if (!versions) {
  1046. // Binaries missing
  1047. if (statusMessage) statusMessage.textContent = 'Ready to download videos - Binaries required';
  1048. if (ytdlpVersionNumber) ytdlpVersionNumber.textContent = 'missing';
  1049. if (ffmpegVersionNumber) ffmpegVersionNumber.textContent = 'missing';
  1050. if (ytdlpUpdateBadge) ytdlpUpdateBadge.classList.add('hidden');
  1051. if (lastUpdateCheck) lastUpdateCheck.textContent = '--';
  1052. return;
  1053. }
  1054. // Update yt-dlp version
  1055. if (ytdlpVersionNumber) {
  1056. const ytdlpVersion = versions.ytdlp?.version || 'unknown';
  1057. ytdlpVersionNumber.textContent = ytdlpVersion;
  1058. }
  1059. // Show/hide update badge for yt-dlp
  1060. if (ytdlpUpdateBadge) {
  1061. if (versions.ytdlp?.updateAvailable) {
  1062. ytdlpUpdateBadge.classList.remove('hidden');
  1063. ytdlpUpdateBadge.title = `Update available: ${versions.ytdlp.latestVersion || 'newer version'}`;
  1064. } else {
  1065. ytdlpUpdateBadge.classList.add('hidden');
  1066. }
  1067. }
  1068. // Update ffmpeg version
  1069. if (ffmpegVersionNumber) {
  1070. const ffmpegVersion = versions.ffmpeg?.version || 'unknown';
  1071. ffmpegVersionNumber.textContent = ffmpegVersion;
  1072. }
  1073. // Update last check timestamp
  1074. if (lastUpdateCheck) {
  1075. const now = new Date();
  1076. const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  1077. lastUpdateCheck.textContent = `checked ${timeString}`;
  1078. lastUpdateCheck.title = `Last update check: ${now.toLocaleString()}`;
  1079. }
  1080. // Update status message
  1081. if (statusMessage) {
  1082. const hasUpdates = versions.ytdlp?.updateAvailable;
  1083. statusMessage.textContent = hasUpdates ?
  1084. 'Update available for yt-dlp' :
  1085. 'Ready to download videos';
  1086. }
  1087. }
  1088. updateDependenciesButtonStatus(status) {
  1089. const btn = document.getElementById('updateDepsBtn');
  1090. if (!btn) return;
  1091. if (status === 'missing') {
  1092. btn.classList.add('bg-red-600', 'animate-pulse');
  1093. btn.classList.remove('bg-[#314158]');
  1094. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">⚠️ Required';
  1095. } else {
  1096. btn.classList.remove('bg-red-600', 'animate-pulse');
  1097. btn.classList.add('bg-[#314158]');
  1098. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">Check for Updates';
  1099. }
  1100. }
  1101. // State persistence
  1102. async loadState() {
  1103. try {
  1104. const savedState = localStorage.getItem('grabzilla-state');
  1105. if (savedState) {
  1106. const data = JSON.parse(savedState);
  1107. this.state.fromJSON(data);
  1108. console.log('✅ Loaded saved state');
  1109. // Re-render video list to show restored videos
  1110. this.renderVideoList();
  1111. this.updateSavePathDisplay();
  1112. this.updateStatsDisplay();
  1113. }
  1114. } catch (error) {
  1115. console.warn('Failed to load saved state:', error);
  1116. }
  1117. }
  1118. async saveState() {
  1119. try {
  1120. const stateData = this.state.toJSON();
  1121. localStorage.setItem('grabzilla-state', JSON.stringify(stateData));
  1122. } catch (error) {
  1123. console.warn('Failed to save state:', error);
  1124. }
  1125. }
  1126. // Lifecycle methods
  1127. handleInitializationError(error) {
  1128. // Show fallback UI or error message
  1129. const statusElement = document.getElementById('statusMessage');
  1130. if (statusElement) {
  1131. statusElement.textContent = 'Failed to initialize application';
  1132. }
  1133. }
  1134. destroy() {
  1135. // Clean up resources
  1136. if (this.state) {
  1137. this.saveState();
  1138. }
  1139. // Remove event listeners
  1140. this.eventBus?.removeAllListeners();
  1141. this.initialized = false;
  1142. console.log('🧹 GrabZilla app destroyed');
  1143. }
  1144. }
  1145. // Initialize function to be called after all scripts are loaded
  1146. window.initializeGrabZilla = function() {
  1147. window.app = new GrabZillaApp();
  1148. window.app.init();
  1149. };
  1150. // Auto-save state on page unload
  1151. window.addEventListener('beforeunload', () => {
  1152. if (window.app?.initialized) {
  1153. window.app.saveState();
  1154. }
  1155. });
  1156. // Export the app class
  1157. if (typeof module !== 'undefined' && module.exports) {
  1158. module.exports = GrabZillaApp;
  1159. } else {
  1160. window.GrabZillaApp = GrabZillaApp;
  1161. }