app.js 108 KB

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