app.js 54 KB

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