app.js 107 KB

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