Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
aandrew-me
GitHub Repository: aandrew-me/ytDownloader
Path: blob/main/src/renderer.js
448 views
1
const {shell, ipcRenderer, clipboard} = require("electron");
2
const {default: YTDlpWrap} = require("yt-dlp-wrap-plus");
3
const {constants} = require("fs/promises");
4
const {homedir, platform} = require("os");
5
const {join} = require("path");
6
const {mkdirSync, accessSync, promises, existsSync} = require("fs");
7
const {execSync, spawn} = require("child_process");
8
9
const CONSTANTS = {
10
DOM_IDS: {
11
// Main UI
12
PASTE_URL_BTN: "pasteUrl",
13
LOADING_WRAPPER: "loadingWrapper",
14
INCORRECT_MSG: "incorrectMsg",
15
ERROR_BTN: "errorBtn",
16
ERROR_DETAILS: "errorDetails",
17
PATH_DISPLAY: "path",
18
SELECT_LOCATION_BTN: "selectLocation",
19
DOWNLOAD_LIST: "list",
20
CLEAR_BTN: "clearBtn",
21
// Hidden Info Panel
22
HIDDEN_PANEL: "hidden",
23
CLOSE_HIDDEN_BTN: "closeHidden",
24
TITLE_CONTAINER: "title",
25
TITLE_INPUT: "titleName",
26
URL_INPUTS: ".url",
27
AUDIO_PRESENT_SECTION: "audioPresent",
28
QUIT_APP_BTN: "quitAppBtn",
29
// Format Selectors
30
VIDEO_FORMAT_SELECT: "videoFormatSelect",
31
AUDIO_FORMAT_SELECT: "audioFormatSelect",
32
AUDIO_FOR_VIDEO_FORMAT_SELECT: "audioForVideoFormatSelect",
33
// Download Buttons
34
VIDEO_DOWNLOAD_BTN: "videoDownload",
35
AUDIO_DOWNLOAD_BTN: "audioDownload",
36
EXTRACT_BTN: "extractBtn",
37
// Audio Extraction
38
EXTRACT_SELECTION: "extractSelection",
39
EXTRACT_QUALITY_SELECT: "extractQualitySelect",
40
// Advanced Options
41
CUSTOM_ARGS_INPUT: "customArgsInput", // Add this line
42
START_TIME: "min-time",
43
END_TIME: "max-time",
44
MIN_SLIDER: "min-slider",
45
MAX_SLIDER: "max-slider",
46
SLIDER_RANGE_HIGHLIGHT: "range-highlight",
47
SUB_CHECKED: "subChecked",
48
QUIT_CHECKED: "quitChecked",
49
// Popups
50
POPUP_BOX: "popupBox",
51
POPUP_BOX_MAC: "popupBoxMac",
52
POPUP_TEXT: "popupText",
53
POPUP_SVG: "popupSvg",
54
YTDLP_DOWNLOAD_PROGRESS: "ytDlpDownloadProgress",
55
UPDATE_POPUP: "updatePopup",
56
UPDATE_POPUP_PROGRESS: "updateProgress",
57
UPDATE_POPUP_BAR: "progressBarFill",
58
// Menu
59
MENU_ICON: "menuIcon",
60
MENU: "menu",
61
PREFERENCE_WIN: "preferenceWin",
62
ABOUT_WIN: "aboutWin",
63
PLAYLIST_WIN: "playlistWin",
64
HISTORY_WIN: "historyWin",
65
COMPRESSOR_WIN: "compressorWin",
66
},
67
LOCAL_STORAGE_KEYS: {
68
DOWNLOAD_PATH: "downloadPath",
69
YT_DLP_PATH: "ytdlp",
70
MAX_DOWNLOADS: "maxActiveDownloads",
71
PREFERRED_VIDEO_QUALITY: "preferredVideoQuality",
72
PREFERRED_AUDIO_QUALITY: "preferredAudioQuality",
73
PREFERRED_VIDEO_CODEC: "preferredVideoCodec",
74
SHOW_MORE_FORMATS: "showMoreFormats",
75
BROWSER_COOKIES: "browser",
76
PROXY: "proxy",
77
CONFIG_PATH: "configPath",
78
AUTO_UPDATE: "autoUpdate",
79
CLOSE_TO_TRAY: "closeToTray",
80
YT_DLP_CUSTOM_ARGS: "customYtDlpArgs",
81
},
82
};
83
84
/**
85
* Shorthand for document.getElementById.
86
* @param {string} id The ID of the DOM element.
87
* @returns {HTMLElement | null}
88
*/
89
const $ = (id) => document.getElementById(id);
90
91
class YtDownloaderApp {
92
constructor() {
93
this.state = {
94
ytDlp: null,
95
ytDlpPath: "",
96
ffmpegPath: "",
97
jsRuntimePath: "",
98
downloadDir: "",
99
maxActiveDownloads: 5,
100
currentDownloads: 0,
101
// Video metadata
102
videoInfo: {
103
title: "",
104
thumbnail: "",
105
duration: 0,
106
extractor_key: "",
107
url: "",
108
},
109
// Download options
110
downloadOptions: {
111
rangeCmd: "",
112
rangeOption: "",
113
subs: "",
114
subLangs: "",
115
},
116
// Preferences
117
preferences: {
118
videoQuality: 1080,
119
audioQuality: "",
120
videoCodec: "avc1",
121
showMoreFormats: false,
122
proxy: "",
123
browserForCookies: "",
124
customYtDlpArgs: "",
125
},
126
downloadControllers: new Map(),
127
downloadedItems: new Set(),
128
downloadQueue: [],
129
};
130
}
131
132
/**
133
* Initializes the application, setting up directories, finding executables,
134
* and attaching event listeners.
135
*/
136
async initialize() {
137
await this._initializeTranslations();
138
139
this._setupDirectories();
140
this._configureTray();
141
this._configureAutoUpdate();
142
143
try {
144
this.state.ytDlpPath = await this._findOrDownloadYtDlp();
145
this.state.ytDlp = new YTDlpWrap(`"${this.state.ytDlpPath}"`);
146
this.state.ffmpegPath = await this._findFfmpeg();
147
this.state.jsRuntimePath = await this._getJsRuntimePath();
148
149
console.log("yt-dlp path:", this.state.ytDlpPath);
150
console.log("ffmpeg path:", this.state.ffmpegPath);
151
console.log("JS runtime path:", this.state.jsRuntimePath);
152
153
this._loadSettings();
154
this._addEventListeners();
155
156
// Signal to the main process that the renderer is ready for links
157
ipcRenderer.send("ready-for-links");
158
} catch (error) {
159
console.error("Initialization failed:", error);
160
$(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent = error.message;
161
$(CONSTANTS.DOM_IDS.PASTE_URL_BTN).style.display = "none";
162
}
163
}
164
165
/**
166
* Sets up the application's hidden directory and the default download directory.
167
*/
168
_setupDirectories() {
169
const userHomeDir = homedir();
170
const hiddenDir = join(userHomeDir, ".ytDownloader");
171
172
if (!existsSync(hiddenDir)) {
173
try {
174
mkdirSync(hiddenDir, {recursive: true});
175
} catch (error) {
176
console.log(error);
177
}
178
}
179
180
let defaultDownloadDir = join(userHomeDir, "Downloads");
181
if (platform() === "linux") {
182
try {
183
const xdgDownloadDir = execSync("xdg-user-dir DOWNLOAD")
184
.toString()
185
.trim();
186
if (xdgDownloadDir) {
187
defaultDownloadDir = xdgDownloadDir;
188
}
189
} catch (err) {
190
console.warn("Could not execute xdg-user-dir:", err.message);
191
}
192
}
193
194
const savedPath = localStorage.getItem(
195
CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH
196
);
197
if (savedPath) {
198
try {
199
accessSync(savedPath, constants.W_OK);
200
this.state.downloadDir = savedPath;
201
} catch {
202
console.warn(
203
`Cannot write to saved path "${savedPath}". Falling back to default.`
204
);
205
this.state.downloadDir = defaultDownloadDir;
206
localStorage.setItem(
207
CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH,
208
defaultDownloadDir
209
);
210
}
211
} else {
212
this.state.downloadDir = defaultDownloadDir;
213
}
214
215
$(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = this.state.downloadDir;
216
217
if (!existsSync(this.state.downloadDir)) {
218
mkdirSync(this.state.downloadDir, {recursive: true});
219
}
220
}
221
222
/**
223
* Checks localStorage to determine if the tray icon should be used.
224
*/
225
_configureTray() {
226
if (
227
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CLOSE_TO_TRAY) ===
228
"true"
229
) {
230
console.log("Tray is enabled.");
231
ipcRenderer.send("useTray", true);
232
}
233
}
234
235
/**
236
* Checks settings to determine if auto-updates should be enabled.
237
*/
238
_configureAutoUpdate() {
239
let autoUpdate = true;
240
if (
241
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.AUTO_UPDATE) ===
242
"false"
243
) {
244
autoUpdate = false;
245
}
246
if (
247
process.windowsStore ||
248
process.env.YTDOWNLOADER_AUTO_UPDATES === "0"
249
) {
250
autoUpdate = false;
251
}
252
ipcRenderer.send("autoUpdate", autoUpdate);
253
}
254
255
/**
256
* Waits for the i18n module to load and then translates the static page content.
257
*/
258
async _initializeTranslations() {
259
return new Promise((resolve) => {
260
document.addEventListener(
261
"translations-loaded",
262
() => {
263
window.i18n.translatePage();
264
resolve();
265
},
266
{once: true}
267
);
268
});
269
}
270
271
/**
272
* Locates the yt-dlp executable path from various sources or downloads it.
273
* @returns {Promise<string>} A promise that resolves with the path to yt-dlp.
274
*/
275
async _findOrDownloadYtDlp() {
276
const hiddenDir = join(homedir(), ".ytDownloader");
277
const defaultYtDlpName = platform() === "win32" ? "ytdlp.exe" : "ytdlp";
278
const defaultYtDlpPath = join(hiddenDir, defaultYtDlpName);
279
const isMacOS = platform() === "darwin";
280
const isFreeBSD = platform() === "freebsd";
281
282
let executablePath = null;
283
284
// PRIORITY 1: Environment Variable
285
if (process.env.YTDOWNLOADER_YTDLP_PATH) {
286
if (existsSync(process.env.YTDOWNLOADER_YTDLP_PATH)) {
287
executablePath = process.env.YTDOWNLOADER_YTDLP_PATH;
288
} else {
289
throw new Error(
290
"YTDOWNLOADER_YTDLP_PATH is set, but no file exists there."
291
);
292
}
293
}
294
295
// PRIORITY 2: macOS homebrew
296
else if (isMacOS) {
297
const possiblePaths = [
298
"/opt/homebrew/bin/yt-dlp", // Apple Silicon
299
"/usr/local/bin/yt-dlp", // Intel
300
];
301
302
executablePath = possiblePaths.find((p) => existsSync(p));
303
304
// If Homebrew check fails, show popup and abort
305
if (!executablePath) {
306
$(CONSTANTS.DOM_IDS.POPUP_BOX_MAC).style.display = "block";
307
console.warn("Homebrew yt-dlp not found. Prompting user.");
308
309
return "";
310
}
311
}
312
313
// PRIORITY 3: FreeBSD
314
else if (isFreeBSD) {
315
try {
316
executablePath = execSync("which yt-dlp").toString().trim();
317
} catch {
318
throw new Error(
319
"No yt-dlp found in PATH on FreeBSD. Please install it."
320
);
321
}
322
}
323
324
// PRIORITY 4: LocalStorage or Download (Windows/Linux)
325
else {
326
const storedPath = localStorage.getItem(
327
CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH
328
);
329
330
if (storedPath && existsSync(storedPath)) {
331
executablePath = storedPath;
332
}
333
// Download if missing
334
else {
335
executablePath = await this.ensureYtDlpBinary(defaultYtDlpPath);
336
}
337
}
338
339
localStorage.setItem(
340
CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH,
341
executablePath
342
);
343
344
// Auto update
345
this._runBackgroundUpdate(executablePath, isMacOS);
346
347
return executablePath;
348
}
349
350
/**
351
* yt-dlp background update
352
*/
353
_runBackgroundUpdate(executablePath, isMacOS) {
354
try {
355
if (isMacOS) {
356
const brewPaths = [
357
"/opt/homebrew/bin/brew",
358
"/usr/local/bin/brew",
359
];
360
const brewExec = brewPaths.find((p) => existsSync(p)) || "brew";
361
362
const brewUpdate = spawn(brewExec, ["upgrade", "yt-dlp"]);
363
364
brewUpdate.on("error", (err) =>
365
console.error("Failed to run 'brew upgrade yt-dlp':", err)
366
);
367
brewUpdate.stdout.on("data", (data) =>
368
console.log("yt-dlp brew update:", data.toString())
369
);
370
} else {
371
const updateProc = spawn(executablePath, ["-U"]);
372
373
updateProc.on("error", (err) =>
374
console.error(
375
"Failed to run background yt-dlp update:",
376
err
377
)
378
);
379
380
updateProc.stdout.on("data", (data) => {
381
const output = data.toString();
382
console.log("yt-dlp update check:", output);
383
384
if (output.toLowerCase().includes("updating to")) {
385
this._showPopup(i18n.__("updatingYtdlp"));
386
} else if (
387
output.toLowerCase().includes("updated yt-dlp to")
388
) {
389
this._showPopup(i18n.__("updatedYtdlp"));
390
}
391
});
392
}
393
} catch (err) {
394
console.warn("Error initiating background update:", err);
395
}
396
}
397
398
/**
399
* Checks for the presence of the yt-dlp binary at the default path.
400
* If not found, it attempts to download it from GitHub.
401
*
402
* @param {string} defaultYtDlpPath The expected path to the yt-dlp binary.
403
* @returns {Promise<string>} A promise that resolves with the path to the yt-dlp binary.
404
* @throws {Error} Throws an error if the download fails.
405
*/
406
async ensureYtDlpBinary(defaultYtDlpPath) {
407
try {
408
await promises.access(defaultYtDlpPath);
409
410
return defaultYtDlpPath;
411
} catch {
412
console.log("yt-dlp not found, downloading...");
413
414
$(CONSTANTS.DOM_IDS.POPUP_BOX).style.display = "block";
415
$(CONSTANTS.DOM_IDS.POPUP_SVG).style.display = "inline";
416
document.querySelector("#popupBox p").textContent = i18n.__(
417
"downloadingNecessaryFilesWait"
418
);
419
420
try {
421
await YTDlpWrap.downloadFromGithub(
422
defaultYtDlpPath,
423
undefined,
424
undefined,
425
(progress, _d, _t) => {
426
$(
427
CONSTANTS.DOM_IDS.YTDLP_DOWNLOAD_PROGRESS
428
).textContent =
429
i18n.__("progress") +
430
`: ${(progress * 100).toFixed(2)}%`;
431
}
432
);
433
434
$(CONSTANTS.DOM_IDS.POPUP_BOX).style.display = "none";
435
436
localStorage.setItem(
437
CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_PATH,
438
defaultYtDlpPath
439
);
440
441
return defaultYtDlpPath;
442
} catch (downloadError) {
443
$(CONSTANTS.DOM_IDS.YTDLP_DOWNLOAD_PROGRESS).textContent = "";
444
445
console.error("Failed to download yt-dlp:", downloadError);
446
447
document.querySelector("#popupBox p").textContent = i18n.__(
448
"errorFailedFileDownload"
449
);
450
$(CONSTANTS.DOM_IDS.POPUP_SVG).style.display = "none";
451
452
const tryAgainBtn = document.createElement("button");
453
tryAgainBtn.id = "tryBtn";
454
tryAgainBtn.textContent = i18n.__("tryAgain");
455
tryAgainBtn.addEventListener("click", () => {
456
// TODO: Improve it
457
ipcRenderer.send("reload");
458
});
459
document.getElementById("popup").appendChild(tryAgainBtn);
460
461
throw new Error("Failed to download yt-dlp.");
462
}
463
}
464
}
465
466
/**
467
* Locates the ffmpeg executable path.
468
* @returns {Promise<string>} A promise that resolves with the path to ffmpeg.
469
*/
470
async _findFfmpeg() {
471
// Priority 1: Environment Variable
472
if (process.env.YTDOWNLOADER_FFMPEG_PATH) {
473
if (existsSync(process.env.YTDOWNLOADER_FFMPEG_PATH)) {
474
return process.env.YTDOWNLOADER_FFMPEG_PATH;
475
}
476
throw new Error(
477
"YTDOWNLOADER_FFMPEG_PATH is set, but no file exists there."
478
);
479
}
480
481
// Priority 2: System-installed (FreeBSD)
482
if (platform() === "freebsd") {
483
try {
484
return execSync("which ffmpeg").toString().trim();
485
} catch {
486
throw new Error(
487
"No ffmpeg found in PATH on FreeBSD. App may not work correctly."
488
);
489
}
490
}
491
492
// Priority 3: Bundled ffmpeg
493
return join(__dirname, "..", "ffmpeg", "bin");
494
}
495
496
/**
497
* Determines the JavaScript runtime path for yt-dlp.
498
* @returns {Promise<string>} A promise that resolves with the JS runtime path.
499
*/
500
async _getJsRuntimePath() {
501
const exeName = "node";
502
503
if (process.env.YTDOWNLOADER_NODE_PATH) {
504
if (existsSync(process.env.YTDOWNLOADER_NODE_PATH)) {
505
return `$node:"${process.env.YTDOWNLOADER_NODE_PATH}"`;
506
}
507
508
return "";
509
}
510
511
if (process.env.YTDOWNLOADER_DENO_PATH) {
512
if (existsSync(process.env.YTDOWNLOADER_DENO_PATH)) {
513
return `$deno:"${process.env.YTDOWNLOADER_DENO_PATH}"`;
514
}
515
516
return "";
517
}
518
519
if (platform() === "darwin") {
520
const possiblePaths = [
521
"/opt/homebrew/bin/deno",
522
"/usr/local/bin/deno",
523
];
524
525
for (const p of possiblePaths) {
526
if (existsSync(p)) {
527
return `deno:"${p}"`;
528
}
529
}
530
531
console.log("No Deno installation found");
532
533
return "";
534
}
535
536
let jsRuntimePath = join(__dirname, "..", exeName);
537
538
if (platform() === "win32") {
539
jsRuntimePath = join(__dirname, "..", `${exeName}.exe`);
540
}
541
542
if (existsSync(jsRuntimePath)) {
543
return `${exeName}:"${jsRuntimePath}"`;
544
} else {
545
return "";
546
}
547
}
548
549
/**
550
* Loads various settings from localStorage into the application state.
551
*/
552
_loadSettings() {
553
const prefs = this.state.preferences;
554
prefs.videoQuality =
555
Number(
556
localStorage.getItem(
557
CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_VIDEO_QUALITY
558
)
559
) || 1080;
560
prefs.audioQuality =
561
localStorage.getItem(
562
CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_AUDIO_QUALITY
563
) || "";
564
prefs.videoCodec =
565
localStorage.getItem(
566
CONSTANTS.LOCAL_STORAGE_KEYS.PREFERRED_VIDEO_CODEC
567
) || "avc1";
568
prefs.showMoreFormats =
569
localStorage.getItem(
570
CONSTANTS.LOCAL_STORAGE_KEYS.SHOW_MORE_FORMATS
571
) === "true";
572
prefs.proxy =
573
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.PROXY) || "";
574
prefs.browserForCookies =
575
localStorage.getItem(
576
CONSTANTS.LOCAL_STORAGE_KEYS.BROWSER_COOKIES
577
) || "";
578
prefs.customYtDlpArgs =
579
localStorage.getItem(
580
CONSTANTS.LOCAL_STORAGE_KEYS.YT_DLP_CUSTOM_ARGS
581
) || "";
582
prefs.configPath = localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CONFIG_PATH) || "";
583
584
const maxDownloads = Number(
585
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.MAX_DOWNLOADS)
586
);
587
this.state.maxActiveDownloads = maxDownloads >= 1 ? maxDownloads : 5;
588
589
// Update UI with loaded settings
590
$(CONSTANTS.DOM_IDS.CUSTOM_ARGS_INPUT).value = prefs.customYtDlpArgs;
591
592
const downloadDir = localStorage.getItem(
593
CONSTANTS.LOCAL_STORAGE_KEYS.DOWNLOAD_PATH
594
);
595
596
if (downloadDir) {
597
this.state.downloadDir = downloadDir;
598
$(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = downloadDir;
599
}
600
}
601
602
/**
603
* Attaches all necessary event listeners for the UI.
604
*/
605
_addEventListeners() {
606
$(CONSTANTS.DOM_IDS.PASTE_URL_BTN).addEventListener("click", () =>
607
this.pasteAndGetInfo()
608
);
609
document.addEventListener("keydown", (event) => {
610
if (
611
((event.ctrlKey && event.key === "v") ||
612
(event.metaKey &&
613
event.key === "v" &&
614
platform() === "darwin")) &&
615
document.activeElement.tagName !== "INPUT" &&
616
document.activeElement.tagName !== "TEXTAREA"
617
) {
618
$(CONSTANTS.DOM_IDS.PASTE_URL_BTN).classList.add("active");
619
620
setTimeout(() => {
621
$(CONSTANTS.DOM_IDS.PASTE_URL_BTN).classList.remove(
622
"active"
623
);
624
}, 150);
625
626
this.pasteAndGetInfo();
627
}
628
});
629
630
// Download buttons
631
$(CONSTANTS.DOM_IDS.VIDEO_DOWNLOAD_BTN).addEventListener("click", () =>
632
this.handleDownloadRequest("video")
633
);
634
$(CONSTANTS.DOM_IDS.AUDIO_DOWNLOAD_BTN).addEventListener("click", () =>
635
this.handleDownloadRequest("audio")
636
);
637
$(CONSTANTS.DOM_IDS.EXTRACT_BTN).addEventListener("click", () =>
638
this.handleDownloadRequest("extract")
639
);
640
641
// UI controls
642
$(CONSTANTS.DOM_IDS.CLOSE_HIDDEN_BTN).addEventListener("click", () =>
643
this._hideInfoPanel()
644
);
645
$(CONSTANTS.DOM_IDS.SELECT_LOCATION_BTN).addEventListener("click", () =>
646
ipcRenderer.send("select-location-main", "")
647
);
648
$(CONSTANTS.DOM_IDS.CLEAR_BTN).addEventListener("click", () =>
649
this._clearAllDownloaded()
650
);
651
652
// Error details
653
$(CONSTANTS.DOM_IDS.ERROR_DETAILS).addEventListener("click", (e) => {
654
// @ts-ignore
655
clipboard.writeText(e.target.innerText);
656
this._showPopup(i18n.__("copiedText"), false);
657
});
658
659
$(CONSTANTS.DOM_IDS.QUIT_APP_BTN).addEventListener("click", () => {
660
ipcRenderer.send("quit", "quit");
661
});
662
663
// IPC listeners
664
ipcRenderer.on("link", (event, text) => this.getInfo(text));
665
ipcRenderer.on("downloadPath", (event, downloadPath) => {
666
try {
667
accessSync(downloadPath[0], constants.W_OK);
668
669
const newPath = downloadPath[0];
670
$(CONSTANTS.DOM_IDS.PATH_DISPLAY).textContent = newPath;
671
this.state.downloadDir = newPath;
672
} catch (error) {
673
console.log(error);
674
this._showPopup(i18n.__("unableToAccessDir"), true);
675
}
676
});
677
678
ipcRenderer.on("download-progress", (_event, percent) => {
679
if (percent) {
680
const popup = $(CONSTANTS.DOM_IDS.UPDATE_POPUP);
681
const textEl = $(CONSTANTS.DOM_IDS.UPDATE_POPUP_PROGRESS);
682
const barEl = $(CONSTANTS.DOM_IDS.UPDATE_POPUP_BAR);
683
684
popup.style.display = "flex";
685
textEl.textContent = `${percent.toFixed(1)}%`;
686
barEl.style.width = `${percent}%`;
687
}
688
});
689
690
ipcRenderer.on("update-downloaded", (_event, _) => {
691
$(CONSTANTS.DOM_IDS.UPDATE_POPUP).style.display = "none";
692
});
693
694
// Menu Listeners
695
const menuMapping = {
696
[CONSTANTS.DOM_IDS.PREFERENCE_WIN]: "/preferences.html",
697
[CONSTANTS.DOM_IDS.ABOUT_WIN]: "/about.html",
698
[CONSTANTS.DOM_IDS.HISTORY_WIN]: "/history.html",
699
};
700
const windowMapping = {
701
[CONSTANTS.DOM_IDS.PLAYLIST_WIN]: "/playlist.html",
702
[CONSTANTS.DOM_IDS.COMPRESSOR_WIN]: "/compressor.html",
703
};
704
705
Object.entries(menuMapping).forEach(([id, page]) => {
706
$(id)?.addEventListener("click", () => {
707
this._closeMenu();
708
ipcRenderer.send("load-page", join(__dirname, page));
709
});
710
});
711
712
Object.entries(windowMapping).forEach(([id, page]) => {
713
$(id)?.addEventListener("click", () => {
714
this._closeMenu();
715
ipcRenderer.send("load-win", join(__dirname, page));
716
});
717
});
718
719
const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER);
720
const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER);
721
722
minSlider.addEventListener("input", () =>
723
this._updateSliderUI(minSlider)
724
);
725
maxSlider.addEventListener("input", () =>
726
this._updateSliderUI(maxSlider)
727
);
728
729
$(CONSTANTS.DOM_IDS.START_TIME).addEventListener(
730
"change",
731
this._handleTimeInputChange
732
);
733
$(CONSTANTS.DOM_IDS.END_TIME).addEventListener(
734
"change",
735
this._handleTimeInputChange
736
);
737
738
this._updateSliderUI(null);
739
}
740
741
// --- Public Methods ---
742
743
/**
744
* Pastes URL from clipboard and initiates fetching video info.
745
*/
746
pasteAndGetInfo() {
747
this.getInfo(clipboard.readText());
748
}
749
750
/**
751
* Fetches video metadata from a given URL.
752
* @param {string} url The video URL.
753
*/
754
async getInfo(url) {
755
this._loadSettings();
756
this._defaultVideoToggle();
757
this._resetUIForNewLink();
758
this.state.videoInfo.url = url;
759
760
try {
761
const metadata = await this._fetchVideoMetadata(url);
762
console.log(metadata);
763
764
const durationInt =
765
metadata.duration == null ? null : Math.ceil(metadata.duration);
766
767
this.state.videoInfo = {
768
...this.state.videoInfo,
769
id: metadata.id,
770
title: metadata.title,
771
thumbnail: metadata.thumbnail,
772
duration: durationInt,
773
extractor_key: metadata.extractor_key,
774
};
775
this.setVideoLength(durationInt);
776
this._populateFormatSelectors(metadata.formats || []);
777
this._displayInfoPanel();
778
} catch (error) {
779
if (
780
error.message.includes("js-runtimes") &&
781
error.message.includes("no such option")
782
) {
783
this._showError(i18n.__("ytDlpUpdateRequired"), url);
784
} else {
785
this._showError(error.message, url);
786
}
787
} finally {
788
$(CONSTANTS.DOM_IDS.LOADING_WRAPPER).style.display = "none";
789
}
790
}
791
792
/**
793
* Handles a download request, either starting it immediately or queuing it.
794
* @param {'video' | 'audio' | 'extract'} type The type of download.
795
*/
796
handleDownloadRequest(type) {
797
this._updateDownloadOptionsFromUI();
798
799
const downloadJob = {
800
type,
801
url: this.state.videoInfo.url,
802
title: this.state.videoInfo.title,
803
thumbnail: this.state.videoInfo.thumbnail,
804
options: {...this.state.downloadOptions},
805
// Capture UI values at the moment of click
806
uiSnapshot: {
807
videoFormat: $(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT).value,
808
audioForVideoFormat: $(
809
CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT
810
).value,
811
audioFormat: $(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT).value,
812
extractFormat: $(CONSTANTS.DOM_IDS.EXTRACT_SELECTION).value,
813
extractQuality: $(CONSTANTS.DOM_IDS.EXTRACT_QUALITY_SELECT)
814
.value,
815
},
816
};
817
818
if (this.state.currentDownloads < this.state.maxActiveDownloads) {
819
this._startDownload(downloadJob);
820
} else {
821
this._queueDownload(downloadJob);
822
}
823
this._hideInfoPanel();
824
}
825
826
/**
827
* Executes yt-dlp to get video metadata in JSON format.
828
* @param {string} url The video URL.
829
* @returns {Promise<object>} A promise that resolves with the parsed JSON metadata.
830
*/
831
_fetchVideoMetadata(url) {
832
return new Promise((resolve, reject) => {
833
const {proxy, browserForCookies, configPath} =
834
this.state.preferences;
835
const args = [
836
"-j",
837
"--no-playlist",
838
"--no-warnings",
839
proxy ? "--proxy" : "",
840
proxy,
841
browserForCookies ? "--cookies-from-browser" : "",
842
browserForCookies,
843
this.state.jsRuntimePath
844
? `--no-js-runtimes --js-runtime ${this.state.jsRuntimePath}`
845
: "",
846
configPath ? "--config-location" : "",
847
configPath ? `"${configPath}"` : "",
848
`"${url}"`,
849
].filter(Boolean);
850
851
const process = this.state.ytDlp.exec(args, {shell: true});
852
853
console.log(
854
"Spawned yt-dlp with args:",
855
process.ytDlpProcess.spawnargs.join(" ")
856
);
857
858
let stdout = "";
859
let stderr = "";
860
861
process.ytDlpProcess.stdout.on("data", (data) => {
862
stdout += data;
863
});
864
process.ytDlpProcess.stderr.on("data", (data) => (stderr += data));
865
866
process.on("close", () => {
867
if (stdout) {
868
try {
869
resolve(JSON.parse(stdout));
870
} catch (e) {
871
reject(
872
new Error(
873
"Failed to parse yt-dlp JSON output: " +
874
(stderr || e.message)
875
)
876
);
877
}
878
} else {
879
reject(
880
new Error(
881
stderr || `yt-dlp exited with a non-zero code.`
882
)
883
);
884
}
885
});
886
887
process.on("error", (err) => reject(err));
888
});
889
}
890
891
/**
892
* Starts the download process for a given job.
893
* @param {object} job The download job object.
894
*/
895
_startDownload(job) {
896
this.state.currentDownloads++;
897
const randomId = "item_" + Math.random().toString(36).substring(2, 12);
898
899
const {downloadArgs, finalFilename, finalExt} =
900
this._prepareDownloadArgs(job);
901
902
this._createDownloadUI(randomId, job);
903
904
const controller = new AbortController();
905
this.state.downloadControllers.set(randomId, controller);
906
907
const downloadProcess = this.state.ytDlp.exec(downloadArgs, {
908
shell: true,
909
detached: false,
910
signal: controller.signal,
911
});
912
913
console.log(
914
"Spawned yt-dlp with args:",
915
downloadProcess.ytDlpProcess.spawnargs.join(" ")
916
);
917
918
// Attach event listeners
919
downloadProcess
920
.on("progress", (progress) => {
921
this._updateProgressUI(randomId, progress);
922
})
923
.once("ytDlpEvent", () => {
924
const el = $(`${randomId}_prog`);
925
if (el) el.textContent = i18n.__("downloading");
926
})
927
// .on("ytDlpEvent", (eventType, eventData) => {
928
// console.log(eventData)
929
// })
930
.once("close", (code) => {
931
this._handleDownloadCompletion(
932
code,
933
randomId,
934
finalFilename,
935
finalExt,
936
job.thumbnail
937
);
938
})
939
.once("error", (error) => {
940
this.state.downloadedItems.add(randomId);
941
this._updateClearAllButton();
942
943
this._handleDownloadError(error, randomId);
944
});
945
}
946
947
/**
948
* Queues a download job if the maximum number of active downloads is reached.
949
* @param {object} job The download job object.
950
*/
951
_queueDownload(job) {
952
const randomId = "queue_" + Math.random().toString(36).substring(2, 12);
953
this.state.downloadQueue.push({...job, queueId: randomId});
954
const itemHTML = `
955
<div class="item" id="${randomId}">
956
<div class="itemIconBox">
957
<img src="${
958
job.thumbnail || "../assets/images/thumb.png"
959
}" alt="thumbnail" class="itemIcon" crossorigin="anonymous">
960
<span class="itemType">${i18n.__(
961
job.type === "video" ? "video" : "audio"
962
)}</span>
963
</div>
964
<div class="itemBody">
965
<div class="itemTitle">${job.title}</div>
966
<p>${i18n.__("preparing")}</p>
967
</div>
968
</div>`;
969
$(CONSTANTS.DOM_IDS.DOWNLOAD_LIST).insertAdjacentHTML(
970
"beforeend",
971
itemHTML
972
);
973
}
974
975
/**
976
* Checks the queue and starts the next download if a slot is available.
977
*/
978
_processQueue() {
979
if (
980
this.state.downloadQueue.length > 0 &&
981
this.state.currentDownloads < this.state.maxActiveDownloads
982
) {
983
const nextJob = this.state.downloadQueue.shift();
984
// Remove the pending UI element
985
$(nextJob.queueId)?.remove();
986
this._startDownload(nextJob);
987
}
988
}
989
990
/**
991
* Prepares the command-line arguments for yt-dlp based on the download job.
992
* @param {object} job The download job object.
993
* @returns {{downloadArgs: string[], finalFilename: string, finalExt: string}}
994
*/
995
_prepareDownloadArgs(job) {
996
const {type, url, title, options, uiSnapshot} = job;
997
const {rangeOption, rangeCmd, subs, subLangs} = options;
998
const {proxy, browserForCookies, configPath} = this.state.preferences;
999
1000
let format_id, ext, audioForVideoFormat_id, audioFormat;
1001
1002
if (type === "video") {
1003
const [videoFid, videoExt, _, videoCodec] =
1004
uiSnapshot.videoFormat.split("|");
1005
const [audioFid, audioExt] =
1006
uiSnapshot.audioForVideoFormat.split("|");
1007
1008
format_id = videoFid;
1009
audioForVideoFormat_id = audioFid;
1010
1011
const finalAudioExt = audioExt === "webm" ? "opus" : audioExt;
1012
1013
ext = videoExt;
1014
1015
if (videoExt === "mp4" && finalAudioExt === "opus") {
1016
if (videoCodec.includes("avc")) ext = "mkv";
1017
else if (videoCodec.includes("av01")) ext = "webm";
1018
} else if (
1019
videoExt === "webm" &&
1020
["m4a", "mp4"].includes(finalAudioExt)
1021
) {
1022
ext = "mkv";
1023
}
1024
1025
audioFormat =
1026
audioForVideoFormat_id === "none"
1027
? ""
1028
: `+${audioForVideoFormat_id}`;
1029
} else if (type === "audio") {
1030
[format_id, ext] = uiSnapshot.audioFormat.split("|");
1031
ext = ext === "webm" ? "opus" : ext;
1032
} else {
1033
// type === 'extract'
1034
ext =
1035
{alac: "m4a"}[uiSnapshot.extractFormat] ||
1036
uiSnapshot.extractFormat;
1037
}
1038
1039
const invalidChars =
1040
platform() === "win32" ? /[<>:"/\\|?*[\]`#]/g : /["/`#]/g;
1041
let finalFilename = title
1042
.replace(invalidChars, "")
1043
.trim()
1044
.slice(0, 100);
1045
if (finalFilename.startsWith(".")) {
1046
finalFilename = finalFilename.substring(1);
1047
}
1048
if (rangeCmd) {
1049
let rangeTxt = rangeCmd.replace("*", "");
1050
if (platform() === "win32") rangeTxt = rangeTxt.replace(/:/g, "_");
1051
finalFilename += ` [${rangeTxt}]`;
1052
}
1053
1054
const outputPath = `"${join(
1055
this.state.downloadDir,
1056
`${finalFilename}.${ext}`
1057
)}"`;
1058
1059
const baseArgs = [
1060
"--no-playlist",
1061
"--no-mtime",
1062
browserForCookies ? "--cookies-from-browser" : "",
1063
browserForCookies,
1064
proxy ? "--proxy" : "",
1065
proxy,
1066
configPath ? "--config-location" : "",
1067
configPath ? `"${configPath}"` : "",
1068
"--ffmpeg-location",
1069
`"${this.state.ffmpegPath}"`,
1070
this.state.jsRuntimePath
1071
? `--no-js-runtimes --js-runtime ${this.state.jsRuntimePath}`
1072
: "",
1073
].filter(Boolean);
1074
1075
if (type === "audio") {
1076
if (ext === "m4a" || ext === "mp3" || ext === "mp4") {
1077
baseArgs.unshift("--embed-thumbnail");
1078
}
1079
} else if (type === "extract") {
1080
if (ext === "mp3" || ext === "m4a") {
1081
baseArgs.unshift("--embed-thumbnail");
1082
}
1083
}
1084
1085
let downloadArgs;
1086
if (type === "extract") {
1087
downloadArgs = [
1088
"-x",
1089
"--audio-format",
1090
uiSnapshot.extractFormat,
1091
"--audio-quality",
1092
uiSnapshot.extractQuality,
1093
"-o",
1094
outputPath,
1095
...baseArgs,
1096
];
1097
} else {
1098
const formatString =
1099
type === "video" ? `${format_id}${audioFormat}` : format_id;
1100
downloadArgs = ["-f", formatString, "-o", outputPath, ...baseArgs];
1101
}
1102
1103
if (subs) downloadArgs.push(subs);
1104
if (subLangs) downloadArgs.push(subLangs);
1105
if (rangeOption) downloadArgs.push(rangeOption, rangeCmd);
1106
1107
const customArgsString = $(
1108
CONSTANTS.DOM_IDS.CUSTOM_ARGS_INPUT
1109
).value.trim();
1110
if (customArgsString) {
1111
const customArgs = customArgsString.split(/\s+/);
1112
downloadArgs.push(...customArgs);
1113
}
1114
1115
downloadArgs.push(`"${url}"`);
1116
1117
return {downloadArgs, finalFilename, finalExt: ext};
1118
}
1119
1120
/**
1121
* Handles the completion of a download process.
1122
*/
1123
_handleDownloadCompletion(code, randomId, filename, ext, thumbnail) {
1124
this.state.currentDownloads--;
1125
this.state.downloadControllers.delete(randomId);
1126
1127
if (code === 0) {
1128
this._showDownloadSuccessUI(randomId, filename, ext, thumbnail);
1129
this.state.downloadedItems.add(randomId);
1130
this._updateClearAllButton();
1131
} else if (code !== null) {
1132
// code is null if aborted, so only show error if it's a real exit code
1133
this._handleDownloadError(
1134
new Error(`Download process exited with code ${code}.`),
1135
randomId
1136
);
1137
}
1138
1139
this._processQueue();
1140
1141
if ($(CONSTANTS.DOM_IDS.QUIT_CHECKED).checked) {
1142
ipcRenderer.send("quit", "quit");
1143
}
1144
}
1145
1146
/**
1147
* Handles an error during the download process.
1148
*/
1149
_handleDownloadError(error, randomId) {
1150
if (
1151
error.name === "AbortError" ||
1152
error.message.includes("AbortError")
1153
) {
1154
console.log(`Download ${randomId} was aborted.`);
1155
this.state.currentDownloads = Math.max(
1156
0,
1157
this.state.currentDownloads - 1
1158
);
1159
this.state.downloadControllers.delete(randomId);
1160
this._processQueue();
1161
return; // Don't treat user cancellation as an error
1162
}
1163
this.state.currentDownloads--;
1164
this.state.downloadControllers.delete(randomId);
1165
console.error("Download Error:", error);
1166
const progressEl = $(`${randomId}_prog`);
1167
if (progressEl) {
1168
progressEl.textContent = i18n.__("errorHoverForDetails");
1169
progressEl.title = error.message;
1170
}
1171
this._processQueue();
1172
}
1173
1174
/**
1175
* Updates the download options state from the UI elements.
1176
*/
1177
_updateDownloadOptionsFromUI() {
1178
const startTime = $(CONSTANTS.DOM_IDS.START_TIME).value;
1179
const endTime = $(CONSTANTS.DOM_IDS.END_TIME).value;
1180
const duration = this.state.videoInfo.duration;
1181
1182
const startSeconds = this.parseTime(startTime);
1183
const endSeconds = this.parseTime(endTime);
1184
1185
if (
1186
startSeconds === 0 &&
1187
(endSeconds === duration || endSeconds === 0)
1188
) {
1189
this.state.downloadOptions.rangeCmd = "";
1190
this.state.downloadOptions.rangeOption = "";
1191
} else {
1192
const start = startTime || "0";
1193
const end = endTime || "inf";
1194
this.state.downloadOptions.rangeCmd = `*${start}-${end}`;
1195
this.state.downloadOptions.rangeOption = "--download-sections";
1196
}
1197
1198
if ($(CONSTANTS.DOM_IDS.SUB_CHECKED).checked) {
1199
this.state.downloadOptions.subs = "--write-subs";
1200
this.state.downloadOptions.subLangs = "--sub-langs all";
1201
} else {
1202
this.state.downloadOptions.subs = "";
1203
this.state.downloadOptions.subLangs = "";
1204
}
1205
}
1206
1207
/**
1208
* Resets the UI state for a new link.
1209
*/
1210
_resetUIForNewLink() {
1211
this._hideInfoPanel();
1212
$(CONSTANTS.DOM_IDS.LOADING_WRAPPER).style.display = "flex";
1213
$(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent = "";
1214
$(CONSTANTS.DOM_IDS.ERROR_BTN).style.display = "none";
1215
$(CONSTANTS.DOM_IDS.ERROR_DETAILS).style.display = "none";
1216
$(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT).innerHTML = "";
1217
$(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT).innerHTML = "";
1218
const noAudioTxt = i18n.__("noAudio");
1219
$(
1220
CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT
1221
).innerHTML = `<option value="none|none">${noAudioTxt}</option>`;
1222
}
1223
1224
/**
1225
* Populates the video and audio format <select> elements.
1226
* @param {Array} formats The formats array from yt-dlp metadata.
1227
*/
1228
_populateFormatSelectors(formats) {
1229
const videoSelect = $(CONSTANTS.DOM_IDS.VIDEO_FORMAT_SELECT);
1230
const audioSelect = $(CONSTANTS.DOM_IDS.AUDIO_FORMAT_SELECT);
1231
const audioForVideoSelect = $(
1232
CONSTANTS.DOM_IDS.AUDIO_FOR_VIDEO_FORMAT_SELECT
1233
);
1234
1235
const NBSP = " ";
1236
1237
let maxVideoQualityLen = 0;
1238
let maxAudioQualityLen = 0;
1239
1240
formats.forEach((format) => {
1241
if (format.video_ext !== "none" && format.vcodec !== "none") {
1242
const quality = `${format.height || "???"}p${
1243
format.fps === 60 ? "60" : ""
1244
}`;
1245
if (quality.length > maxVideoQualityLen) {
1246
maxVideoQualityLen = quality.length;
1247
}
1248
} else if (
1249
format.acodec !== "none" &&
1250
format.video_ext === "none"
1251
) {
1252
const formatNote =
1253
i18n.__(format.format_note) || i18n.__("unknownQuality");
1254
if (formatNote.length > maxAudioQualityLen) {
1255
maxAudioQualityLen = formatNote.length;
1256
}
1257
}
1258
});
1259
1260
const videoQualityPadding = maxVideoQualityLen;
1261
const audioQualityPadding = maxAudioQualityLen;
1262
1263
const extPadding = 5; // "mp4", "webm"
1264
const vcodecPadding = 5; // "avc1", "vp9"
1265
const filesizePadding = 10; // "12.48 MB"
1266
1267
const {videoQuality, videoCodec, showMoreFormats} =
1268
this.state.preferences;
1269
let bestMatchHeight = 0;
1270
1271
formats.forEach((f) => {
1272
if (
1273
f.height &&
1274
f.height <= videoQuality &&
1275
f.height > bestMatchHeight &&
1276
f.video_ext !== "none"
1277
) {
1278
bestMatchHeight = f.height;
1279
}
1280
});
1281
if (bestMatchHeight === 0 && formats.length > 0) {
1282
bestMatchHeight = Math.max(
1283
...formats.filter((f) => f.height).map((f) => f.height)
1284
);
1285
}
1286
const availableCodecs = new Set(
1287
formats
1288
.filter((f) => f.height === bestMatchHeight && f.vcodec)
1289
.map((f) => f.vcodec.split(".")[0])
1290
);
1291
const finalCodec = availableCodecs.has(videoCodec)
1292
? videoCodec
1293
: [...availableCodecs].pop();
1294
let isAVideoSelected = false;
1295
1296
formats.forEach((format) => {
1297
let sizeInMB = null;
1298
let isApprox = false;
1299
1300
if (format.filesize) {
1301
sizeInMB = format.filesize / 1000000;
1302
} else if (format.filesize_approx) {
1303
sizeInMB = format.filesize_approx / 1000000;
1304
isApprox = true;
1305
} else if (this.state.videoInfo.duration && format.tbr) {
1306
sizeInMB = (this.state.videoInfo.duration * format.tbr) / 8192;
1307
isApprox = true;
1308
}
1309
1310
const displaySize = sizeInMB
1311
? `${isApprox ? "~" : ""}${sizeInMB.toFixed(2)} MB`
1312
: i18n.__("unknownSize");
1313
1314
if (format.video_ext !== "none" && format.vcodec !== "none") {
1315
if (
1316
!showMoreFormats &&
1317
(format.ext === "webm" || format.vcodec?.startsWith("vp"))
1318
) {
1319
return;
1320
}
1321
let isSelected = false;
1322
if (
1323
!isAVideoSelected &&
1324
format.height === bestMatchHeight &&
1325
format.vcodec?.startsWith(finalCodec)
1326
) {
1327
isSelected = true;
1328
isAVideoSelected = true;
1329
}
1330
1331
const quality = `${format.height || "???"}p${
1332
format.fps === 60 ? "60" : ""
1333
}`;
1334
const hasAudio = format.acodec !== "none" ? " 🔊" : "";
1335
1336
const col1 = quality.padEnd(videoQualityPadding + 1, NBSP);
1337
const col2 = format.ext.padEnd(extPadding, NBSP);
1338
const col4 = displaySize.padEnd(filesizePadding, NBSP);
1339
1340
let optionText;
1341
if (showMoreFormats) {
1342
const vcodec = format.vcodec?.split(".")[0] || "";
1343
const col3 = vcodec.padEnd(vcodecPadding, NBSP);
1344
optionText = `${col1} | ${col2} | ${col3} | ${col4}${hasAudio}`;
1345
} else {
1346
optionText = `${col1} | ${col2} | ${col4}${hasAudio}`;
1347
}
1348
1349
const option = `<option value="${format.format_id}|${
1350
format.ext
1351
}|${format.height}|${format.vcodec}" ${
1352
isSelected ? "selected" : ""
1353
}>${optionText}</option>`;
1354
1355
videoSelect.innerHTML += option;
1356
} else if (
1357
format.acodec !== "none" &&
1358
format.video_ext === "none"
1359
) {
1360
if (!showMoreFormats && format.ext === "webm") return;
1361
1362
const audioExt = format.ext === "webm" ? "opus" : format.ext;
1363
const formatNote =
1364
i18n.__(format.format_note) || i18n.__("unknownQuality");
1365
1366
const audioExtPadded = audioExt.padEnd(extPadding, NBSP);
1367
1368
const audioQualityPadded = formatNote.padEnd(
1369
audioQualityPadding,
1370
NBSP
1371
);
1372
const audioSizePadded = displaySize.padEnd(
1373
filesizePadding,
1374
NBSP
1375
);
1376
1377
const option_audio = `<option value="${format.format_id}|${audioExt}">${audioQualityPadded} | ${audioExtPadded} | ${audioSizePadded}</option>`;
1378
1379
audioSelect.innerHTML += option_audio;
1380
audioForVideoSelect.innerHTML += option_audio;
1381
}
1382
});
1383
1384
if (
1385
formats.every((f) => f.acodec === "none" || f.acodec === undefined)
1386
) {
1387
$(CONSTANTS.DOM_IDS.AUDIO_PRESENT_SECTION).style.display = "none";
1388
} else {
1389
$(CONSTANTS.DOM_IDS.AUDIO_PRESENT_SECTION).style.display = "block";
1390
}
1391
}
1392
1393
/**
1394
* Shows the hidden panel with video information.
1395
*/
1396
_displayInfoPanel() {
1397
const info = this.state.videoInfo;
1398
const titleContainer = $(CONSTANTS.DOM_IDS.TITLE_CONTAINER);
1399
1400
titleContainer.innerHTML = ""; // Clear previous content
1401
titleContainer.append(
1402
Object.assign(document.createElement("b"), {
1403
textContent: i18n.__("title") + ": ",
1404
}),
1405
Object.assign(document.createElement("input"), {
1406
className: "title",
1407
id: CONSTANTS.DOM_IDS.TITLE_INPUT,
1408
type: "text",
1409
value: `${info.title} [${info.id}]`,
1410
onchange: (e) => (this.state.videoInfo.title = e.target.value),
1411
})
1412
);
1413
1414
document
1415
.querySelectorAll(CONSTANTS.DOM_IDS.URL_INPUTS)
1416
.forEach((el) => {
1417
el.value = info.url;
1418
});
1419
1420
const hiddenPanel = $(CONSTANTS.DOM_IDS.HIDDEN_PANEL);
1421
hiddenPanel.style.display = "inline-block";
1422
hiddenPanel.classList.add("scaleUp");
1423
}
1424
1425
/**
1426
* Creates the initial UI element for a new download.
1427
*/
1428
_createDownloadUI(randomId, job) {
1429
const itemHTML = `
1430
<div class="item" id="${randomId}">
1431
<div class="itemIconBox">
1432
<img src="${
1433
job.thumbnail || "../assets/images/thumb.png"
1434
}" alt="thumbnail" class="itemIcon" crossorigin="anonymous">
1435
<span class="itemType">${i18n.__(
1436
job.type === "video" ? "video" : "audio"
1437
)}</span>
1438
</div>
1439
<img src="../assets/images/close.png" class="itemClose" id="${randomId}_close">
1440
<div class="itemBody">
1441
<div class="itemTitle">${job.title}</div>
1442
<strong class="itemSpeed" id="${randomId}_speed"></strong>
1443
<div id="${randomId}_prog" class="itemProgress">${i18n.__(
1444
"preparing"
1445
)}</div>
1446
</div>
1447
</div>`;
1448
$(CONSTANTS.DOM_IDS.DOWNLOAD_LIST).insertAdjacentHTML(
1449
"beforeend",
1450
itemHTML
1451
);
1452
1453
$(`${randomId}_close`).addEventListener("click", () =>
1454
this._cancelDownload(randomId)
1455
);
1456
}
1457
1458
/**
1459
* Updates the progress bar and speed for a download item.
1460
*/
1461
_updateProgressUI(randomId, progress) {
1462
const speedEl = $(`${randomId}_speed`);
1463
const progEl = $(`${randomId}_prog`);
1464
if (!speedEl || !progEl) return;
1465
1466
let fillEl = progEl.querySelector(".custom-progress-fill");
1467
1468
if (!fillEl) {
1469
progEl.innerHTML = "";
1470
1471
const bar = document.createElement("div");
1472
bar.className = "custom-progress";
1473
1474
fillEl = document.createElement("div");
1475
fillEl.className = "custom-progress-fill";
1476
1477
bar.appendChild(fillEl);
1478
progEl.appendChild(bar);
1479
}
1480
1481
if (progress.percent === 100) {
1482
fillEl.style.width = progress.percent + "%";
1483
speedEl.textContent = "";
1484
progEl.textContent = i18n.__("processing");
1485
ipcRenderer.send("progress", 0);
1486
1487
return;
1488
}
1489
1490
speedEl.textContent = `${i18n.__("speed")}: ${
1491
progress.currentSpeed || "0 B/s"
1492
}`;
1493
fillEl.style.width = progress.percent + "%";
1494
1495
ipcRenderer.send("progress", progress.percent / 100);
1496
}
1497
1498
/**
1499
* Updates a download item's UI to show it has completed successfully.
1500
*/
1501
_showDownloadSuccessUI(randomId, filename, ext, thumbnail) {
1502
const progressEl = $(`${randomId}_prog`);
1503
if (!progressEl) return;
1504
1505
const fullFilename = `${filename}.${ext}`;
1506
const fullPath = join(this.state.downloadDir, fullFilename);
1507
1508
progressEl.innerHTML = ""; // Clear progress bar
1509
const link = document.createElement("b");
1510
link.textContent = i18n.__("fileSavedClickToOpen");
1511
link.style.cursor = "pointer";
1512
link.onclick = () => {
1513
ipcRenderer.send("show-file", fullPath);
1514
};
1515
progressEl.appendChild(link);
1516
$(`${randomId}_speed`).textContent = "";
1517
1518
// Send desktop notification
1519
new Notification("ytDownloader", {
1520
body: fullFilename,
1521
icon: thumbnail,
1522
}).onclick = () => {
1523
shell.showItemInFolder(fullPath);
1524
};
1525
1526
// Add to download history
1527
promises
1528
.stat(fullPath)
1529
.then((stat) => {
1530
const fileSize = stat.size || 0;
1531
ipcRenderer
1532
.invoke("add-to-history", {
1533
title: this.state.videoInfo.title,
1534
url: this.state.videoInfo.url,
1535
filename: filename,
1536
filePath: fullPath,
1537
fileSize: fileSize,
1538
format: ext,
1539
thumbnail: thumbnail,
1540
duration: this.state.videoInfo.duration,
1541
})
1542
.catch((err) =>
1543
console.error("Error adding to history:", err)
1544
);
1545
})
1546
.catch((error) => console.error("Error saving to history:", error));
1547
}
1548
1549
/**
1550
* Shows an error message in the main UI.
1551
*/
1552
_showError(errorMessage, url) {
1553
$(CONSTANTS.DOM_IDS.INCORRECT_MSG).textContent =
1554
i18n.__("errorNetworkOrUrl");
1555
$(CONSTANTS.DOM_IDS.ERROR_BTN).style.display = "inline-block";
1556
const errorDetails = $(CONSTANTS.DOM_IDS.ERROR_DETAILS);
1557
errorDetails.innerHTML = `<strong>URL: ${url}</strong><br><br>${errorMessage}`;
1558
errorDetails.title = i18n.__("clickToCopy");
1559
}
1560
1561
/**
1562
* Hides the info panel with an animation.
1563
*/
1564
_hideInfoPanel() {
1565
const panel = $(CONSTANTS.DOM_IDS.HIDDEN_PANEL);
1566
if (panel.style.display !== "none") {
1567
panel.classList.remove("scaleUp");
1568
panel.classList.add("scale");
1569
setTimeout(() => {
1570
panel.style.display = "none";
1571
panel.classList.remove("scale");
1572
}, 400);
1573
}
1574
}
1575
1576
/**
1577
* Displays a temporary popup message.
1578
*/
1579
_showPopup(text, isError = false) {
1580
let popupContainer = document.getElementById("popupContainer");
1581
1582
if (!popupContainer) {
1583
popupContainer = document.createElement("div");
1584
popupContainer.id = "popupContainer";
1585
popupContainer.className = "popup-container";
1586
document.body.appendChild(popupContainer);
1587
}
1588
1589
const popup = document.createElement("span");
1590
popup.textContent = text;
1591
popup.classList.add("popup-item");
1592
1593
popup.style.background = isError ? "#ff6b6b" : "#54abde";
1594
1595
if (isError) {
1596
popup.classList.add("popup-error");
1597
}
1598
1599
popupContainer.appendChild(popup);
1600
1601
setTimeout(() => {
1602
popup.style.opacity = "0";
1603
setTimeout(() => {
1604
popup.remove();
1605
if (popupContainer.childElementCount === 0) {
1606
popupContainer.remove();
1607
}
1608
}, 1000);
1609
}, 2200);
1610
}
1611
1612
/**
1613
* Hides the main menu.
1614
*/
1615
_closeMenu() {
1616
$(CONSTANTS.DOM_IDS.MENU_ICON).style.transform = "rotate(0deg)";
1617
$(CONSTANTS.DOM_IDS.MENU).style.opacity = "0";
1618
setTimeout(
1619
() => ($(CONSTANTS.DOM_IDS.MENU).style.display = "none"),
1620
500
1621
);
1622
}
1623
1624
/**
1625
* Cancels a download in progress or removes it from the queue.
1626
* @param {string} id The ID of the download item.
1627
*/
1628
_cancelDownload(id) {
1629
// If it's an active download
1630
if (this.state.downloadControllers.has(id)) {
1631
this.state.downloadControllers.get(id).abort();
1632
}
1633
// If it's in the queue
1634
this.state.downloadQueue = this.state.downloadQueue.filter(
1635
(job) => job.queueId !== id
1636
);
1637
1638
// If it has been downloaded, remove from the set
1639
this.state.downloadedItems.delete(id);
1640
1641
this._fadeAndRemoveItem(id);
1642
this._updateClearAllButton();
1643
}
1644
1645
/**
1646
* Fades and removes a DOM element.
1647
*/
1648
_fadeAndRemoveItem(id) {
1649
const item = $(id);
1650
if (item) {
1651
item.classList.add("scale");
1652
setTimeout(() => item.remove(), 500);
1653
}
1654
}
1655
1656
/**
1657
* Removes all completed download items from the UI.
1658
*/
1659
_clearAllDownloaded() {
1660
this.state.downloadedItems.forEach((id) => this._fadeAndRemoveItem(id));
1661
this.state.downloadedItems.clear();
1662
this._updateClearAllButton();
1663
}
1664
1665
/**
1666
* Shows or hides the "Clear All" button based on the number of completed items.
1667
*/
1668
_updateClearAllButton() {
1669
const btn = $(CONSTANTS.DOM_IDS.CLEAR_BTN);
1670
btn.style.display =
1671
this.state.downloadedItems.size > 1 ? "inline-block" : "none";
1672
}
1673
1674
/**
1675
* Toggles between audio and video tabs
1676
*/
1677
_defaultVideoToggle() {
1678
let defaultWindow = "video";
1679
if (localStorage.getItem("defaultWindow")) {
1680
defaultWindow = localStorage.getItem("defaultWindow");
1681
}
1682
if (defaultWindow == "video") {
1683
selectVideo();
1684
} else {
1685
selectAudio();
1686
}
1687
}
1688
1689
/**
1690
* @param {string} timeString
1691
*/
1692
parseTime(timeString) {
1693
const parts = timeString.split(":").map((p) => parseInt(p.trim(), 10));
1694
1695
let totalSeconds = 0;
1696
1697
if (parts.length === 3) {
1698
// H:MM:SS format
1699
const [hrs, mins, secs] = parts;
1700
if (
1701
isNaN(hrs) ||
1702
isNaN(mins) ||
1703
isNaN(secs) ||
1704
mins < 0 ||
1705
mins > 59 ||
1706
secs < 0 ||
1707
secs > 59
1708
)
1709
return NaN;
1710
totalSeconds = hrs * 3600 + mins * 60 + secs;
1711
} else if (parts.length === 2) {
1712
// MM:SS format
1713
const [mins, secs] = parts;
1714
if (isNaN(mins) || isNaN(secs) || secs < 0 || secs > 59) return NaN;
1715
totalSeconds = mins * 60 + secs;
1716
} else if (parts.length === 1) {
1717
const [secs] = parts;
1718
if (isNaN(secs)) return NaN;
1719
totalSeconds = secs;
1720
} else {
1721
return NaN;
1722
}
1723
1724
return totalSeconds;
1725
}
1726
1727
_formatTime(duration) {
1728
if (duration === null) {
1729
return "";
1730
}
1731
1732
const hrs = Math.floor(duration / 3600);
1733
const mins = Math.floor((duration % 3600) / 60);
1734
const secs = Math.floor(duration % 60);
1735
1736
const paddedMins = String(mins).padStart(2, "0");
1737
const paddedSecs = String(secs).padStart(2, "0");
1738
1739
if (hrs > 0) {
1740
// H:MM:SS format
1741
return `${hrs}:${paddedMins}:${paddedSecs}`;
1742
} else {
1743
// MM:SS format
1744
return `${paddedMins}:${paddedSecs}`;
1745
}
1746
}
1747
1748
/**
1749
* @param {HTMLElement} movedSlider
1750
*/
1751
_updateSliderUI(movedSlider) {
1752
const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER);
1753
const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER);
1754
const minTimeDisplay = $(CONSTANTS.DOM_IDS.START_TIME);
1755
const maxTimeDisplay = $(CONSTANTS.DOM_IDS.END_TIME);
1756
const rangeHighlight = $(CONSTANTS.DOM_IDS.SLIDER_RANGE_HIGHLIGHT);
1757
1758
let minValue = parseInt(minSlider.value);
1759
let maxValue = parseInt(maxSlider.value);
1760
const minSliderVal = parseInt(minSlider.min);
1761
const maxSliderVal = parseInt(minSlider.max);
1762
const sliderRange = maxSliderVal - minSliderVal;
1763
1764
// Prevent sliders from crossing each other
1765
if (minValue >= maxValue) {
1766
if (movedSlider && movedSlider.id === "min-slider") {
1767
// Min must be at least 1 second less than Max
1768
minValue = Math.max(minSliderVal, maxValue - 1);
1769
minSlider.value = minValue;
1770
} else {
1771
// Max must be at least 1 second more than Min
1772
maxValue = Math.min(maxSliderVal, minValue + 1);
1773
maxSlider.value = maxValue;
1774
}
1775
}
1776
1777
minTimeDisplay.value = this._formatTime(minValue);
1778
maxTimeDisplay.value = this._formatTime(maxValue);
1779
1780
const minPercent = ((minValue - minSliderVal) / sliderRange) * 100;
1781
const maxPercent = ((maxValue - minSliderVal) / sliderRange) * 100;
1782
1783
rangeHighlight.style.left = `${minPercent}%`;
1784
rangeHighlight.style.width = `${maxPercent - minPercent}%`;
1785
}
1786
1787
/**
1788
* @param {Event} e
1789
*/
1790
_handleTimeInputChange = (e) => {
1791
const input = e.target;
1792
let newSeconds = this.parseTime(input.value);
1793
const minSlider = $("min-slider");
1794
const maxSlider = $("max-slider");
1795
1796
if (isNaN(newSeconds)) {
1797
input.value = this._formatTime(
1798
input.id === "min-time" ? minSlider.value : maxSlider.value
1799
);
1800
return;
1801
}
1802
1803
const minSliderVal = parseInt(minSlider.min);
1804
const maxSliderVal = parseInt(minSlider.max);
1805
newSeconds = Math.max(minSliderVal, Math.min(maxSliderVal, newSeconds));
1806
1807
if (input.id === "min-time") {
1808
if (newSeconds >= parseInt(maxSlider.value)) {
1809
newSeconds = Math.max(
1810
minSliderVal,
1811
parseInt(maxSlider.value) - 1
1812
);
1813
}
1814
minSlider.value = newSeconds;
1815
} else {
1816
if (newSeconds <= parseInt(minSlider.value)) {
1817
newSeconds = Math.min(
1818
maxSliderVal,
1819
parseInt(minSlider.value) + 1
1820
);
1821
}
1822
maxSlider.value = newSeconds;
1823
}
1824
1825
this._updateSliderUI(null);
1826
};
1827
1828
/**
1829
* Sets the maximum duration for the video and updates the slider's max range.
1830
* @param {number} duration - The total length of the video in seconds (must be an integer >= 1).
1831
*/
1832
setVideoLength(duration) {
1833
const minSlider = $(CONSTANTS.DOM_IDS.MIN_SLIDER);
1834
const maxSlider = $(CONSTANTS.DOM_IDS.MAX_SLIDER);
1835
1836
if (typeof duration !== "number" || duration < 1) {
1837
console.error(
1838
"Invalid duration provided to setVideoLength. Must be a number greater than 0."
1839
);
1840
1841
minSlider.max = 0;
1842
maxSlider.max = 0;
1843
1844
minSlider.value = 0;
1845
maxSlider.value = 0;
1846
1847
return;
1848
}
1849
1850
minSlider.max = duration;
1851
maxSlider.max = duration;
1852
1853
const defaultMin = 0;
1854
const defaultMax = duration;
1855
1856
minSlider.value = defaultMin;
1857
maxSlider.value = defaultMax;
1858
1859
this._updateSliderUI(null);
1860
}
1861
}
1862
1863
// --- Application Entry Point ---
1864
document.addEventListener("DOMContentLoaded", () => {
1865
const app = new YtDownloaderApp();
1866
app.initialize();
1867
});
1868
1869