app.js 107 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713
  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. this.state.on('videoSelectionChanged', (data) => this.onVideoSelectionChanged(data));
  96. // UI event listeners
  97. this.setupButtonEventListeners();
  98. this.setupInputEventListeners();
  99. this.setupVideoListEventListeners();
  100. }
  101. // Set up button event listeners
  102. setupButtonEventListeners() {
  103. // Add Video button
  104. const addVideoBtn = document.getElementById('addVideoBtn');
  105. if (addVideoBtn) {
  106. addVideoBtn.addEventListener('click', () => this.handleAddVideo());
  107. }
  108. // Import URLs button
  109. const importUrlsBtn = document.getElementById('importUrlsBtn');
  110. if (importUrlsBtn) {
  111. importUrlsBtn.addEventListener('click', () => this.handleImportUrls());
  112. }
  113. // Save Path button
  114. const savePathBtn = document.getElementById('savePathBtn');
  115. if (savePathBtn) {
  116. savePathBtn.addEventListener('click', () => this.handleSelectSavePath());
  117. }
  118. // Note: Cookie file and save path buttons removed from main panel,
  119. // now only accessible via Settings modal
  120. // Clipboard monitoring toggle
  121. const clipboardToggle = document.getElementById('clipboardMonitorToggle');
  122. if (clipboardToggle) {
  123. clipboardToggle.addEventListener('change', (e) => this.handleClipboardToggle(e.target.checked));
  124. }
  125. // Control panel buttons
  126. const clearListBtn = document.getElementById('clearListBtn');
  127. if (clearListBtn) {
  128. clearListBtn.addEventListener('click', () => this.handleClearList());
  129. }
  130. const downloadVideosBtn = document.getElementById('downloadVideosBtn');
  131. if (downloadVideosBtn) {
  132. downloadVideosBtn.addEventListener('click', () => this.handleDownloadVideos());
  133. }
  134. const cancelDownloadsBtn = document.getElementById('cancelDownloadsBtn');
  135. if (cancelDownloadsBtn) {
  136. cancelDownloadsBtn.addEventListener('click', () => this.handleCancelDownloads());
  137. }
  138. const updateDepsBtn = document.getElementById('updateDepsBtn');
  139. if (updateDepsBtn) {
  140. updateDepsBtn.addEventListener('click', () => this.handleUpdateDependencies());
  141. }
  142. const exportListBtn = document.getElementById('exportListBtn');
  143. if (exportListBtn) {
  144. exportListBtn.addEventListener('click', () => this.handleExportList());
  145. }
  146. const importListBtn = document.getElementById('importListBtn');
  147. if (importListBtn) {
  148. importListBtn.addEventListener('click', () => this.handleImportList());
  149. }
  150. const settingsBtn = document.getElementById('settingsBtn');
  151. if (settingsBtn) {
  152. settingsBtn.addEventListener('click', () => this.showSettingsModal());
  153. }
  154. const settingsBtn2 = document.getElementById('settingsBtn2');
  155. if (settingsBtn2) {
  156. settingsBtn2.addEventListener('click', () => this.showSettingsModal());
  157. }
  158. const showHistoryBtn = document.getElementById('showHistoryBtn');
  159. if (showHistoryBtn) {
  160. showHistoryBtn.addEventListener('click', () => this.showHistoryModal());
  161. }
  162. }
  163. // Set up input event listeners
  164. setupInputEventListeners() {
  165. // URL input - no paste handler needed, user clicks "Add Video" button
  166. const urlInput = document.getElementById('urlInput');
  167. if (urlInput) {
  168. // Optional: could add real-time validation feedback here
  169. }
  170. // Configuration inputs
  171. const defaultQuality = document.getElementById('defaultQuality');
  172. if (defaultQuality) {
  173. defaultQuality.addEventListener('change', (e) => {
  174. const newValue = e.target.value;
  175. this.state.updateConfig({ defaultQuality: newValue });
  176. // Ask if user wants to update existing videos
  177. this.promptUpdateExistingVideos('quality', newValue);
  178. });
  179. }
  180. const defaultFormat = document.getElementById('defaultFormat');
  181. if (defaultFormat) {
  182. defaultFormat.addEventListener('change', (e) => {
  183. const newValue = e.target.value;
  184. this.state.updateConfig({ defaultFormat: newValue });
  185. // Ask if user wants to update existing videos
  186. this.promptUpdateExistingVideos('format', newValue);
  187. });
  188. }
  189. }
  190. // Set up video list event listeners
  191. setupVideoListEventListeners() {
  192. const videoList = document.getElementById('videoList');
  193. if (videoList) {
  194. videoList.addEventListener('click', (e) => this.handleVideoListClick(e));
  195. videoList.addEventListener('change', (e) => this.handleVideoListChange(e));
  196. this.setupDragAndDrop(videoList);
  197. }
  198. }
  199. // Set up drag-and-drop reordering
  200. setupDragAndDrop(videoList) {
  201. let draggedElement = null;
  202. let draggedVideoId = null;
  203. videoList.addEventListener('dragstart', (e) => {
  204. const videoItem = e.target.closest('.video-item');
  205. if (!videoItem) return;
  206. draggedElement = videoItem;
  207. draggedVideoId = videoItem.dataset.videoId;
  208. videoItem.classList.add('opacity-50');
  209. e.dataTransfer.effectAllowed = 'move';
  210. e.dataTransfer.setData('text/html', videoItem.innerHTML);
  211. });
  212. videoList.addEventListener('dragover', (e) => {
  213. e.preventDefault();
  214. const videoItem = e.target.closest('.video-item');
  215. if (!videoItem || videoItem === draggedElement) return;
  216. e.dataTransfer.dropEffect = 'move';
  217. // Visual feedback - show where it will drop
  218. const rect = videoItem.getBoundingClientRect();
  219. const midpoint = rect.top + rect.height / 2;
  220. if (e.clientY < midpoint) {
  221. videoItem.classList.add('border-t-2', 'border-[#155dfc]');
  222. videoItem.classList.remove('border-b-2');
  223. } else {
  224. videoItem.classList.add('border-b-2', 'border-[#155dfc]');
  225. videoItem.classList.remove('border-t-2');
  226. }
  227. });
  228. videoList.addEventListener('dragleave', (e) => {
  229. const videoItem = e.target.closest('.video-item');
  230. if (videoItem) {
  231. videoItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  232. }
  233. });
  234. videoList.addEventListener('drop', (e) => {
  235. e.preventDefault();
  236. const targetItem = e.target.closest('.video-item');
  237. if (!targetItem || !draggedVideoId) return;
  238. const targetVideoId = targetItem.dataset.videoId;
  239. // Calculate drop position
  240. const rect = targetItem.getBoundingClientRect();
  241. const midpoint = rect.top + rect.height / 2;
  242. const dropBefore = e.clientY < midpoint;
  243. // Reorder in state
  244. this.handleVideoReorder(draggedVideoId, targetVideoId, dropBefore);
  245. // Clean up visual feedback
  246. targetItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  247. });
  248. videoList.addEventListener('dragend', (e) => {
  249. const videoItem = e.target.closest('.video-item');
  250. if (videoItem) {
  251. videoItem.classList.remove('opacity-50');
  252. }
  253. // Clean up all visual feedback
  254. document.querySelectorAll('.video-item').forEach(item => {
  255. item.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]');
  256. });
  257. draggedElement = null;
  258. draggedVideoId = null;
  259. });
  260. }
  261. handleVideoReorder(draggedId, targetId, insertBefore) {
  262. const videos = this.state.getVideos();
  263. const draggedIndex = videos.findIndex(v => v.id === draggedId);
  264. const targetIndex = videos.findIndex(v => v.id === targetId);
  265. if (draggedIndex === -1 || targetIndex === -1) return;
  266. let newIndex = targetIndex;
  267. if (draggedIndex < targetIndex && !insertBefore) {
  268. newIndex = targetIndex;
  269. } else if (draggedIndex > targetIndex && insertBefore) {
  270. newIndex = targetIndex;
  271. } else if (insertBefore) {
  272. newIndex = targetIndex;
  273. } else {
  274. newIndex = targetIndex + 1;
  275. }
  276. this.state.reorderVideos(draggedIndex, newIndex);
  277. }
  278. // Handle clicks in video list (checkboxes, delete buttons)
  279. handleVideoListClick(event) {
  280. const target = event.target;
  281. const videoItem = target.closest('.video-item');
  282. if (!videoItem) return;
  283. const videoId = videoItem.dataset.videoId;
  284. if (!videoId) return;
  285. // Handle checkbox click
  286. if (target.closest('.video-checkbox')) {
  287. event.preventDefault();
  288. this.toggleVideoSelection(videoId);
  289. return;
  290. }
  291. // Handle thumbnail click (preview)
  292. if (target.closest('.video-thumbnail-container')) {
  293. event.preventDefault();
  294. const previewUrl = target.closest('.video-thumbnail-container').dataset.previewUrl;
  295. if (previewUrl) {
  296. this.showVideoPreview(videoId, previewUrl);
  297. }
  298. return;
  299. }
  300. // Handle delete button click
  301. if (target.closest('.delete-video-btn')) {
  302. event.preventDefault();
  303. this.handleRemoveVideo(videoId);
  304. return;
  305. }
  306. // Handle pause/resume button click
  307. if (target.closest('.pause-resume-btn')) {
  308. event.preventDefault();
  309. const btn = target.closest('.pause-resume-btn');
  310. const action = btn.dataset.action;
  311. if (action === 'pause') {
  312. this.handlePauseDownload(videoId);
  313. } else if (action === 'resume') {
  314. this.handleResumeDownload(videoId);
  315. }
  316. return;
  317. }
  318. }
  319. // Handle dropdown changes in video list (quality, format)
  320. handleVideoListChange(event) {
  321. const target = event.target;
  322. const videoItem = target.closest('.video-item');
  323. if (!videoItem) return;
  324. const videoId = videoItem.dataset.videoId;
  325. if (!videoId) return;
  326. // Handle quality dropdown change
  327. if (target.classList.contains('quality-select')) {
  328. const quality = target.value;
  329. this.state.updateVideo(videoId, { quality });
  330. console.log(`Updated video ${videoId} quality to ${quality}`);
  331. return;
  332. }
  333. // Handle format dropdown change
  334. if (target.classList.contains('format-select')) {
  335. const format = target.value;
  336. this.state.updateVideo(videoId, { format });
  337. console.log(`Updated video ${videoId} format to ${format}`);
  338. return;
  339. }
  340. }
  341. // Toggle video selection
  342. toggleVideoSelection(videoId) {
  343. this.state.toggleVideoSelection(videoId);
  344. this.updateVideoCheckbox(videoId);
  345. }
  346. // Update checkbox visual state
  347. updateVideoCheckbox(videoId) {
  348. const videoItem = document.querySelector(`[data-video-id="${videoId}"]`);
  349. if (!videoItem) return;
  350. const checkbox = videoItem.querySelector('.video-checkbox');
  351. if (!checkbox) return;
  352. const isSelected = this.state.ui.selectedVideos.includes(videoId);
  353. checkbox.setAttribute('aria-checked', isSelected ? 'true' : 'false');
  354. // Update checkbox SVG
  355. const svg = checkbox.querySelector('svg');
  356. if (svg) {
  357. if (isSelected) {
  358. svg.innerHTML = `<rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="currentColor" rx="2" />
  359. <path d="M5 8L7 10L11 6" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
  360. } else {
  361. svg.innerHTML = `<rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="none" rx="2" />`;
  362. }
  363. }
  364. }
  365. // Remove video from list
  366. handleRemoveVideo(videoId) {
  367. try {
  368. const video = this.state.getVideo(videoId);
  369. if (video && confirm(`Remove "${video.getDisplayName()}"?`)) {
  370. this.state.removeVideo(videoId);
  371. this.updateStatusMessage('Video removed');
  372. }
  373. } catch (error) {
  374. console.error('Error removing video:', error);
  375. this.showError(`Failed to remove video: ${error.message}`);
  376. }
  377. }
  378. /**
  379. * Check for duplicate URLs in the list
  380. * @param {string[]} urls - URLs to check
  381. * @returns {Object} Object with unique and duplicate URLs
  382. */
  383. checkForDuplicates(urls) {
  384. const unique = [];
  385. const duplicates = [];
  386. for (const url of urls) {
  387. const normalizedUrl = window.URLValidator ? window.URLValidator.normalizeUrl(url) : url;
  388. const existingVideo = this.state.videos.find(v => v.getNormalizedUrl() === normalizedUrl);
  389. if (existingVideo) {
  390. duplicates.push({
  391. url,
  392. existingVideo
  393. });
  394. } else {
  395. unique.push(url);
  396. }
  397. }
  398. return { unique, duplicates };
  399. }
  400. /**
  401. * Show dialog for handling duplicate URLs
  402. * @param {Object} duplicateInfo - Info about duplicates
  403. * @returns {Promise<string>} Action: 'skip', 'replace', 'keep-both', or null (cancel)
  404. */
  405. async handleDuplicateUrls(duplicateInfo) {
  406. const duplicateCount = duplicateInfo.duplicates.length;
  407. const uniqueCount = duplicateInfo.unique.length;
  408. // Show titles of duplicate videos
  409. const duplicateTitles = duplicateInfo.duplicates
  410. .map(dup => `• ${dup.existingVideo.title}`)
  411. .slice(0, 5) // Show max 5
  412. .join('\n');
  413. const moreText = duplicateCount > 5 ? `\n... and ${duplicateCount - 5} more` : '';
  414. const message =
  415. `Found ${duplicateCount} duplicate URL(s):\n\n` +
  416. duplicateTitles + moreText + '\n\n' +
  417. `Choose an action:\n\n` +
  418. `1. SKIP duplicates (add ${uniqueCount} new video(s) only)\n` +
  419. `2. REPLACE existing videos with new ones\n` +
  420. `3. KEEP BOTH (add duplicates again)\n\n` +
  421. `Enter 1, 2, or 3:`;
  422. const choice = prompt(message);
  423. if (choice === '1') {
  424. return 'skip';
  425. } else if (choice === '2') {
  426. return 'replace';
  427. } else if (choice === '3') {
  428. return 'keep-both';
  429. } else {
  430. return null; // Cancel
  431. }
  432. }
  433. /**
  434. * Handle playlist URL - show modal with all videos
  435. * @param {string} playlistUrl - YouTube playlist URL
  436. */
  437. async handlePlaylistUrl(playlistUrl) {
  438. try {
  439. this.updateStatusMessage('Extracting playlist...');
  440. const result = await window.electronAPI.extractPlaylistVideos(playlistUrl);
  441. if (!result.success) {
  442. this.showError('Failed to extract playlist');
  443. return;
  444. }
  445. this.showPlaylistModal(result);
  446. } catch (error) {
  447. console.error('Error handling playlist:', error);
  448. this.showError(`Playlist extraction failed: ${error.message}`);
  449. }
  450. }
  451. /**
  452. * Show playlist modal with video list
  453. * @param {Object} playlistData - Playlist data from extraction
  454. */
  455. showPlaylistModal(playlistData) {
  456. const modal = document.getElementById('playlistModal');
  457. const title = document.getElementById('playlistTitle');
  458. const info = document.getElementById('playlistInfo');
  459. const videoList = document.getElementById('playlistVideoList');
  460. if (!modal || !title || !info || !videoList) return;
  461. // Update modal content
  462. title.textContent = `Playlist (${playlistData.videoCount} videos)`;
  463. info.textContent = `${playlistData.videoCount} video(s) found in this playlist`;
  464. // Clear previous video list
  465. videoList.innerHTML = '';
  466. // Store playlist videos for later use
  467. this.currentPlaylistVideos = playlistData.videos;
  468. // Create checkbox for each video
  469. playlistData.videos.forEach((video, index) => {
  470. const videoItem = document.createElement('label');
  471. videoItem.className = 'flex items-center gap-3 p-2 hover:bg-[#45556c]/30 rounded cursor-pointer';
  472. videoItem.innerHTML = `
  473. <input type="checkbox" class="playlist-video-checkbox w-4 h-4" data-index="${index}" checked>
  474. <img src="${video.thumbnail || 'assets/icons/video-placeholder.svg'}" alt="" class="w-16 h-12 object-cover rounded">
  475. <div class="flex-1 min-w-0">
  476. <p class="text-sm text-white truncate">${video.title}</p>
  477. <p class="text-xs text-[#90a1b9]">${video.duration ? this.formatDuration(video.duration) : 'Unknown duration'}</p>
  478. </div>
  479. `;
  480. videoList.appendChild(videoItem);
  481. });
  482. // Setup modal event listeners
  483. this.setupPlaylistModalListeners();
  484. // Show modal
  485. modal.classList.remove('hidden');
  486. modal.classList.add('flex');
  487. }
  488. /**
  489. * Setup event listeners for playlist modal
  490. */
  491. setupPlaylistModalListeners() {
  492. const modal = document.getElementById('playlistModal');
  493. const closeBtn = document.getElementById('closePlaylistModal');
  494. const cancelBtn = document.getElementById('cancelPlaylistBtn');
  495. const downloadBtn = document.getElementById('downloadSelectedPlaylistBtn');
  496. const selectAllCheckbox = document.getElementById('selectAllPlaylistVideos');
  497. // Close modal handlers
  498. const closeModal = () => {
  499. modal.classList.remove('flex');
  500. modal.classList.add('hidden');
  501. this.currentPlaylistVideos = null;
  502. };
  503. closeBtn?.addEventListener('click', closeModal);
  504. cancelBtn?.addEventListener('click', closeModal);
  505. // Select all handler
  506. selectAllCheckbox?.addEventListener('change', (e) => {
  507. const checkboxes = document.querySelectorAll('.playlist-video-checkbox');
  508. checkboxes.forEach(cb => cb.checked = e.target.checked);
  509. });
  510. // Download selected handler
  511. downloadBtn?.addEventListener('click', async () => {
  512. const checkboxes = document.querySelectorAll('.playlist-video-checkbox:checked');
  513. const selectedIndices = Array.from(checkboxes).map(cb => parseInt(cb.dataset.index));
  514. if (selectedIndices.length === 0) {
  515. this.showError('Please select at least one video');
  516. return;
  517. }
  518. const selectedUrls = selectedIndices.map(i => this.currentPlaylistVideos[i].url);
  519. // Add selected videos to queue
  520. const results = await this.state.addVideosFromUrls(selectedUrls);
  521. this.showToast(`Added ${results.successful.length} video(s) from playlist`, 'success');
  522. closeModal();
  523. });
  524. }
  525. /**
  526. * Format duration in seconds to MM:SS
  527. * @param {number} seconds - Duration in seconds
  528. * @returns {string} Formatted duration
  529. */
  530. formatDuration(seconds) {
  531. if (!seconds || isNaN(seconds)) return 'Unknown';
  532. const mins = Math.floor(seconds / 60);
  533. const secs = Math.floor(seconds % 60);
  534. return `${mins}:${secs.toString().padStart(2, '0')}`;
  535. }
  536. /**
  537. * Show video preview modal
  538. * @param {string} videoId - Video ID
  539. * @param {string} url - Video URL
  540. */
  541. async showVideoPreview(videoId, url) {
  542. const modal = document.getElementById('previewModal');
  543. const player = document.getElementById('previewPlayer');
  544. const title = document.getElementById('previewTitle');
  545. const duration = document.getElementById('previewDuration');
  546. const views = document.getElementById('previewViews');
  547. const likes = document.getElementById('previewLikes');
  548. const description = document.getElementById('previewDescription');
  549. const downloadBtn = document.getElementById('downloadFromPreviewBtn');
  550. if (!modal || !player) return;
  551. const video = this.state.getVideo(videoId);
  552. if (!video) return;
  553. // Store current video for download button
  554. this.currentPreviewVideoId = videoId;
  555. // Set title
  556. title.textContent = video.title || video.url;
  557. // Extract video ID and create embed URL
  558. let embedUrl = '';
  559. if (url.includes('youtube.com') || url.includes('youtu.be')) {
  560. const youtubeIdMatch = url.match(/(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
  561. if (youtubeIdMatch) {
  562. embedUrl = `https://www.youtube.com/embed/${youtubeIdMatch[1]}`;
  563. }
  564. } else if (url.includes('vimeo.com')) {
  565. const vimeoIdMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
  566. if (vimeoIdMatch) {
  567. embedUrl = `https://player.vimeo.com/video/${vimeoIdMatch[1]}`;
  568. }
  569. }
  570. if (!embedUrl) {
  571. this.showError('Preview not available for this video');
  572. return;
  573. }
  574. // Set iframe src
  575. player.src = embedUrl;
  576. // Set duration
  577. if (video.duration) {
  578. duration.querySelector('span').textContent = video.duration;
  579. } else {
  580. duration.querySelector('span').textContent = '--:--';
  581. }
  582. // Show loading state for other info
  583. views.querySelector('span').textContent = 'Loading...';
  584. likes.querySelector('span').textContent = 'Loading...';
  585. description.textContent = 'Loading video information...';
  586. // Fetch full metadata (views, likes, description)
  587. try {
  588. const metadata = await window.electronAPI.getVideoMetadata(url);
  589. if (metadata.views) {
  590. views.querySelector('span').textContent = this.formatNumber(metadata.views);
  591. }
  592. if (metadata.likes) {
  593. likes.querySelector('span').textContent = this.formatNumber(metadata.likes);
  594. }
  595. if (metadata.description) {
  596. description.textContent = metadata.description.slice(0, 500) + (metadata.description.length > 500 ? '...' : '');
  597. }
  598. } catch (error) {
  599. console.error('Error fetching preview metadata:', error);
  600. views.querySelector('span').textContent = 'N/A';
  601. likes.querySelector('span').textContent = 'N/A';
  602. description.textContent = 'Unable to load video information.';
  603. }
  604. // Setup modal event listeners
  605. this.setupPreviewModalListeners();
  606. // Show modal
  607. modal.classList.remove('hidden');
  608. modal.classList.add('flex');
  609. }
  610. /**
  611. * Setup event listeners for preview modal
  612. */
  613. setupPreviewModalListeners() {
  614. const modal = document.getElementById('previewModal');
  615. const closeBtn = document.getElementById('closePreviewModal');
  616. const closeBtn2 = document.getElementById('closePreviewBtn');
  617. const downloadBtn = document.getElementById('downloadFromPreviewBtn');
  618. const player = document.getElementById('previewPlayer');
  619. const closeModal = () => {
  620. modal.classList.remove('flex');
  621. modal.classList.add('hidden');
  622. player.src = ''; // Stop video playback
  623. this.currentPreviewVideoId = null;
  624. };
  625. closeBtn?.addEventListener('click', closeModal);
  626. closeBtn2?.addEventListener('click', closeModal);
  627. downloadBtn?.addEventListener('click', async () => {
  628. if (this.currentPreviewVideoId) {
  629. // Mark video as selected and trigger download
  630. const video = this.state.getVideo(this.currentPreviewVideoId);
  631. if (video && video.status === 'ready') {
  632. // Select this video only
  633. this.state.clearVideoSelection();
  634. this.state.toggleVideoSelection(this.currentPreviewVideoId);
  635. // Trigger download
  636. await this.handleDownloadVideos();
  637. }
  638. }
  639. closeModal();
  640. });
  641. }
  642. /**
  643. * Format number with K/M suffix
  644. * @param {number} num - Number to format
  645. * @returns {string} Formatted number
  646. */
  647. formatNumber(num) {
  648. if (!num || isNaN(num)) return 'N/A';
  649. if (num >= 1000000) {
  650. return (num / 1000000).toFixed(1) + 'M';
  651. } else if (num >= 1000) {
  652. return (num / 1000).toFixed(1) + 'K';
  653. }
  654. return num.toString();
  655. }
  656. /**
  657. * Show toast notification
  658. * @param {string} message - Message to display
  659. * @param {string} type - Type of toast: 'success', 'error', 'warning', 'info'
  660. * @param {number} duration - Duration in milliseconds (default: 4000)
  661. */
  662. showToast(message, type = 'info', duration = 4000) {
  663. const container = document.getElementById('toastContainer');
  664. if (!container) return;
  665. // Create toast element
  666. const toast = document.createElement('div');
  667. toast.className = 'toast bg-[#314158] rounded-lg shadow-lg p-4 flex items-start gap-3 border border-[#45556c]';
  668. // Icon based on type
  669. let icon = '';
  670. let iconColor = '';
  671. switch (type) {
  672. case 'success':
  673. iconColor = '#00a63e';
  674. icon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2">
  675. <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
  676. <polyline points="22 4 12 14.01 9 11.01"/>
  677. </svg>`;
  678. break;
  679. case 'error':
  680. iconColor = '#e7000b';
  681. icon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2">
  682. <circle cx="12" cy="12" r="10"/>
  683. <line x1="15" y1="9" x2="9" y2="15"/>
  684. <line x1="9" y1="9" x2="15" y2="15"/>
  685. </svg>`;
  686. break;
  687. case 'warning':
  688. iconColor = '#ffa500';
  689. icon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2">
  690. <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
  691. <line x1="12" y1="9" x2="12" y2="13"/>
  692. <line x1="12" y1="17" x2="12.01" y2="17"/>
  693. </svg>`;
  694. break;
  695. default: // info
  696. iconColor = '#155dfc';
  697. icon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2">
  698. <circle cx="12" cy="12" r="10"/>
  699. <line x1="12" y1="16" x2="12" y2="12"/>
  700. <line x1="12" y1="8" x2="12.01" y2="8"/>
  701. </svg>`;
  702. }
  703. toast.innerHTML = `
  704. <div class="flex-shrink-0">${icon}</div>
  705. <div class="flex-1 text-sm text-[#cad5e2]">${message}</div>
  706. <button class="toast-close flex-shrink-0 text-[#90a1b9] hover:text-white transition-colors">
  707. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  708. <line x1="18" y1="6" x2="6" y2="18"/>
  709. <line x1="6" y1="6" x2="18" y2="18"/>
  710. </svg>
  711. </button>
  712. `;
  713. // Add to container
  714. container.appendChild(toast);
  715. // Close button handler
  716. const closeBtn = toast.querySelector('.toast-close');
  717. closeBtn.addEventListener('click', () => {
  718. this.removeToast(toast);
  719. });
  720. // Auto-remove after duration
  721. setTimeout(() => {
  722. this.removeToast(toast);
  723. }, duration);
  724. }
  725. /**
  726. * Remove toast notification
  727. * @param {HTMLElement} toast - Toast element to remove
  728. */
  729. removeToast(toast) {
  730. if (!toast || !toast.parentElement) return;
  731. toast.classList.add('removing');
  732. setTimeout(() => {
  733. if (toast.parentElement) {
  734. toast.parentElement.removeChild(toast);
  735. }
  736. }, 300);
  737. }
  738. /**
  739. * Show settings modal
  740. */
  741. showSettingsModal() {
  742. const modal = document.getElementById('settingsModal');
  743. if (!modal) return;
  744. // Load current settings into form
  745. this.loadSettingsIntoModal();
  746. // Set up tab switching
  747. this.setupSettingsTabs();
  748. // Set up concurrent downloads slider
  749. const concurrentSlider = document.getElementById('settings-concurrent-downloads');
  750. const concurrentValue = document.getElementById('concurrent-value');
  751. if (concurrentSlider && concurrentValue) {
  752. concurrentSlider.addEventListener('input', (e) => {
  753. concurrentValue.textContent = e.target.value;
  754. });
  755. }
  756. // Setup event listeners
  757. this.setupSettingsModalListeners();
  758. // Show modal
  759. modal.classList.remove('hidden');
  760. modal.classList.add('flex');
  761. }
  762. /**
  763. * Load current settings into modal form
  764. */
  765. loadSettingsIntoModal() {
  766. // General tab
  767. const savePathInput = document.getElementById('settings-save-path');
  768. if (savePathInput) {
  769. savePathInput.value = this.state.config.savePath || '';
  770. }
  771. // Downloads tab
  772. const concurrentSlider = document.getElementById('settings-concurrent-downloads');
  773. const concurrentValue = document.getElementById('concurrent-value');
  774. const concurrentDownloads = this.state.config.concurrentDownloads || 3;
  775. if (concurrentSlider) concurrentSlider.value = concurrentDownloads;
  776. if (concurrentValue) concurrentValue.textContent = concurrentDownloads;
  777. // Advanced tab
  778. const cookieFileInput = document.getElementById('settings-cookie-file');
  779. if (cookieFileInput) {
  780. cookieFileInput.value = this.state.config.cookieFile || '';
  781. }
  782. }
  783. /**
  784. * Setup tab switching for settings modal
  785. */
  786. setupSettingsTabs() {
  787. const tabs = document.querySelectorAll('.settings-tab');
  788. const contents = document.querySelectorAll('.settings-content');
  789. tabs.forEach(tab => {
  790. tab.addEventListener('click', () => {
  791. // Remove active class from all tabs
  792. tabs.forEach(t => t.classList.remove('active'));
  793. // Add active class to clicked tab
  794. tab.classList.add('active');
  795. // Hide all content
  796. contents.forEach(c => c.classList.add('hidden'));
  797. // Show selected content
  798. const tabName = tab.dataset.tab;
  799. const content = document.getElementById(`tab-${tabName}`);
  800. if (content) content.classList.remove('hidden');
  801. });
  802. });
  803. }
  804. /**
  805. * Setup event listeners for settings modal
  806. */
  807. setupSettingsModalListeners() {
  808. const modal = document.getElementById('settingsModal');
  809. const closeBtn = document.getElementById('closeSettingsModal');
  810. const cancelBtn = document.getElementById('cancelSettingsBtn');
  811. const saveBtn = document.getElementById('saveSettingsBtn');
  812. const changePathBtn = document.getElementById('settings-change-path');
  813. const selectCookieBtn = document.getElementById('settings-select-cookie');
  814. const clearCookieBtn = document.getElementById('settings-clear-cookie');
  815. const closeModal = () => {
  816. modal.classList.remove('flex');
  817. modal.classList.add('hidden');
  818. };
  819. closeBtn?.addEventListener('click', closeModal);
  820. cancelBtn?.addEventListener('click', closeModal);
  821. // Save settings
  822. saveBtn?.addEventListener('click', async () => {
  823. await this.saveSettings();
  824. closeModal();
  825. });
  826. // Change save path
  827. changePathBtn?.addEventListener('click', async () => {
  828. const result = await window.electronAPI.selectSaveDirectory();
  829. if (result.success && result.path) {
  830. document.getElementById('settings-save-path').value = result.path;
  831. }
  832. });
  833. // Select cookie file
  834. selectCookieBtn?.addEventListener('click', async () => {
  835. const result = await window.electronAPI.selectCookieFile();
  836. if (result.success && result.path) {
  837. document.getElementById('settings-cookie-file').value = result.path;
  838. }
  839. });
  840. // Clear cookie file
  841. clearCookieBtn?.addEventListener('click', () => {
  842. document.getElementById('settings-cookie-file').value = '';
  843. });
  844. // Export/Import/Update buttons in Data tab
  845. const exportListBtnSettings = document.getElementById('exportListBtnSettings');
  846. const importListBtnSettings = document.getElementById('importListBtnSettings');
  847. const updateDepsBtnSettings = document.getElementById('updateDepsBtnSettings');
  848. exportListBtnSettings?.addEventListener('click', () => {
  849. this.handleExportList();
  850. });
  851. importListBtnSettings?.addEventListener('click', () => {
  852. this.handleImportList();
  853. });
  854. updateDepsBtnSettings?.addEventListener('click', () => {
  855. this.handleUpdateDependencies();
  856. });
  857. // Close on Escape key
  858. const escHandler = (e) => {
  859. if (e.key === 'Escape') {
  860. closeModal();
  861. document.removeEventListener('keydown', escHandler);
  862. }
  863. };
  864. document.addEventListener('keydown', escHandler);
  865. // Close on click outside
  866. modal.addEventListener('click', (e) => {
  867. if (e.target === modal) {
  868. closeModal();
  869. }
  870. });
  871. }
  872. /**
  873. * Save settings from modal to state
  874. */
  875. async saveSettings() {
  876. const newSettings = {
  877. savePath: document.getElementById('settings-save-path')?.value || this.state.config.savePath,
  878. concurrentDownloads: parseInt(document.getElementById('settings-concurrent-downloads')?.value) || 3,
  879. autoOrganize: document.getElementById('settings-auto-organize')?.checked || false,
  880. filenameTemplate: document.getElementById('settings-filename-template')?.value || '%(title)s',
  881. autoDownloadSubtitles: document.getElementById('settings-auto-download-subtitles')?.checked || false,
  882. subtitleLanguage: document.getElementById('settings-subtitle-language')?.value || 'en',
  883. desktopNotifications: document.getElementById('settings-desktop-notifications')?.checked || true,
  884. maxRetries: parseInt(document.getElementById('settings-max-retries')?.value) || 3,
  885. timeout: parseInt(document.getElementById('settings-timeout')?.value) || 30,
  886. cookieFile: document.getElementById('settings-cookie-file')?.value || null
  887. };
  888. // Update state
  889. this.state.updateConfig(newSettings);
  890. // Update main UI if save path changed
  891. if (newSettings.savePath !== this.state.config.savePath) {
  892. const savePathDisplay = document.getElementById('savePath');
  893. if (savePathDisplay) {
  894. savePathDisplay.textContent = newSettings.savePath;
  895. }
  896. }
  897. this.showToast('Settings saved successfully', 'success');
  898. }
  899. // Show history modal
  900. showHistoryModal() {
  901. const modal = document.getElementById('historyModal');
  902. if (!modal) return;
  903. this.renderHistoryList();
  904. this.setupHistoryModalListeners();
  905. modal.classList.remove('hidden');
  906. modal.classList.add('flex');
  907. }
  908. // Render history list
  909. renderHistoryList() {
  910. const historyList = document.getElementById('historyList');
  911. const emptyState = document.getElementById('historyEmptyState');
  912. const history = this.state.getHistory();
  913. if (!historyList) return;
  914. if (history.length === 0) {
  915. historyList.innerHTML = '';
  916. if (emptyState) {
  917. emptyState.classList.remove('hidden');
  918. emptyState.classList.add('flex');
  919. }
  920. return;
  921. }
  922. if (emptyState) {
  923. emptyState.classList.add('hidden');
  924. emptyState.classList.remove('flex');
  925. }
  926. historyList.innerHTML = history.map(entry => {
  927. const downloadDate = new Date(entry.downloadedAt);
  928. const dateStr = downloadDate.toLocaleDateString();
  929. const timeStr = downloadDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  930. return `
  931. <div class="bg-[#1d293d] rounded-lg p-3 flex items-center gap-3 hover:bg-[#243447] transition-colors">
  932. <img src="${entry.thumbnail || 'assets/icons/placeholder.svg'}"
  933. alt="${entry.title}"
  934. class="w-16 h-12 object-cover rounded flex-shrink-0">
  935. <div class="flex-1 min-w-0">
  936. <h3 class="text-sm text-white font-medium truncate">${entry.title}</h3>
  937. <div class="flex items-center gap-3 text-xs text-[#90a1b9] mt-1">
  938. <span>${entry.quality} • ${entry.format !== 'None' ? entry.format : 'MP4'}</span>
  939. <span>•</span>
  940. <span>${dateStr} ${timeStr}</span>
  941. </div>
  942. </div>
  943. <div class="flex items-center gap-2 flex-shrink-0">
  944. <button class="redownload-history-btn text-[#155dfc] hover:text-white px-3 py-1 rounded border border-[#155dfc] hover:bg-[#155dfc] transition-colors text-xs"
  945. data-entry-id="${entry.id}"
  946. data-url="${entry.url}"
  947. title="Re-download this video">
  948. Re-download
  949. </button>
  950. <button class="delete-history-btn text-[#90a1b9] hover:text-[#e7000b] transition-colors p-1"
  951. data-entry-id="${entry.id}"
  952. title="Remove from history">
  953. <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
  954. <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"
  955. stroke-linecap="round" stroke-linejoin="round"/>
  956. </svg>
  957. </button>
  958. </div>
  959. </div>
  960. `;
  961. }).join('');
  962. }
  963. // Setup history modal listeners
  964. setupHistoryModalListeners() {
  965. const closeBtn = document.getElementById('closeHistoryModal');
  966. const clearBtn = document.getElementById('clearHistoryBtn');
  967. const historyList = document.getElementById('historyList');
  968. const modal = document.getElementById('historyModal');
  969. // Remove existing listeners if any
  970. if (closeBtn) {
  971. closeBtn.replaceWith(closeBtn.cloneNode(true));
  972. const newCloseBtn = document.getElementById('closeHistoryModal');
  973. newCloseBtn.addEventListener('click', () => {
  974. modal.classList.remove('flex');
  975. modal.classList.add('hidden');
  976. });
  977. }
  978. if (clearBtn) {
  979. clearBtn.replaceWith(clearBtn.cloneNode(true));
  980. const newClearBtn = document.getElementById('clearHistoryBtn');
  981. newClearBtn.addEventListener('click', () => {
  982. if (confirm('Are you sure you want to clear all download history? This cannot be undone.')) {
  983. this.state.clearHistory();
  984. this.renderHistoryList();
  985. this.showToast('Download history cleared', 'info');
  986. }
  987. });
  988. }
  989. // Handle delete and redownload buttons
  990. if (historyList) {
  991. historyList.addEventListener('click', (e) => {
  992. const deleteBtn = e.target.closest('.delete-history-btn');
  993. const redownloadBtn = e.target.closest('.redownload-history-btn');
  994. if (deleteBtn) {
  995. const entryId = deleteBtn.dataset.entryId;
  996. this.state.removeHistoryEntry(entryId);
  997. this.renderHistoryList();
  998. this.showToast('Removed from history', 'info');
  999. }
  1000. if (redownloadBtn) {
  1001. const url = redownloadBtn.dataset.url;
  1002. // Close history modal
  1003. modal.classList.remove('flex');
  1004. modal.classList.add('hidden');
  1005. // Add video to queue
  1006. const urlInput = document.getElementById('urlInput');
  1007. if (urlInput) {
  1008. urlInput.value = url;
  1009. this.handleAddVideo();
  1010. }
  1011. }
  1012. });
  1013. }
  1014. // Close on outside click
  1015. if (modal) {
  1016. modal.addEventListener('click', (e) => {
  1017. if (e.target === modal) {
  1018. modal.classList.remove('flex');
  1019. modal.classList.add('hidden');
  1020. }
  1021. });
  1022. }
  1023. }
  1024. // Event handlers
  1025. async handleAddVideo() {
  1026. const urlInput = document.getElementById('urlInput');
  1027. const inputText = urlInput?.value.trim();
  1028. if (!inputText) {
  1029. this.showError('Please enter a URL');
  1030. return;
  1031. }
  1032. try {
  1033. this.updateStatusMessage('Adding videos...');
  1034. // Check if it's a playlist URL
  1035. const playlistPattern = /[?&]list=([\w\-]+)/;
  1036. if (playlistPattern.test(inputText)) {
  1037. await this.handlePlaylistUrl(inputText);
  1038. if (urlInput) {
  1039. urlInput.value = '';
  1040. }
  1041. return;
  1042. }
  1043. // Validate URLs
  1044. const validation = window.URLValidator.validateMultipleUrls(inputText);
  1045. if (validation.invalid.length > 0) {
  1046. this.showError(`Invalid URLs found: ${validation.invalid.join(', ')}`);
  1047. return;
  1048. }
  1049. if (validation.valid.length === 0) {
  1050. this.showError('No valid URLs found');
  1051. return;
  1052. }
  1053. // Check for duplicates first
  1054. const duplicateInfo = this.checkForDuplicates(validation.valid);
  1055. let urlsToAdd = validation.valid;
  1056. let addOptions = {};
  1057. // If duplicates found, ask user what to do
  1058. if (duplicateInfo.duplicates.length > 0) {
  1059. const action = await this.handleDuplicateUrls(duplicateInfo);
  1060. if (action === 'skip') {
  1061. // Only add non-duplicate URLs
  1062. urlsToAdd = duplicateInfo.unique;
  1063. } else if (action === 'replace') {
  1064. // Remove existing duplicates, then add all URLs
  1065. duplicateInfo.duplicates.forEach(dup => {
  1066. this.state.removeVideo(dup.existingVideo.id);
  1067. });
  1068. urlsToAdd = validation.valid;
  1069. } else if (action === 'keep-both') {
  1070. // Add all URLs (duplicates will be added again)
  1071. urlsToAdd = validation.valid;
  1072. addOptions = { allowDuplicates: true };
  1073. } else {
  1074. // User cancelled
  1075. return;
  1076. }
  1077. }
  1078. // Add videos to state
  1079. const results = await this.state.addVideosFromUrls(urlsToAdd, addOptions);
  1080. // Clear input on success
  1081. if (urlInput) {
  1082. urlInput.value = '';
  1083. }
  1084. // Show results with toast
  1085. const successCount = results.successful.length;
  1086. const failedCount = results.failed.length;
  1087. if (successCount > 0) {
  1088. const message = `Added ${successCount} video(s)`;
  1089. this.showToast(message, 'success');
  1090. }
  1091. if (failedCount > 0) {
  1092. this.showToast(`${failedCount} video(s) failed to add`, 'error');
  1093. }
  1094. } catch (error) {
  1095. console.error('Error adding videos:', error);
  1096. this.showError(`Failed to add videos: ${error.message}`);
  1097. }
  1098. }
  1099. async handleImportUrls() {
  1100. if (!window.electronAPI) {
  1101. this.showError('File import requires Electron environment');
  1102. return;
  1103. }
  1104. try {
  1105. // Implementation would use Electron file dialog
  1106. this.updateStatusMessage('Import URLs functionality coming soon');
  1107. } catch (error) {
  1108. this.showError(`Failed to import URLs: ${error.message}`);
  1109. }
  1110. }
  1111. async handleSelectSavePath() {
  1112. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  1113. this.showError('Path selection requires Electron environment');
  1114. return;
  1115. }
  1116. try {
  1117. this.updateStatusMessage('Select download directory...');
  1118. const result = await window.IPCManager.selectSaveDirectory();
  1119. if (result && result.success && result.path) {
  1120. this.state.updateConfig({ savePath: result.path });
  1121. await this.ensureSaveDirectoryExists(); // Auto-create directory
  1122. this.updateSavePathDisplay();
  1123. this.updateStatusMessage(`Save path set to: ${result.path}`);
  1124. } else if (result && result.error) {
  1125. this.showError(result.error);
  1126. } else {
  1127. this.updateStatusMessage('No directory selected');
  1128. }
  1129. } catch (error) {
  1130. console.error('Error selecting save path:', error);
  1131. this.showError(`Failed to select save path: ${error.message}`);
  1132. }
  1133. }
  1134. async handleSelectCookieFile() {
  1135. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  1136. this.showError('File selection requires Electron environment');
  1137. return;
  1138. }
  1139. try {
  1140. this.updateStatusMessage('Select cookie file...');
  1141. const result = await window.IPCManager.selectCookieFile();
  1142. if (result && result.success && result.path) {
  1143. this.state.updateConfig({ cookieFile: result.path });
  1144. this.updateStatusMessage(`Cookie file set: ${result.path}`);
  1145. // Update UI to show selected cookie file
  1146. const cookieFilePathElement = document.getElementById('cookieFilePath');
  1147. if (cookieFilePathElement) {
  1148. const fileName = result.path.split('/').pop() || result.path.split('\\').pop();
  1149. cookieFilePathElement.textContent = fileName;
  1150. cookieFilePathElement.title = result.path;
  1151. }
  1152. } else if (result && result.error) {
  1153. this.showError(result.error);
  1154. } else {
  1155. this.updateStatusMessage('No file selected');
  1156. }
  1157. } catch (error) {
  1158. console.error('Error selecting cookie file:', error);
  1159. this.showError(`Failed to select cookie file: ${error.message}`);
  1160. }
  1161. }
  1162. async handleOpenFolder() {
  1163. // Check if IPC is available
  1164. if (!window.electronAPI || !window.electronAPI.openDownloadsFolder) {
  1165. this.showError('Open folder functionality requires Electron environment');
  1166. return;
  1167. }
  1168. // Get save path from state
  1169. const savePath = this.state.config.savePath;
  1170. if (!savePath) {
  1171. this.showError('No download folder configured. Please set a save path first.');
  1172. return;
  1173. }
  1174. try {
  1175. const result = await window.electronAPI.openDownloadsFolder(savePath);
  1176. if (!result.success) {
  1177. this.showError(result.error || 'Failed to open folder');
  1178. }
  1179. // On success, no message needed - folder opens in file explorer
  1180. } catch (error) {
  1181. console.error('Error opening folder:', error);
  1182. this.showError(`Failed to open folder: ${error.message}`);
  1183. }
  1184. }
  1185. async handleClipboardToggle(enabled) {
  1186. if (!window.electronAPI) {
  1187. this.showError('Clipboard monitoring requires Electron environment');
  1188. return;
  1189. }
  1190. try {
  1191. if (enabled) {
  1192. const result = await window.electronAPI.startClipboardMonitor();
  1193. if (result.success) {
  1194. // Set up listener for detected URLs
  1195. window.electronAPI.onClipboardUrlDetected((event, url) => {
  1196. this.showClipboardNotification(url);
  1197. });
  1198. this.updateStatusMessage('Clipboard monitoring enabled');
  1199. } else {
  1200. this.showError('Failed to start clipboard monitoring');
  1201. document.getElementById('clipboardMonitorToggle').checked = false;
  1202. }
  1203. } else {
  1204. await window.electronAPI.stopClipboardMonitor();
  1205. this.updateStatusMessage('Clipboard monitoring disabled');
  1206. }
  1207. } catch (error) {
  1208. console.error('Error toggling clipboard monitor:', error);
  1209. this.showError(`Clipboard monitoring error: ${error.message}`);
  1210. }
  1211. }
  1212. async showClipboardNotification(url) {
  1213. if (!window.electronAPI) return;
  1214. try {
  1215. await window.electronAPI.showNotification({
  1216. title: 'Video URL Detected',
  1217. message: `Click to add: ${url.substring(0, 50)}...`,
  1218. sound: true
  1219. });
  1220. // Auto-add the URL
  1221. const urlInput = document.getElementById('urlInput');
  1222. if (urlInput) {
  1223. urlInput.value = url;
  1224. // Trigger add action
  1225. await this.handleAddVideo();
  1226. }
  1227. } catch (error) {
  1228. console.error('Error showing clipboard notification:', error);
  1229. }
  1230. }
  1231. /**
  1232. * Export current video list to JSON file
  1233. */
  1234. async handleExportList() {
  1235. const videos = this.state.videos;
  1236. if (videos.length === 0) {
  1237. this.showError('No videos to export');
  1238. return;
  1239. }
  1240. try {
  1241. const result = await window.electronAPI.exportVideoList(videos);
  1242. if (result.cancelled) {
  1243. return; // User cancelled dialog
  1244. }
  1245. if (result.success) {
  1246. this.showToast(`Exported ${videos.length} video(s)`, 'success');
  1247. } else {
  1248. this.showToast(`Export failed: ${result.error}`, 'error');
  1249. }
  1250. } catch (error) {
  1251. console.error('Error exporting video list:', error);
  1252. this.showError('Failed to export video list');
  1253. }
  1254. }
  1255. /**
  1256. * Import video list from JSON file
  1257. */
  1258. async handleImportList() {
  1259. try {
  1260. const result = await window.electronAPI.importVideoList();
  1261. if (result.cancelled) {
  1262. return; // User cancelled dialog
  1263. }
  1264. if (!result.success) {
  1265. this.showError(`Import failed: ${result.error}`);
  1266. return;
  1267. }
  1268. // Ask user if they want to replace or merge
  1269. const action = confirm(
  1270. `Import ${result.videos.length} video(s)?\n\n` +
  1271. `Click OK to REPLACE current list\n` +
  1272. `Click Cancel to MERGE with current list`
  1273. );
  1274. if (action) {
  1275. // Replace: clear current list first
  1276. this.state.clearVideos();
  1277. }
  1278. // Add imported videos
  1279. let addedCount = 0;
  1280. let skippedCount = 0;
  1281. for (const videoData of result.videos) {
  1282. // Check for duplicates (only if merging)
  1283. if (!action) {
  1284. const existingVideo = this.state.videos.find(v => v.url === videoData.url);
  1285. if (existingVideo) {
  1286. skippedCount++;
  1287. continue;
  1288. }
  1289. }
  1290. // Create new video with imported data - Video constructor takes (url, options)
  1291. const video = new Video(videoData.url, {
  1292. title: videoData.title || 'Imported Video',
  1293. thumbnail: videoData.thumbnail || '',
  1294. duration: videoData.duration || '',
  1295. quality: videoData.quality || this.state.config.defaultQuality,
  1296. format: videoData.format || this.state.config.defaultFormat,
  1297. status: 'ready' // Always reset to ready on import
  1298. });
  1299. this.state.addVideo(video);
  1300. addedCount++;
  1301. }
  1302. const message = action
  1303. ? `Imported ${addedCount} video(s)`
  1304. : `Imported ${addedCount} video(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicate(s)` : ''}`;
  1305. this.showToast(message, 'success');
  1306. this.renderVideoList();
  1307. } catch (error) {
  1308. console.error('Error importing video list:', error);
  1309. this.showError('Failed to import video list');
  1310. }
  1311. }
  1312. handleClearList() {
  1313. const selectedVideos = this.state.getSelectedVideos();
  1314. const hasSelection = selectedVideos.length > 0;
  1315. if (hasSelection) {
  1316. // Clear only selected videos
  1317. selectedVideos.forEach(video => {
  1318. this.state.removeVideo(video.id);
  1319. });
  1320. this.updateStatusMessage(`Cleared ${selectedVideos.length} selected video(s)`);
  1321. } else {
  1322. // Clear all videos
  1323. if (this.state.getVideos().length === 0) {
  1324. this.updateStatusMessage('No videos to clear');
  1325. return;
  1326. }
  1327. const removedVideos = this.state.clearVideos();
  1328. this.updateStatusMessage(`Cleared ${removedVideos.length} video(s)`);
  1329. }
  1330. }
  1331. async handleDownloadVideos() {
  1332. // Check if IPC is available
  1333. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  1334. this.showError('Download functionality requires Electron environment');
  1335. return;
  1336. }
  1337. // Check completed videos for missing files and reset them to ready
  1338. const completedVideos = this.state.getVideosByStatus('completed');
  1339. for (const video of completedVideos) {
  1340. if (video.filename) {
  1341. const filePath = `${this.state.config.savePath}/${video.filename}`;
  1342. try {
  1343. const result = await window.electronAPI.checkFileExists(filePath);
  1344. if (!result.exists) {
  1345. console.log(`File missing for ${video.title}, resetting to ready`);
  1346. this.state.updateVideo(video.id, {
  1347. status: 'ready',
  1348. progress: 0,
  1349. filename: '',
  1350. error: null
  1351. });
  1352. }
  1353. } catch (error) {
  1354. console.error(`Error checking file existence for ${video.title}:`, error);
  1355. }
  1356. }
  1357. }
  1358. // Get downloadable videos (either selected or all ready videos)
  1359. const selectedVideos = this.state.getSelectedVideos().filter(v => v.isDownloadable());
  1360. const videos = selectedVideos.length > 0
  1361. ? selectedVideos
  1362. : this.state.getVideos().filter(v => v.isDownloadable());
  1363. if (videos.length === 0) {
  1364. this.showError('No videos ready for download');
  1365. return;
  1366. }
  1367. // Validate save path
  1368. if (!this.state.config.savePath) {
  1369. this.showError('Please select a save directory first');
  1370. return;
  1371. }
  1372. this.state.updateUI({ isDownloading: true });
  1373. this.updateStatusMessage(`Starting parallel download of ${videos.length} video(s)...`);
  1374. // Set up download progress listener
  1375. window.IPCManager.onDownloadProgress('app', (progressData) => {
  1376. this.handleDownloadProgress(progressData);
  1377. });
  1378. // PARALLEL DOWNLOADS: Start all downloads simultaneously
  1379. // The DownloadManager will handle concurrency limits automatically
  1380. console.log(`Starting ${videos.length} downloads in parallel...`);
  1381. const downloadPromises = videos.map(async (video) => {
  1382. try {
  1383. // Update video status to downloading
  1384. this.state.updateVideo(video.id, { status: 'downloading', progress: 0 });
  1385. const result = await window.IPCManager.downloadVideo({
  1386. videoId: video.id,
  1387. url: video.url,
  1388. quality: video.quality,
  1389. format: video.format,
  1390. savePath: this.state.config.savePath,
  1391. cookieFile: this.state.config.cookieFile
  1392. });
  1393. if (result.success) {
  1394. this.state.updateVideo(video.id, {
  1395. status: 'completed',
  1396. progress: 100,
  1397. filename: result.filename
  1398. });
  1399. // Add to download history
  1400. const completedVideo = this.state.getVideo(video.id);
  1401. if (completedVideo) {
  1402. this.state.addToHistory(completedVideo);
  1403. }
  1404. // Show notification for successful download
  1405. this.showDownloadNotification(video, 'success');
  1406. return { success: true, video };
  1407. } else {
  1408. this.state.updateVideo(video.id, {
  1409. status: 'error',
  1410. error: result.error || 'Download failed'
  1411. });
  1412. // Show notification for failed download
  1413. this.showDownloadNotification(video, 'error', result.error);
  1414. return { success: false, video, error: result.error };
  1415. }
  1416. } catch (error) {
  1417. console.error(`Error downloading video ${video.id}:`, error);
  1418. this.state.updateVideo(video.id, {
  1419. status: 'error',
  1420. error: error.message
  1421. });
  1422. return { success: false, video, error: error.message };
  1423. }
  1424. });
  1425. // Wait for all downloads to complete
  1426. const results = await Promise.all(downloadPromises);
  1427. // Count successes and failures
  1428. const successCount = results.filter(r => r.success).length;
  1429. const failedCount = results.filter(r => !r.success).length;
  1430. // Clean up progress listener
  1431. window.IPCManager.removeDownloadProgressListener('app');
  1432. this.state.updateUI({ isDownloading: false });
  1433. // Show final status
  1434. let message = `Download complete: ${successCount} succeeded`;
  1435. if (failedCount > 0) {
  1436. message += `, ${failedCount} failed`;
  1437. }
  1438. this.updateStatusMessage(message);
  1439. }
  1440. // Handle pause download
  1441. async handlePauseDownload(videoId) {
  1442. if (!window.electronAPI) {
  1443. this.showToast('Pause functionality requires Electron environment', 'error');
  1444. return;
  1445. }
  1446. try {
  1447. const result = await window.electronAPI.pauseDownload(videoId);
  1448. if (result.success) {
  1449. this.state.updateVideo(videoId, { status: 'paused' });
  1450. this.showToast('Download paused', 'info');
  1451. } else {
  1452. this.showToast(result.message || 'Failed to pause download', 'error');
  1453. }
  1454. } catch (error) {
  1455. console.error('Error pausing download:', error);
  1456. this.showToast('Failed to pause download', 'error');
  1457. }
  1458. }
  1459. // Handle resume download
  1460. async handleResumeDownload(videoId) {
  1461. if (!window.electronAPI) {
  1462. this.showToast('Resume functionality requires Electron environment', 'error');
  1463. return;
  1464. }
  1465. try {
  1466. const result = await window.electronAPI.resumeDownload(videoId);
  1467. if (result.success) {
  1468. this.state.updateVideo(videoId, { status: 'downloading' });
  1469. this.showToast('Download resumed', 'success');
  1470. } else {
  1471. this.showToast(result.message || 'Failed to resume download', 'error');
  1472. }
  1473. } catch (error) {
  1474. console.error('Error resuming download:', error);
  1475. this.showToast('Failed to resume download', 'error');
  1476. }
  1477. }
  1478. // Handle download progress updates from IPC
  1479. handleDownloadProgress(progressData) {
  1480. const { url, progress, status, stage, message, speed, eta } = progressData;
  1481. // Find video by URL
  1482. const video = this.state.getVideos().find(v => v.url === url);
  1483. if (!video) return;
  1484. // Update video progress
  1485. this.state.updateVideo(video.id, {
  1486. progress: Math.round(progress),
  1487. status: status || 'downloading',
  1488. downloadSpeed: speed,
  1489. eta: eta
  1490. });
  1491. }
  1492. // Show download notification
  1493. async showDownloadNotification(video, type, errorMessage = null) {
  1494. if (!window.electronAPI) return;
  1495. try {
  1496. const notificationOptions = {
  1497. title: type === 'success' ? 'Download Complete' : 'Download Failed',
  1498. message: type === 'success'
  1499. ? `${video.getDisplayName()}`
  1500. : `${video.getDisplayName()}: ${errorMessage || 'Unknown error'}`,
  1501. sound: true
  1502. };
  1503. await window.electronAPI.showNotification(notificationOptions);
  1504. } catch (error) {
  1505. console.warn('Failed to show notification:', error);
  1506. }
  1507. }
  1508. async handleCancelDownloads() {
  1509. const selectedVideos = this.state.getSelectedVideos();
  1510. const hasSelection = selectedVideos.length > 0;
  1511. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  1512. this.showError('Cancel functionality requires Electron environment');
  1513. return;
  1514. }
  1515. try {
  1516. let videosToCancel = [];
  1517. if (hasSelection) {
  1518. // Cancel only selected videos that are downloading or converting
  1519. videosToCancel = selectedVideos.filter(v =>
  1520. v.status === 'downloading' || v.status === 'converting'
  1521. );
  1522. if (videosToCancel.length === 0) {
  1523. this.updateStatusMessage('No active downloads in selection');
  1524. return;
  1525. }
  1526. this.updateStatusMessage(`Cancelling ${videosToCancel.length} selected download(s)...`);
  1527. // Cancel each selected video individually
  1528. for (const video of videosToCancel) {
  1529. try {
  1530. await window.electronAPI.cancelDownload(video.id);
  1531. } catch (error) {
  1532. console.error(`Error cancelling download for ${video.id}:`, error);
  1533. }
  1534. }
  1535. } else {
  1536. // Cancel all active downloads
  1537. const downloadingVideos = this.state.getVideosByStatus('downloading');
  1538. const convertingVideos = this.state.getVideosByStatus('converting');
  1539. videosToCancel = [...downloadingVideos, ...convertingVideos];
  1540. if (videosToCancel.length === 0) {
  1541. this.updateStatusMessage('No active downloads to cancel');
  1542. return;
  1543. }
  1544. this.updateStatusMessage(`Cancelling ${videosToCancel.length} active download(s)...`);
  1545. // Cancel all downloads via IPC
  1546. await window.electronAPI.cancelAllDownloads();
  1547. // Cancel all conversions via IPC
  1548. await window.electronAPI.cancelAllConversions();
  1549. }
  1550. // Update video statuses to ready
  1551. videosToCancel.forEach(video => {
  1552. this.state.updateVideo(video.id, {
  1553. status: 'ready',
  1554. progress: 0,
  1555. error: 'Cancelled by user'
  1556. });
  1557. });
  1558. this.state.updateUI({ isDownloading: false });
  1559. this.updateStatusMessage(hasSelection ? 'Selected downloads cancelled' : 'Downloads cancelled');
  1560. } catch (error) {
  1561. console.error('Error cancelling downloads:', error);
  1562. this.showError(`Failed to cancel downloads: ${error.message}`);
  1563. }
  1564. }
  1565. async handleUpdateDependencies() {
  1566. if (!window.IPCManager || !window.IPCManager.isAvailable()) {
  1567. this.showError('Update functionality requires Electron environment');
  1568. return;
  1569. }
  1570. const btn = document.getElementById('updateDepsBtn');
  1571. const originalBtnHTML = btn ? btn.innerHTML : '';
  1572. try {
  1573. // Show loading state
  1574. this.updateStatusMessage('Checking binary versions...');
  1575. if (btn) {
  1576. btn.disabled = true;
  1577. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy" class="animate-spin">Checking...';
  1578. }
  1579. const versions = await window.IPCManager.checkBinaryVersions();
  1580. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  1581. const ytdlp = versions.ytDlp || versions.ytdlp;
  1582. const ffmpeg = versions.ffmpeg;
  1583. if (versions && (ytdlp || ffmpeg)) {
  1584. // Update both button status and version display
  1585. const ytdlpMissing = !ytdlp || !ytdlp.available;
  1586. const ffmpegMissing = !ffmpeg || !ffmpeg.available;
  1587. if (ytdlpMissing || ffmpegMissing) {
  1588. this.updateDependenciesButtonStatus('missing');
  1589. this.updateBinaryVersionDisplay(null);
  1590. } else {
  1591. this.updateDependenciesButtonStatus('ok');
  1592. // Normalize the format for display
  1593. const normalizedVersions = {
  1594. ytdlp: ytdlp,
  1595. ffmpeg: ffmpeg
  1596. };
  1597. this.updateBinaryVersionDisplay(normalizedVersions);
  1598. // Show dialog if updates are available
  1599. if (ytdlp.updateAvailable) {
  1600. this.showInfo({
  1601. title: 'Update Available',
  1602. 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.`
  1603. });
  1604. }
  1605. }
  1606. } else {
  1607. this.showError('Could not check binary versions');
  1608. }
  1609. } catch (error) {
  1610. console.error('Error checking dependencies:', error);
  1611. this.showError(`Failed to check dependencies: ${error.message}`);
  1612. } finally {
  1613. // Restore button state
  1614. if (btn) {
  1615. btn.disabled = false;
  1616. btn.innerHTML = originalBtnHTML || '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">Check for Updates';
  1617. }
  1618. }
  1619. }
  1620. // State change handlers
  1621. onVideoAdded(data) {
  1622. this.renderVideoList();
  1623. this.updateStatsDisplay();
  1624. }
  1625. onVideoRemoved(data) {
  1626. this.renderVideoList();
  1627. this.updateStatsDisplay();
  1628. }
  1629. onVideoUpdated(data) {
  1630. this.updateVideoElement(data.video);
  1631. this.updateStatsDisplay();
  1632. }
  1633. onVideosReordered(data) {
  1634. // Re-render entire list to reflect new order
  1635. this.renderVideoList();
  1636. console.log('Video order updated:', data);
  1637. }
  1638. onVideosCleared(data) {
  1639. this.renderVideoList();
  1640. this.updateStatsDisplay();
  1641. }
  1642. onConfigUpdated(data) {
  1643. this.updateConfigUI(data.config);
  1644. }
  1645. onVideoSelectionChanged(data) {
  1646. const selectedVideos = data.selectedVideos || [];
  1647. const hasSelection = selectedVideos.length > 0;
  1648. // Update Clear List button
  1649. const clearListBtn = document.getElementById('clearListBtn');
  1650. if (clearListBtn) {
  1651. clearListBtn.textContent = hasSelection ? 'Clear Selected' : 'Clear List';
  1652. clearListBtn.setAttribute('aria-label', hasSelection ? 'Clear selected videos' : 'Clear all videos');
  1653. }
  1654. // Update Cancel Downloads button
  1655. const cancelDownloadsBtn = document.getElementById('cancelDownloadsBtn');
  1656. if (cancelDownloadsBtn) {
  1657. cancelDownloadsBtn.textContent = hasSelection ? 'Cancel Selected' : 'Cancel Downloads';
  1658. cancelDownloadsBtn.setAttribute('aria-label', hasSelection ? 'Cancel selected downloads' : 'Cancel all downloads');
  1659. }
  1660. // Update Download Videos button
  1661. const downloadVideosBtn = document.getElementById('downloadVideosBtn');
  1662. if (downloadVideosBtn) {
  1663. downloadVideosBtn.textContent = hasSelection ? 'Download Selected' : 'Download Videos';
  1664. downloadVideosBtn.setAttribute('aria-label', hasSelection ? 'Download selected videos' : 'Download all videos');
  1665. }
  1666. }
  1667. // UI update methods
  1668. updateSavePathDisplay() {
  1669. const savePathElement = document.getElementById('savePath');
  1670. if (savePathElement) {
  1671. savePathElement.textContent = this.state.config.savePath;
  1672. }
  1673. }
  1674. initializeDropdowns() {
  1675. // Set dropdown values from config
  1676. const defaultQuality = document.getElementById('defaultQuality');
  1677. if (defaultQuality) {
  1678. defaultQuality.value = this.state.config.defaultQuality;
  1679. }
  1680. const defaultFormat = document.getElementById('defaultFormat');
  1681. if (defaultFormat) {
  1682. defaultFormat.value = this.state.config.defaultFormat;
  1683. }
  1684. }
  1685. initializeVideoList() {
  1686. this.renderVideoList();
  1687. }
  1688. renderVideoList() {
  1689. const videoList = document.getElementById('videoList');
  1690. if (!videoList) return;
  1691. const videos = this.state.getVideos();
  1692. // Clear all existing videos (including mockups)
  1693. videoList.innerHTML = '';
  1694. // If no videos, show empty state
  1695. if (videos.length === 0) {
  1696. videoList.innerHTML = `
  1697. <div class="text-center py-12 text-[#90a1b9]">
  1698. <p class="text-lg mb-2">No videos yet</p>
  1699. <p class="text-sm">Paste YouTube or Vimeo URLs above to get started</p>
  1700. </div>
  1701. `;
  1702. return;
  1703. }
  1704. // Render each video
  1705. videos.forEach(video => {
  1706. const videoElement = this.createVideoElement(video);
  1707. videoList.appendChild(videoElement);
  1708. });
  1709. }
  1710. createVideoElement(video) {
  1711. const div = document.createElement('div');
  1712. 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';
  1713. div.dataset.videoId = video.id;
  1714. div.setAttribute('draggable', 'true'); // Make video item draggable
  1715. div.innerHTML = `
  1716. <!-- Checkbox -->
  1717. <div class="flex items-center justify-center">
  1718. <button class="video-checkbox w-6 h-6 rounded flex items-center justify-center hover:bg-[#45556c] transition-colors"
  1719. role="checkbox" aria-checked="false" aria-label="Select ${video.getDisplayName()}">
  1720. <svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="text-white">
  1721. <rect x="3" y="3" width="10" height="10" stroke="currentColor" stroke-width="1.5" fill="none" rx="2" />
  1722. </svg>
  1723. </button>
  1724. </div>
  1725. <!-- Drag Handle -->
  1726. <div class="flex items-center justify-center text-[#90a1b9] hover:text-white cursor-grab transition-colors">
  1727. <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
  1728. <circle cx="4" cy="4" r="1" />
  1729. <circle cx="4" cy="8" r="1" />
  1730. <circle cx="4" cy="12" r="1" />
  1731. <circle cx="8" cy="4" r="1" />
  1732. <circle cx="8" cy="8" r="1" />
  1733. <circle cx="8" cy="12" r="1" />
  1734. <circle cx="12" cy="4" r="1" />
  1735. <circle cx="12" cy="8" r="1" />
  1736. <circle cx="12" cy="12" r="1" />
  1737. </svg>
  1738. </div>
  1739. <!-- Video Info -->
  1740. <div class="flex items-center gap-3 min-w-0">
  1741. <div class="video-thumbnail-container w-16 h-12 bg-[#45556c] rounded overflow-hidden flex-shrink-0 relative group cursor-pointer" data-preview-url="${video.url}">
  1742. ${video.isFetchingMetadata ?
  1743. `<div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  1744. <svg class="animate-spin h-5 w-5 text-[#155dfc]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
  1745. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  1746. <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>
  1747. </svg>
  1748. </div>` :
  1749. video.thumbnail ?
  1750. `<img src="${video.thumbnail}" alt="${video.getDisplayName()}" class="w-full h-full object-cover">` :
  1751. `<div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  1752. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-[#90a1b9]">
  1753. <path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
  1754. </svg>
  1755. </div>`
  1756. }
  1757. <!-- Preview Overlay -->
  1758. <div class="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
  1759. <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
  1760. <path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
  1761. <path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 010-1.113zM17.25 12a5.25 5.25 0 11-10.5 0 5.25 5.25 0 0110.5 0z" clip-rule="evenodd"/>
  1762. </svg>
  1763. </div>
  1764. </div>
  1765. <div class="min-w-0 flex-1">
  1766. <div class="flex items-center gap-2">
  1767. <div class="text-sm text-white truncate font-medium flex-1">${video.getDisplayName()}</div>
  1768. ${video.requiresAuth ? `
  1769. <div class="flex-shrink-0 group relative">
  1770. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-[#f59e0b]" stroke-linecap="round" stroke-linejoin="round">
  1771. <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
  1772. <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
  1773. </svg>
  1774. <div class="absolute bottom-full right-0 mb-2 hidden group-hover:block z-10 w-48">
  1775. <div class="bg-[#1d293d] border border-[#45556c] rounded-lg p-2 text-xs text-[#cad5e2] shadow-lg">
  1776. <div class="flex items-start gap-1">
  1777. <svg width="12" height="12" class="mt-0.5 flex-shrink-0 text-[#f59e0b]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  1778. <circle cx="12" cy="12" r="10"/>
  1779. <line x1="12" y1="8" x2="12" y2="12"/>
  1780. <line x1="12" y1="16" x2="12.01" y2="16"/>
  1781. </svg>
  1782. <span>Requires cookie file. Set in Settings → Advanced.</span>
  1783. </div>
  1784. </div>
  1785. </div>
  1786. </div>
  1787. ` : ''}
  1788. </div>
  1789. ${video.isFetchingMetadata ?
  1790. `<div class="text-xs text-[#155dfc] animate-pulse">Fetching info...</div>` :
  1791. video.requiresAuth && !window.appState?.config?.cookieFile ?
  1792. `<div class="text-xs text-[#f59e0b]">⚠️ Cookie file needed</div>` :
  1793. ''
  1794. }
  1795. </div>
  1796. </div>
  1797. <!-- Duration -->
  1798. <div class="text-sm text-[#cad5e2] text-center">${video.duration || '--:--'}</div>
  1799. <!-- Quality Dropdown -->
  1800. <div class="flex justify-center">
  1801. <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"
  1802. aria-label="Quality for ${video.getDisplayName()}">
  1803. <option value="Best" ${video.quality === 'Best' ? 'selected' : ''}>Best</option>
  1804. <option value="4K" ${video.quality === '4K' ? 'selected' : ''}>4K</option>
  1805. <option value="1080p" ${video.quality === '1080p' ? 'selected' : ''}>1080p</option>
  1806. <option value="720p" ${video.quality === '720p' ? 'selected' : ''}>720p</option>
  1807. </select>
  1808. </div>
  1809. <!-- Format Dropdown -->
  1810. <div class="flex justify-center">
  1811. <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"
  1812. aria-label="Format for ${video.getDisplayName()}">
  1813. <option value="None" ${video.format === 'None' ? 'selected' : ''}>None</option>
  1814. <option value="H264" ${video.format === 'H264' ? 'selected' : ''}>H264</option>
  1815. <option value="ProRes" ${video.format === 'ProRes' ? 'selected' : ''}>ProRes</option>
  1816. <option value="DNxHR" ${video.format === 'DNxHR' ? 'selected' : ''}>DNxHR</option>
  1817. <option value="Audio only" ${video.format === 'Audio only' ? 'selected' : ''}>Audio only</option>
  1818. </select>
  1819. </div>
  1820. <!-- Status Badge with Pause/Resume -->
  1821. <div class="flex items-center justify-center gap-2 status-column">
  1822. <span class="status-badge ${video.status}" role="status" aria-live="polite">
  1823. ${this.getStatusText(video)}
  1824. </span>
  1825. ${video.status === 'downloading' || video.status === 'paused' ? `
  1826. <button class="pause-resume-btn w-6 h-6 rounded flex items-center justify-center hover:bg-[#45556c] text-[#90a1b9] hover:text-white transition-colors duration-200"
  1827. data-video-id="${video.id}"
  1828. data-action="${video.status === 'paused' ? 'resume' : 'pause'}"
  1829. aria-label="${video.status === 'paused' ? 'Resume' : 'Pause'} download"
  1830. title="${video.status === 'paused' ? 'Resume download (Space)' : 'Pause download (Space)'}">
  1831. ${video.status === 'paused' ? `
  1832. <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
  1833. <path d="M3 2l10 6-10 6V2z"/>
  1834. </svg>
  1835. ` : `
  1836. <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
  1837. <path d="M5 3h2v10H5V3zm4 0h2v10H9V3z"/>
  1838. </svg>
  1839. `}
  1840. </button>
  1841. ` : ''}
  1842. </div>
  1843. <!-- Delete Button -->
  1844. <div class="flex items-center justify-center">
  1845. <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"
  1846. aria-label="Delete ${video.getDisplayName()}" title="Remove from queue">
  1847. <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
  1848. <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"
  1849. stroke-linecap="round" stroke-linejoin="round"/>
  1850. </svg>
  1851. </button>
  1852. </div>
  1853. `;
  1854. return div;
  1855. }
  1856. getStatusText(video) {
  1857. switch (video.status) {
  1858. case 'downloading':
  1859. let downloadText = `Downloading ${video.progress || 0}%`;
  1860. if (video.downloadSpeed) {
  1861. downloadText += ` (${video.downloadSpeed})`;
  1862. }
  1863. if (video.eta) {
  1864. downloadText += ` ETA ${video.eta}`;
  1865. }
  1866. return downloadText;
  1867. case 'paused':
  1868. return `Paused ${video.progress || 0}%`;
  1869. case 'converting':
  1870. return `Converting ${video.progress || 0}%`;
  1871. case 'completed':
  1872. return 'Completed';
  1873. case 'error':
  1874. const retryText = video.retryCount > 0 ? ` (Retry ${video.retryCount}/${video.maxRetries})` : '';
  1875. return `Error${retryText}`;
  1876. case 'ready':
  1877. default:
  1878. return 'Ready';
  1879. }
  1880. }
  1881. updateVideoElement(video) {
  1882. const videoElement = document.querySelector(`[data-video-id="${video.id}"]`);
  1883. if (!videoElement) return;
  1884. // Update thumbnail - show loading spinner if fetching metadata
  1885. const thumbnailContainer = videoElement.querySelector('.w-16.h-12');
  1886. if (thumbnailContainer) {
  1887. if (video.isFetchingMetadata) {
  1888. thumbnailContainer.innerHTML = `
  1889. <div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  1890. <svg class="animate-spin h-5 w-5 text-[#155dfc]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
  1891. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  1892. <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>
  1893. </svg>
  1894. </div>`;
  1895. } else if (video.thumbnail) {
  1896. thumbnailContainer.innerHTML = `<img src="${video.thumbnail}" alt="${video.getDisplayName()}" class="w-full h-full object-cover">`;
  1897. } else {
  1898. thumbnailContainer.innerHTML = `
  1899. <div class="w-full h-full bg-gradient-to-br from-[#4a5568] to-[#2d3748] flex items-center justify-center">
  1900. <svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="text-[#90a1b9]">
  1901. <path d="M8 5V19L19 12L8 5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
  1902. </svg>
  1903. </div>`;
  1904. }
  1905. }
  1906. // Update title and loading message
  1907. const titleContainer = videoElement.querySelector('.min-w-0.flex-1');
  1908. if (titleContainer) {
  1909. const titleElement = titleContainer.querySelector('.text-sm.text-white.truncate');
  1910. if (titleElement) {
  1911. titleElement.textContent = video.getDisplayName();
  1912. }
  1913. // Update or remove "Fetching info..." message
  1914. const existingLoadingMsg = titleContainer.querySelector('.text-xs.text-\\[\\#155dfc\\]');
  1915. if (video.isFetchingMetadata && !existingLoadingMsg) {
  1916. const loadingMsg = document.createElement('div');
  1917. loadingMsg.className = 'text-xs text-[#155dfc] animate-pulse';
  1918. loadingMsg.textContent = 'Fetching info...';
  1919. titleContainer.appendChild(loadingMsg);
  1920. } else if (!video.isFetchingMetadata && existingLoadingMsg) {
  1921. existingLoadingMsg.remove();
  1922. }
  1923. }
  1924. // Update duration
  1925. const durationElement = videoElement.querySelector('.text-sm.text-\\[\\#cad5e2\\].text-center');
  1926. if (durationElement) {
  1927. durationElement.textContent = video.duration || '--:--';
  1928. }
  1929. // Update quality dropdown
  1930. const qualitySelect = videoElement.querySelector('.quality-select');
  1931. if (qualitySelect) {
  1932. qualitySelect.value = video.quality;
  1933. }
  1934. // Update format dropdown
  1935. const formatSelect = videoElement.querySelector('.format-select');
  1936. if (formatSelect) {
  1937. formatSelect.value = video.format;
  1938. }
  1939. // Update status badge with progress
  1940. const statusBadge = videoElement.querySelector('.status-badge');
  1941. if (statusBadge) {
  1942. statusBadge.className = `status-badge ${video.status}`;
  1943. statusBadge.textContent = this.getStatusText(video);
  1944. // Add progress bar for downloading/converting states
  1945. if (video.status === 'downloading' || video.status === 'converting') {
  1946. const progress = video.progress || 0;
  1947. statusBadge.style.background = `linear-gradient(to right, #155dfc ${progress}%, #314158 ${progress}%)`;
  1948. } else {
  1949. statusBadge.style.background = '';
  1950. }
  1951. }
  1952. }
  1953. updateStatsDisplay() {
  1954. const stats = this.state.getStats();
  1955. // Update UI with current statistics
  1956. }
  1957. updateConfigUI(config) {
  1958. this.updateSavePathDisplay();
  1959. this.initializeDropdowns();
  1960. }
  1961. updateStatusMessage(message) {
  1962. const statusElement = document.getElementById('statusMessage');
  1963. if (statusElement) {
  1964. statusElement.textContent = message;
  1965. }
  1966. // Auto-clear success messages
  1967. if (!message.toLowerCase().includes('error') && !message.toLowerCase().includes('failed')) {
  1968. setTimeout(() => {
  1969. if (statusElement && statusElement.textContent === message) {
  1970. statusElement.textContent = 'Ready to download videos';
  1971. }
  1972. }, 5000);
  1973. }
  1974. }
  1975. showError(message) {
  1976. this.updateStatusMessage(`Error: ${message}`);
  1977. console.error('App Error:', message);
  1978. this.eventBus.emit('app:error', { type: 'user', message });
  1979. }
  1980. displayError(errorData) {
  1981. const message = errorData.error?.message || errorData.message || 'An error occurred';
  1982. this.updateStatusMessage(`Error: ${message}`);
  1983. }
  1984. /**
  1985. * Prompt user to update existing videos with new default settings
  1986. * @param {string} property - 'quality' or 'format'
  1987. * @param {string} newValue - New default value
  1988. */
  1989. promptUpdateExistingVideos(property, newValue) {
  1990. const allVideos = this.state.getVideos();
  1991. const selectedVideos = this.state.getSelectedVideos();
  1992. // Determine which videos to potentially update
  1993. // If videos are selected, only update those; otherwise, update all downloadable videos
  1994. const videosToCheck = selectedVideos.length > 0
  1995. ? selectedVideos.filter(v => v.status === 'ready' || v.status === 'error')
  1996. : allVideos.filter(v => v.status === 'ready' || v.status === 'error');
  1997. // Only prompt if there are videos that could be updated
  1998. if (videosToCheck.length === 0) {
  1999. return;
  2000. }
  2001. const propertyName = property === 'quality' ? 'quality' : 'format';
  2002. const scope = selectedVideos.length > 0 ? 'selected' : 'all';
  2003. const message = `Update ${scope} ${videosToCheck.length} video(s) in the list to use ${propertyName}: ${newValue}?`;
  2004. if (confirm(message)) {
  2005. let updatedCount = 0;
  2006. videosToCheck.forEach(video => {
  2007. this.state.updateVideo(video.id, { [property]: newValue });
  2008. updatedCount++;
  2009. });
  2010. this.updateStatusMessage(`Updated ${updatedCount} ${scope} video(s) with new ${propertyName}: ${newValue}`);
  2011. this.renderVideoList();
  2012. }
  2013. }
  2014. // Keyboard navigation
  2015. initializeKeyboardNavigation() {
  2016. // Enhanced keyboard navigation setup
  2017. document.addEventListener('keydown', (e) => {
  2018. // Ignore if user is typing in an input
  2019. if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
  2020. return;
  2021. }
  2022. // Ctrl/Cmd shortcuts
  2023. if (e.ctrlKey || e.metaKey) {
  2024. switch (e.key) {
  2025. case 'a':
  2026. e.preventDefault();
  2027. this.state.selectAllVideos();
  2028. this.updateStatusMessage('All videos selected');
  2029. break;
  2030. case 'd':
  2031. e.preventDefault();
  2032. this.handleDownloadVideos();
  2033. break;
  2034. case ',':
  2035. e.preventDefault();
  2036. this.showSettingsModal();
  2037. break;
  2038. case '/':
  2039. e.preventDefault();
  2040. // Show shortcuts tab in settings
  2041. this.showSettingsModal();
  2042. // Switch to shortcuts tab
  2043. setTimeout(() => {
  2044. const shortcutsTab = document.querySelector('.settings-tab[data-tab="shortcuts"]');
  2045. if (shortcutsTab) shortcutsTab.click();
  2046. }, 100);
  2047. break;
  2048. }
  2049. }
  2050. // Non-modifier shortcuts
  2051. else {
  2052. switch (e.key) {
  2053. case 'Delete':
  2054. case 'Backspace':
  2055. e.preventDefault();
  2056. const selectedVideos = this.state.getSelectedVideos();
  2057. if (selectedVideos.length > 0) {
  2058. selectedVideos.forEach(video => {
  2059. this.state.removeVideo(video.id);
  2060. });
  2061. this.updateStatusMessage(`Removed ${selectedVideos.length} video(s)`);
  2062. }
  2063. break;
  2064. case ' ':
  2065. // Space to toggle selection of focused video
  2066. e.preventDefault();
  2067. const focusedItem = document.querySelector('.video-item:focus-within');
  2068. if (focusedItem) {
  2069. const videoId = focusedItem.dataset.videoId;
  2070. if (videoId) {
  2071. this.state.toggleVideoSelection(videoId);
  2072. }
  2073. }
  2074. break;
  2075. case 'Escape':
  2076. // Close any open modals
  2077. const modals = ['settingsModal', 'playlistModal', 'previewModal', 'historyModal'];
  2078. modals.forEach(modalId => {
  2079. const modal = document.getElementById(modalId);
  2080. if (modal && modal.classList.contains('flex')) {
  2081. modal.classList.remove('flex');
  2082. modal.classList.add('hidden');
  2083. }
  2084. });
  2085. break;
  2086. case 'p':
  2087. case 'P':
  2088. // Pause/Resume selected downloading videos
  2089. e.preventDefault();
  2090. const selectedVids = this.state.getSelectedVideos();
  2091. if (selectedVids.length > 0) {
  2092. selectedVids.forEach(video => {
  2093. if (video.status === 'downloading') {
  2094. this.handlePauseDownload(video.id);
  2095. } else if (video.status === 'paused') {
  2096. this.handleResumeDownload(video.id);
  2097. }
  2098. });
  2099. }
  2100. break;
  2101. }
  2102. }
  2103. });
  2104. }
  2105. // Ensure save directory exists
  2106. async ensureSaveDirectoryExists() {
  2107. const savePath = this.state.config.savePath;
  2108. if (!savePath || !window.electronAPI) return;
  2109. try {
  2110. const result = await window.electronAPI.createDirectory(savePath);
  2111. if (!result.success) {
  2112. console.warn('Failed to create save directory:', result.error);
  2113. } else {
  2114. console.log('Save directory ready:', result.path);
  2115. }
  2116. } catch (error) {
  2117. console.error('Error creating directory:', error);
  2118. }
  2119. }
  2120. // Check binary status and validate with blocking dialog if missing
  2121. async checkAndValidateBinaries() {
  2122. if (!window.IPCManager || !window.IPCManager.isAvailable()) return;
  2123. try {
  2124. const versions = await window.IPCManager.checkBinaryVersions();
  2125. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  2126. const ytdlp = versions.ytDlp || versions.ytdlp;
  2127. const ffmpeg = versions.ffmpeg;
  2128. if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) {
  2129. this.updateDependenciesButtonStatus('missing');
  2130. this.updateBinaryVersionDisplay(null);
  2131. // Show blocking dialog to warn user
  2132. await this.showMissingBinariesDialog(ytdlp, ffmpeg);
  2133. } else {
  2134. this.updateDependenciesButtonStatus('ok');
  2135. // Normalize the format for display
  2136. const normalizedVersions = {
  2137. ytdlp: ytdlp,
  2138. ffmpeg: ffmpeg
  2139. };
  2140. this.updateBinaryVersionDisplay(normalizedVersions);
  2141. }
  2142. } catch (error) {
  2143. console.error('Error checking binary status:', error);
  2144. // Set missing status on error
  2145. this.updateDependenciesButtonStatus('missing');
  2146. this.updateBinaryVersionDisplay(null);
  2147. // Show dialog on error too
  2148. await this.showMissingBinariesDialog(null, null);
  2149. }
  2150. }
  2151. // Show blocking dialog when binaries are missing
  2152. async showMissingBinariesDialog(ytdlp, ffmpeg) {
  2153. // Determine which binaries are missing
  2154. const missingBinaries = [];
  2155. if (!ytdlp || !ytdlp.available) missingBinaries.push('yt-dlp');
  2156. if (!ffmpeg || !ffmpeg.available) missingBinaries.push('ffmpeg');
  2157. const missingList = missingBinaries.length > 0
  2158. ? missingBinaries.join(', ')
  2159. : 'yt-dlp and ffmpeg';
  2160. if (window.electronAPI && window.electronAPI.showErrorDialog) {
  2161. // Use native Electron dialog
  2162. await window.electronAPI.showErrorDialog({
  2163. title: 'Required Binaries Missing',
  2164. message: `The following required binaries are missing: ${missingList}`,
  2165. detail: 'Please run "npm run setup" in the terminal to download the required binaries.\n\n' +
  2166. 'Without these binaries, GrabZilla cannot download or convert videos.\n\n' +
  2167. 'After running "npm run setup", restart the application.'
  2168. });
  2169. } else {
  2170. // Fallback to browser alert
  2171. alert(
  2172. `⚠️ Required Binaries Missing\n\n` +
  2173. `Missing: ${missingList}\n\n` +
  2174. `Please run "npm run setup" to download the required binaries.\n\n` +
  2175. `Without these binaries, GrabZilla cannot download or convert videos.`
  2176. );
  2177. }
  2178. }
  2179. // Check binary status and update UI (non-blocking version for updates)
  2180. async checkBinaryStatus() {
  2181. if (!window.IPCManager || !window.IPCManager.isAvailable()) return;
  2182. try {
  2183. const versions = await window.IPCManager.checkBinaryVersions();
  2184. // Handle both ytDlp (from main.js) and ytdlp (legacy) formats
  2185. const ytdlp = versions.ytDlp || versions.ytdlp;
  2186. const ffmpeg = versions.ffmpeg;
  2187. if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) {
  2188. this.updateDependenciesButtonStatus('missing');
  2189. this.updateBinaryVersionDisplay(null);
  2190. } else {
  2191. this.updateDependenciesButtonStatus('ok');
  2192. // Normalize the format for display
  2193. const normalizedVersions = {
  2194. ytdlp: ytdlp,
  2195. ffmpeg: ffmpeg
  2196. };
  2197. this.updateBinaryVersionDisplay(normalizedVersions);
  2198. }
  2199. } catch (error) {
  2200. console.error('Error checking binary status:', error);
  2201. // Set missing status on error
  2202. this.updateDependenciesButtonStatus('missing');
  2203. this.updateBinaryVersionDisplay(null);
  2204. }
  2205. }
  2206. updateBinaryVersionDisplay(versions) {
  2207. const statusMessage = document.getElementById('statusMessage');
  2208. const ytdlpVersionNumber = document.getElementById('ytdlpVersionNumber');
  2209. const ytdlpUpdateBadge = document.getElementById('ytdlpUpdateBadge');
  2210. const ffmpegVersionNumber = document.getElementById('ffmpegVersionNumber');
  2211. const lastUpdateCheck = document.getElementById('lastUpdateCheck');
  2212. if (!versions) {
  2213. // Binaries missing
  2214. if (statusMessage) statusMessage.textContent = 'Ready to download videos - Binaries required';
  2215. if (ytdlpVersionNumber) ytdlpVersionNumber.textContent = 'missing';
  2216. if (ffmpegVersionNumber) ffmpegVersionNumber.textContent = 'missing';
  2217. if (ytdlpUpdateBadge) ytdlpUpdateBadge.classList.add('hidden');
  2218. if (lastUpdateCheck) lastUpdateCheck.textContent = '--';
  2219. return;
  2220. }
  2221. // Update yt-dlp version
  2222. if (ytdlpVersionNumber) {
  2223. const ytdlpVersion = versions.ytdlp?.version || 'unknown';
  2224. ytdlpVersionNumber.textContent = ytdlpVersion;
  2225. }
  2226. // Show/hide update badge for yt-dlp
  2227. if (ytdlpUpdateBadge) {
  2228. if (versions.ytdlp?.updateAvailable) {
  2229. ytdlpUpdateBadge.classList.remove('hidden');
  2230. ytdlpUpdateBadge.title = `Update available: ${versions.ytdlp.latestVersion || 'newer version'}`;
  2231. } else {
  2232. ytdlpUpdateBadge.classList.add('hidden');
  2233. }
  2234. }
  2235. // Update ffmpeg version
  2236. if (ffmpegVersionNumber) {
  2237. const ffmpegVersion = versions.ffmpeg?.version || 'unknown';
  2238. ffmpegVersionNumber.textContent = ffmpegVersion;
  2239. }
  2240. // Update last check timestamp
  2241. if (lastUpdateCheck) {
  2242. const now = new Date();
  2243. const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  2244. lastUpdateCheck.textContent = `checked ${timeString}`;
  2245. lastUpdateCheck.title = `Last update check: ${now.toLocaleString()}`;
  2246. }
  2247. // Update status message
  2248. if (statusMessage) {
  2249. const hasUpdates = versions.ytdlp?.updateAvailable;
  2250. statusMessage.textContent = hasUpdates ?
  2251. 'Update available for yt-dlp' :
  2252. 'Ready to download videos';
  2253. }
  2254. }
  2255. updateDependenciesButtonStatus(status) {
  2256. const btn = document.getElementById('updateDepsBtn');
  2257. if (!btn) return;
  2258. if (status === 'missing') {
  2259. btn.classList.add('bg-red-600', 'animate-pulse');
  2260. btn.classList.remove('bg-[#314158]');
  2261. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">⚠️ Required';
  2262. } else {
  2263. btn.classList.remove('bg-red-600', 'animate-pulse');
  2264. btn.classList.add('bg-[#314158]');
  2265. btn.innerHTML = '<img src="assets/icons/refresh.svg" alt="" width="16" height="16" loading="lazy">Check for Updates';
  2266. }
  2267. }
  2268. // State persistence
  2269. async loadState() {
  2270. try {
  2271. const savedState = localStorage.getItem('grabzilla-state');
  2272. if (savedState) {
  2273. const data = JSON.parse(savedState);
  2274. this.state.fromJSON(data);
  2275. console.log('✅ Loaded saved state');
  2276. // Re-render video list to show restored videos
  2277. this.renderVideoList();
  2278. this.updateSavePathDisplay();
  2279. this.updateStatsDisplay();
  2280. }
  2281. } catch (error) {
  2282. console.warn('Failed to load saved state:', error);
  2283. }
  2284. }
  2285. async saveState() {
  2286. try {
  2287. const stateData = this.state.toJSON();
  2288. localStorage.setItem('grabzilla-state', JSON.stringify(stateData));
  2289. } catch (error) {
  2290. console.warn('Failed to save state:', error);
  2291. }
  2292. }
  2293. // Lifecycle methods
  2294. handleInitializationError(error) {
  2295. // Show fallback UI or error message
  2296. const statusElement = document.getElementById('statusMessage');
  2297. if (statusElement) {
  2298. statusElement.textContent = 'Failed to initialize application';
  2299. }
  2300. }
  2301. destroy() {
  2302. // Clean up resources
  2303. if (this.state) {
  2304. this.saveState();
  2305. }
  2306. // Remove event listeners
  2307. this.eventBus?.removeAllListeners();
  2308. this.initialized = false;
  2309. console.log('🧹 GrabZilla app destroyed');
  2310. }
  2311. }
  2312. // Initialize function to be called after all scripts are loaded
  2313. window.initializeGrabZilla = function() {
  2314. window.app = new GrabZillaApp();
  2315. window.app.init();
  2316. };
  2317. // Auto-save state on page unload
  2318. window.addEventListener('beforeunload', () => {
  2319. if (window.app?.initialized) {
  2320. window.app.saveState();
  2321. }
  2322. });
  2323. // Export the app class
  2324. if (typeof module !== 'undefined' && module.exports) {
  2325. module.exports = GrabZillaApp;
  2326. } else {
  2327. window.GrabZillaApp = GrabZillaApp;
  2328. }