├── .gitignore
├── LICENSE
├── README.md
├── build
├── 1024x1024.png
├── 128x128.png
├── 16x16.png
├── 24x24.png
├── 256x256.png
├── 32x32.png
├── 48x48.png
├── 512x512.png
├── 64x64.png
├── background.png
├── background.tiff
├── background@2x.png
├── favicon.png
├── icon.icns
├── icon.ico
└── setup.png
├── const.js
├── domutil.js
├── downloader.js
├── i18n
├── en_US.json
├── zh_CN.json
└── zh_HK.json
├── icons.js
├── index.html
├── main.js
├── package-lock.json
├── package.json
├── preload.js
├── readme
├── feature.cn.svg
├── feature.en.svg
├── install chrome app.png
├── miniui.cn.png
├── miniui.en.png
├── ui.cn.png
└── ui.en.png
├── renderer.js
├── resource
├── downloaded.svg
├── downloading.svg
├── exit.svg
├── failed.svg
├── folder.svg
├── github.svg
├── keeptop.svg
├── maximize.svg
├── minimize.svg
├── paste.svg
├── pause.svg
├── quittop.svg
├── resume.svg
├── stop.svg
├── stopwatch.svg
├── unknown.svg
├── waiting.svg
└── watch.svg
├── styles.css
└── tool
└── buildSvgSymbol.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .prettierrc.js
3 | dist/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Tairraos
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TikDown App [点这里查看中文说明](#中文说明)
2 |
3 | This is a TikTok/Douyin downloader built with Electron. Download the video without watermark by pasting the share link in the clipboard.
4 |
5 | ### Install
6 | - Download the Win/Mac installation package
7 | https://github.com/Tairraos/TikDown/releases/latest
8 | - Or download the portable version to use directly, which is the package with "portable" in the name.
9 | - The application is only available for Mac and Win. If you want to use it under Linux, please clone this repository and build it yourself.
10 | - Install with brew
11 | ```
12 | brew tap Tairraos/tikdown && brew install --cask tikdown
13 | ```
14 | - Use this command to check if new version is available
15 | ```
16 | brew livecheck tikdown
17 | ```
18 | - The Homebrew tap is maintained here
19 | https://github.com/Tairraos/homebrew-tikdown
20 |
21 | ### Features
22 | 
23 |
24 |
25 | ### Comments
26 | - 
27 | - The Chrome app version of TikTok/Douyin works very well.
28 | - Visit TikTok/Douyin site from Chrome (or any other Chromium based browser), and you will see installation icon on the right of the address bar.
29 | - If you are looking for a Python application, there is a tiny version of this app writen in Python, with only 100 lines code. [Go to Python Version](https://github.com/Tairraos/tiktok-downloader.py)
30 |
31 |
32 | ### Screenshot
33 | - 
34 | - 
35 |
36 | ### Features will be implemented
37 |
38 | - Bug that List is cleared after closing the interface and opening it again.
39 | - Allow to pause, resume, cancel a downloading task.
40 | - Allow to cancel a task that has not been downloaded yet.
41 | - Allow user to select filename template
42 | - Allow user to save download log
43 | - Batch add download URLs, paste in multiple lines at once.
44 | - Remove the counter at the end of the download file name.
45 | - MAYBE: Batch downad all video of a user
46 |
47 |
48 | ****************************************
49 |
50 | ## 中文说明
51 |
52 | 基于Electron构建的 TikTok/抖音 下载器。通过粘帖剪贴板里的分享链接下载无水印的视频。
53 |
54 | ### 安装
55 | - 下载 Win/Mac 安装包:
56 | https://github.com/Tairraos/TikDown/releases/latest
57 | - 也可以下载便携版直接使用,即文件名里带 "portable" 字样的。
58 | - 仅提供 Mac 和 Win 的 App,如果你需要在 Linux 下使用,请自己克隆仓库编译。
59 | - 或使用brew安装:
60 | ```
61 | brew tap Tairraos/tikdown && brew install --cask tikdown
62 | ```
63 | - 用这个命令检测是否有新版本。
64 | ```
65 | brew livecheck tikdown
66 | ```
67 | - Homebrew tap在这里维护:
68 | https://github.com/Tairraos/homebrew-tikdown
69 |
70 | ### 功能
71 | 
72 |
73 |
74 | ### 备注
75 | - 
76 | - Chrome app版本的TikTok/Douyin很好用.
77 | - 用Chrome访问TikTok/Douyin网站,在地址栏最右侧有安装图标。
78 | - 如果你在找 Python App, 我有一个100行代码的Pythont版本。 [跳转到Python版本](https://github.com/Tairraos/tiktok-downloader.py)
79 |
80 |
81 | ### 截图
82 | - 
83 | - 
84 |
85 | ### 将会在后续版本中实现的功能
86 | - BUG: 关闭界面后再打开List被清空。
87 | - 允许暂停,继续,取消一个正在下载的任务。
88 | - 允许取消还未下载的任务。
89 | - 允许用户选择保存的文件名模板。
90 | - 允许用户保存下载日志
91 | - 批量添加下载URL,一次粘帖入多行。
92 | - 移除下载文件名尾部的计数器。
93 | - 可能会做:批量下载某帐号下的所有视频。
94 |
95 | ****************************************
96 | Reference & Thanks 参考及鸣谢
97 |
98 | - UI Design / UI设计: [MasterGo](https://mastergo.com/file/64638217599752)
99 | - API Information / API 信息: [Github Repo](https://github.com/Evil0ctal/Douyin_TikTok_Download_API)
100 | - background material / 安装程序背景: [TikTok background vector created by BiZkettE1](https://www.freepik.com/vectors/tiktok-background)
101 | - arraw material / 箭头素材: [Trajectory vector created by freepik](https://www.freepik.com/vectors/trajectory)
102 |
103 |
--------------------------------------------------------------------------------
/build/1024x1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/1024x1024.png
--------------------------------------------------------------------------------
/build/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/128x128.png
--------------------------------------------------------------------------------
/build/16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/16x16.png
--------------------------------------------------------------------------------
/build/24x24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/24x24.png
--------------------------------------------------------------------------------
/build/256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/256x256.png
--------------------------------------------------------------------------------
/build/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/32x32.png
--------------------------------------------------------------------------------
/build/48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/48x48.png
--------------------------------------------------------------------------------
/build/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/512x512.png
--------------------------------------------------------------------------------
/build/64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/64x64.png
--------------------------------------------------------------------------------
/build/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background.png
--------------------------------------------------------------------------------
/build/background.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background.tiff
--------------------------------------------------------------------------------
/build/background@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/background@2x.png
--------------------------------------------------------------------------------
/build/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/favicon.png
--------------------------------------------------------------------------------
/build/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/icon.icns
--------------------------------------------------------------------------------
/build/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/icon.ico
--------------------------------------------------------------------------------
/build/setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/build/setup.png
--------------------------------------------------------------------------------
/const.js:
--------------------------------------------------------------------------------
1 | const STATE_PARSING = "Parsing...";
2 | const STATE_WAITING = "Waiting...";
3 | const STATE_DOWNLOADING = "Downloading...";
4 | const STATE_DOWNLOADED = "Downloaded";
5 | const STATE_FAILED = "Failed";
6 | const STATE_CANCELED = "Canceled";
7 | const STATE_PAUSED = "Paused";
8 | const STATE_OK = "ok";
9 | const STATE_ERROR = "error";
10 |
11 |
--------------------------------------------------------------------------------
/domutil.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Generate new dom or selector exist dom like a simplest Sizzle.
3 | * @param {string} arg
4 | * @returns
5 | */
6 | function $(arg) {
7 | if (arg.match(/^)) {
8 | const template = document.createElement("template");
9 | template.innerHTML = arg;
10 | return template.content.firstChild;
11 | }
12 | return document.querySelector(arg);
13 | }
14 |
15 | /**
16 | * Generate the top bar icon with text.
17 | * @param {string} iconName
18 | * @param {string} textKey
19 | * @returns
20 | */
21 | function genIconTextButton(iconName, textKey) {
22 | const domStr = [
23 | ``
27 | ].join("");
28 | return $(domStr);
29 | }
30 |
31 | /**
32 | * Generate the top bar icon with hover title.
33 | * @param {string} iconName
34 | * @param {string} textKey
35 | * @returns
36 | */
37 | function genIconButton(iconName, textKey) {
38 | const domStr = [
39 | ``
42 | ].join("");
43 | return $(domStr);
44 | }
45 |
46 | /**
47 | * Generate the footer stat.
48 | * @param {string} iconName
49 | * @param {string} textKey
50 | * @param {string} counter
51 | * @returns
52 | */
53 | function genIconDataStat(iconName, textKey, counter) {
54 | const domStr = [
55 | `
`,
56 | `
`,
57 | `
${counter}`,
58 | ``
59 | ].join("");
60 | return $(domStr);
61 | }
62 |
63 | function genFolderTextBtn() {
64 | return $(`
`);
65 | }
66 |
67 | /**
68 | * Generate the language selector.
69 | * @returns
70 | */
71 | function genLangSelector() {
72 | const domArr = [`
`);
77 | return $(domArr.join(""));
78 | }
79 |
80 | /**
81 | * Generate the task box content.
82 | * @param {object} task
83 | * @returns
84 | */
85 | function genTaskBox(task) {
86 | const domStr = [
87 | `
`,
88 | `
`,
89 | `
`,
90 | `
${task.videoUrl}
`,
91 | `
`,
92 | `
`,
93 | ``,
94 | ``,
95 | `
`,
96 | `
`,
97 | `
`
98 | ].join("");
99 | return $(domStr);
100 | }
101 |
102 | /**
103 | * Show a flash frame to Paste&Download button.
104 | * @param {string} type
105 | */
106 | function flashPasteBtnUI(type) {
107 | const extClass = `border-flash-${type}`;
108 | dom.btnPaste.classList.add(extClass);
109 | setTimeout(() => dom.btnPaste.classList.remove(extClass), 1000);
110 | }
111 |
112 | /**
113 | * Update the task content with the new data.
114 | * @param {string} domId
115 | * @param {object} data
116 | */
117 | function updateTaskBoxUI(domId, data) {
118 | const base = `.task-${domId}`,
119 | $container = $(base),
120 | $title = $(base + " .task-title"),
121 | $cover = $(base + " .task-cover"),
122 | $url = $(base + " .task-url"),
123 | $size = $(base + " .task-size"),
124 | $status = $(base + " .task-status");
125 | data.cover && ($cover.innerHTML = `

`);
126 | data.url && ($url.innerText = data.url);
127 | data.title && ($title.innerText = i18n.get(data.title));
128 | if (data.size) {
129 | $size.innerText = (data.size / 1024).toFixed(1).replace(/\B(?=(?:\d{3})+\b)/g, ",") + "KB";
130 | }
131 | if (data.status) {
132 | $container.className = `task-box task-${domId} ${data.status.toLowerCase().replace(/\.+$/, "")}`;
133 | $status.innerText = i18n.get(data.status);
134 | $status.setAttribute("data-i18n", `innerText%${data.status}`);
135 | if (data.status === STATE_DOWNLOADED) {
136 | $container.classList.add("canopen");
137 | $title.addEventListener("click", () => {
138 | utils.openFolder(data.openpath);
139 | });
140 | }
141 | }
142 | if (data.progress) {
143 | $(`${base} .task-progressbar`).classList.remove("hide");
144 | $(`${base} .task-progress`).style.width = `${+data.progress * 2}px`;
145 | }
146 | updateFooterStatUI();
147 | }
148 |
149 | function updateFooterStatUI() {
150 | const counter = updateTaskCounter();
151 | dom.dataDownloading.innerText = counter.Downloading || 0;
152 | dom.dataWaiting.innerText = (counter.Waiting || 0) + (counter.Parsing || 0);
153 | dom.dataDownloaded.innerText = counter.Downloaded || 0;
154 | dom.dataFailed.innerText = counter.Failed || 0;
155 | }
156 |
157 | /**
158 | * Update the download folder
159 | * @param {string} folder
160 | */
161 | function updateFolderTextUI(folder) {
162 | if (folder !== "") {
163 | dom.btnFolderText.innerText = folder;
164 | dom.btnFolderText.classList.remove("error");
165 | }
166 | if (!utils.existDir(dom.btnFolderText.innerText)) {
167 | dom.btnFolderText.classList.add("error");
168 | } else {
169 | config.target = folder;
170 | utils.setSetting("target", folder);
171 | printFooterLog("You have changed the download folder.");
172 | }
173 | }
174 |
175 | /**
176 | * Iterate throughout the UI, replacing all i18n strings.
177 | * @param {string} lang
178 | */
179 | function updateI18nStringUI(lang) {
180 | const domList = document.querySelectorAll("[data-i18n]");
181 | config.lang = lang;
182 | utils.setSetting("lang", lang);
183 | i18n.select(lang);
184 | domList.forEach((item) => {
185 | const [attr, i18nKey] = item.getAttribute("data-i18n").split("%");
186 | item[attr] = i18n.get(i18nKey);
187 | });
188 | document.title = i18n.get("TikDown", utils.getVersion());
189 | printFooterLog("You have changed the display language.");
190 | }
191 |
192 | /**
193 | * Show message in footer.
194 | * @param {string} logKey
195 | */
196 | function printFooterLog(logKey) {
197 | dom.staLogText.innerText = i18n.get(logKey);
198 | clearTimeout(dom.staLogText.timer);
199 | dom.staLogText.timer = setTimeout(() => {
200 | dom.staLogText.innerText = "";
201 | }, 8000);
202 | }
203 |
--------------------------------------------------------------------------------
/downloader.js:
--------------------------------------------------------------------------------
1 | const downloadQueue = {}, //parsed task, waiting for download, can be cancelled
2 | parseQueue = [], //pasted string, waitting to parse
3 | taskStore = {
4 | newTaskId: 1,
5 | downloadQueue,
6 | parseQueue,
7 | isParseBusy: false,
8 | isDownloadBusy: false,
9 | watchHandler: null,
10 | lastClipboard: ""
11 | };
12 |
13 | function parseContent(urlStr) {
14 | let parsed = urlStr.match(
15 | /https?:\/\/(www\.tiktok\.com\/@[^/]+\/video\/(\d+)|vm\.tiktok\.com\/([^/]+)\/|www\.douyin\.com\/video\/(\d+)|v\.douyin\.com\/([^/]+)\/)/
16 | );
17 |
18 | return parsed
19 | ? { videoUrl: parsed[0], type: parsed[1].replace(/((www|v|vm)\.(tiktok|douyin)).*/, "$1"), parsedId: parsed.slice(2).filter((n) => n)[0] }
20 | : null;
21 | }
22 |
23 | function watchClipboard(toggle) {
24 | if (toggle) {
25 | taskStore.watchHandler = setInterval(() => {
26 | const clipStr = utils.readClipboard();
27 | if (taskStore.lastClipboard !== clipStr) {
28 | manageClipboard(clipStr);
29 | }
30 | }, 1000);
31 | } else {
32 | clearInterval(taskStore.watchHandler);
33 | taskStore.watchHandler = null;
34 | }
35 | }
36 |
37 | async function parseVideoId(task) {
38 | if (task.type === "v.douyin" || task.type === "vm.tiktok") {
39 | let parsed = parseContent((await fetchURL(task.videoUrl))["url"]);
40 | return parsed.parsedId;
41 | }
42 | return task.parsedId;
43 | }
44 |
45 | async function manageClipboard(clipStr) {
46 | if (taskStore.lastClipboard === clipStr) {
47 | printFooterLog("The same task is already in the download list.");
48 | flashPasteBtnUI(STATE_ERROR);
49 | return;
50 | }
51 | taskStore.lastClipboard = clipStr;
52 | parseQueue.push(...clipStr.split("\n"));
53 | manageTask();
54 | }
55 |
56 | async function manageTask() {
57 | if (parseQueue.length === 0 || taskStore.isParseBusy) {
58 | return;
59 | }
60 |
61 | const parsed = parseContent(parseQueue.shift());
62 | taskStore.isParseBusy = true;
63 | // step 1: parse clipboard to get
64 | if (!parsed) {
65 | printFooterLog("The content of the clipboard is not a valid TikTok/Douyin URL.");
66 | taskStore.isParseBusy = false;
67 | return flashPasteBtnUI(STATE_ERROR);
68 | }
69 |
70 | const parsedId = parsed.parsedId;
71 | if ($(`.task-${parsed.parsedId}`)) {
72 | printFooterLog("The same task is already in the download list.");
73 | taskStore.isParseBusy = false;
74 | return flashPasteBtnUI(STATE_ERROR);
75 | }
76 |
77 | const taskId = taskStore.newTaskId++,
78 | task = {
79 | taskId,
80 | videoUrl: parsed.videoUrl,
81 | type: parsed.type,
82 | parsedId: parsed.parsedId,
83 | domId: parsedId
84 | };
85 | downloadQueue[taskId] = task;
86 | task.dom = createTaskUI(task);
87 | printFooterLog("You have added a new download task.");
88 | flashPasteBtnUI(STATE_OK);
89 |
90 | // step 2: parse parsedId to get videoId
91 | task.step = STATE_PARSING;
92 | updateTaskBoxUI(task.domId, { status: STATE_PARSING });
93 | task.videoId = await parseVideoId(task);
94 |
95 | // step 3: parse videoId to get video info
96 | const data = await parseVideoInfo(task);
97 | if (data.success) {
98 | const title = data.title
99 | .replace(/[/\|\n*"':<>()[\]{}.?!‘“’”:()[]{}。?!]/g, " ") //replace invalid chars to space
100 | .replace(/\s+/g, " ") //merge multi space as one
101 | .replace(/&[^;]{3,5};/g, " ") //remove html entities
102 | .replace(/#[^ ]+( |$)/g, "") //remove #tags
103 | .trim()
104 | .replace(/^(.{60}[\w]+.).*/, "$1") //truncate title to 60 chars + last word
105 | .replace(/^(.{80}).*/, "$1"), //truncate title to 80 chars
106 | filename = `${data.author} - ${data.date} - ${title || i18n.get("untitled")}`;
107 | task.step = STATE_WAITING;
108 | updateTaskBoxUI(task.domId, {
109 | status: STATE_WAITING,
110 | title: filename,
111 | cover: data.cover
112 | });
113 | task.filename = filename;
114 | task.fileurl = data.fileurl;
115 | task.videoCover = data.cover;
116 | downloadWaitingTask();
117 | } else {
118 | task.step = STATE_FAILED;
119 | updateTaskBoxUI(task.domId, {
120 | status: STATE_FAILED,
121 | title: data.resaon
122 | });
123 | }
124 |
125 | taskStore.isParseBusy = false;
126 | manageTask();
127 | }
128 |
129 | function downloadWaitingTask() {
130 | // step 4: download video
131 | if (!taskStore.isDownloadBusy) {
132 | const task = getWaitingTask();
133 | if (task) {
134 | utils.download({
135 | taskId: task.taskId,
136 | filename: task.filename,
137 | fileurl: task.fileurl
138 | });
139 | taskStore.isDownloadBusy = true;
140 | }
141 | }
142 | }
143 |
144 | function updateTaskCounter() {
145 | const result = {};
146 | for (let key in downloadQueue) {
147 | let step = downloadQueue[key].step.replace(/\.+$/, "");
148 | result[step] = (result[step] || 0) + 1;
149 | }
150 | taskStore.counter = result;
151 | return result;
152 | }
153 |
154 | function getWaitingTask() {
155 | for (let key in downloadQueue) {
156 | if (downloadQueue[key].step === STATE_WAITING) {
157 | return downloadQueue[key];
158 | }
159 | }
160 | }
161 |
162 | async function parseVideoInfo(task) {
163 | let result, apiurl, rootInfo;
164 | switch (task.type) {
165 | case "v.douyin":
166 | case "www.douyin":
167 | apiurl = `https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=${task.videoId}`;
168 | result = await fetchURL(apiurl);
169 | if (result.status_code !== 0) {
170 | return { success: false, reason: result.status_msg };
171 | }
172 | rootInfo = result["item_list"][0];
173 | rootInfo.fileurl = (await fetchURL(rootInfo["video"]["play_addr"]["url_list"][0].replace("playwm", "play")))["url"];
174 | break;
175 | case "vm.tiktok":
176 | case "www.tiktok":
177 | apiurl = `https://api-h2.tiktokv.com/aweme/v1/feed/?version_code=2613&aweme_id=${task.videoId}&device_type=iPad`;
178 | result = await fetchURL(apiurl);
179 | if (result.status_code !== 0) {
180 | return { success: false, reason: result.status_msg };
181 | }
182 | rootInfo = result["aweme_list"][0];
183 | rootInfo.fileurl = rootInfo["video"]["play_addr"]["url_list"][0];
184 | break;
185 | default:
186 | return {
187 | success: false,
188 | resaon: "The content of the clipboard is not a valid TikTok/Douyin URL."
189 | };
190 | }
191 | return {
192 | success: true,
193 | title: rootInfo["desc"],
194 | fileurl: rootInfo.fileurl,
195 | author: rootInfo["author"]["nickname"],
196 | cover: rootInfo["video"]["cover"]["url_list"][0],
197 | date: new Date(rootInfo.create_time * 1000).toISOString().replace(/-|T.*/g, "")
198 | };
199 | }
200 |
201 | async function fetchURL(url) {
202 | let headers = new Headers();
203 | headers.set("Referer", "no-referrer");
204 | let requestOptions = {
205 | method: "POST",
206 | headers: headers,
207 | redirect: "follow"
208 | };
209 | let response = await fetch(url, requestOptions);
210 | return response.redirected ? response : await response.json();
211 | }
212 |
213 | /**
214 | * Put download event handler here, will be bind to IPC in renderer.js
215 | */
216 | const downloadEventHandler = {
217 | downloadStart: function (data) {
218 | downloadQueue[data.taskId].step = STATE_DOWNLOADING;
219 | updateTaskBoxUI(downloadQueue[data.taskId].domId, {
220 | status: STATE_DOWNLOADING,
221 | title: data.filename,
222 | size: data.size
223 | });
224 | downloadQueue[data.taskId].filename = data.filename;
225 | },
226 |
227 | downloadProgress: function (data) {
228 | downloadQueue[data.taskId].step = STATE_DOWNLOADING;
229 | updateTaskBoxUI(downloadQueue[data.taskId].domId, {
230 | status: STATE_DOWNLOADING,
231 | progress: data.progress
232 | });
233 | },
234 |
235 | downloadError: function (data) {
236 | downloadQueue[data.taskId].step = STATE_FAILED;
237 | updateTaskBoxUI(downloadQueue[data.taskId].domId, {
238 | status: STATE_FAILED,
239 | title: data.message
240 | });
241 | },
242 |
243 | downloadEnd: function (data) {
244 | if (data.isSuccess) {
245 | downloadQueue[data.taskId].step = STATE_DOWNLOADED;
246 | updateTaskBoxUI(downloadQueue[data.taskId].domId, {
247 | status: STATE_DOWNLOADED,
248 | openpath: data.openpath
249 | });
250 | config.record.push(downloadQueue[data.taskId].videoId);
251 | utils.setSetting("record", config.record);
252 | } else {
253 | onDownloadError(data);
254 | }
255 | taskStore.isDownloadBusy = false;
256 | downloadWaitingTask();
257 | }
258 | };
259 |
--------------------------------------------------------------------------------
/i18n/en_US.json:
--------------------------------------------------------------------------------
1 | {
2 | "language_name": "English",
3 | "TikDown": "TikDown v{0}",
4 | "Paste/Download": "Paste/Download",
5 | "Start clipboard monitoring (auto paste)": "Start clipboard monitoring (auto paste)",
6 | "Stop clipboard monitoring": "Stop clipboard monitoring",
7 | "Keep window on top": "Keep window on top",
8 | "Exit window on top mode": "Exit window on top mode",
9 | "Mini window mode": "Mini window mode",
10 | "Normal window mode": "Normal window mode",
11 | "Change download folder": "Change download folder",
12 | "Open folder": "Open folder",
13 | "Change language": "Change language",
14 | "Exit": "Exit",
15 | "Duplicated": "Duplicated",
16 | "Waiting...": "Waiting...",
17 | "Parsing...": "Parsing...",
18 | "Downloading...": "Downloading...",
19 | "Downloaded": "Downloaded",
20 | "Cancelled": "Cancelled",
21 | "Paused": "Paused",
22 | "Failed": "Failed",
23 | "Github source": "Github source",
24 | "You have added a new download task.": "You have added a new download task.",
25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "The content of the clipboard is not a valid TikTok/Douyin URL.",
26 | "You have changed the download folder.": "You have changed the download folder.",
27 | "You have changed the display language.": "You have changed the display language.",
28 | "The same task is already in the download list.": "The same task is already in the download list.",
29 | "This video was once downloaded, requires a manual launch.": "This video was once downloaded, requires a manual launch.",
30 | "untitled": "untitled"
31 | }
32 |
--------------------------------------------------------------------------------
/i18n/zh_CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "language_name": "简体中文",
3 | "TikDown": "TikDown v{0}",
4 | "Paste/Download": "粘帖/下载",
5 | "Start clipboard monitoring (auto paste)": "监视剪贴板(自动粘帖)",
6 | "Stop clipboard monitoring": "停止监视剪贴板",
7 | "Keep window on top": "窗口置顶",
8 | "Exit window on top mode": "取消窗口置顶",
9 | "Mini window mode": "迷你窗口模式",
10 | "Normal window mode": "正常窗口模式",
11 | "Change download folder": "更改下载位置",
12 | "Open folder": "打开文件夹位置",
13 | "Change language": "更改语言",
14 | "Exit": "退出",
15 | "Duplicated": "重复任务",
16 | "Waiting...": "等待下载...",
17 | "Parsing...": "正在解析...",
18 | "Downloading...": "正在下载...",
19 | "Downloaded": "下载完成",
20 | "Cancelled": "取消下载",
21 | "Paused": "暂停下载",
22 | "Failed": "下载失败",
23 | "Github source": "开源地址",
24 | "You have added a new download task.": "你已经添加了一个新的下载任务。",
25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "剪贴板里的内容并非有效的TikTok/Douyin网址。",
26 | "You have changed the download folder.": "您更改了下载文件夹。",
27 | "You have changed the display language.": "您更改了显示语言。",
28 | "The same task is already in the download list.": "下载列表里已经有相同的任务。",
29 | "This video was once downloaded, requires a manual launch.": "这个视频曾经下载过,需要手动启动。",
30 | "untitled": "无标题"
31 | }
32 |
--------------------------------------------------------------------------------
/i18n/zh_HK.json:
--------------------------------------------------------------------------------
1 | {
2 | "language_name": "簡體中文",
3 | "TikDown": "TikDown v{0}",
4 | "Paste/Download": "粘帖/下載",
5 | "Start clipboard monitoring (auto paste)": "監視剪貼闆(自動粘帖)",
6 | "Stop clipboard monitoring": "停止監視剪貼闆",
7 | "Keep window on top": "窗口置頂",
8 | "Exit window on top mode": "取消窗口置頂",
9 | "Mini window mode": "迷你窗口模式",
10 | "Normal window mode": "正常窗口模式",
11 | "Change download folder": "更改下載位置",
12 | "Open folder": "打開文件夾位置",
13 | "Change language": "更改語言",
14 | "Exit": "退出",
15 | "Duplicated": "重複任務",
16 | "Waiting...": "等待下載...",
17 | "Parsing...": "正在解析...",
18 | "Downloading...": "正在下載...",
19 | "Downloaded": "下載完成",
20 | "Cancelled": "取消下載",
21 | "Paused": "暫停下載",
22 | "Failed": "下載失敗",
23 | "Github source": "開源地址",
24 | "You have added a new download task.": "你已經添加了一個新的下載任務。",
25 | "The content of the clipboard is not a valid TikTok/Douyin URL.": "剪貼闆裡的內容並非有效的TikTok/Douyin網址。",
26 | "You have changed the download folder.": "您更改了下載文件夾。",
27 | "You have changed the display language.": "您更改了顯示語言。",
28 | "The same task is already in the download list.": "下載列表裡已經有相同的任務。",
29 | "This video was once downloaded, requires a manual launch.": "這個視頻曾經下載過,需要手動啟動。",
30 | "untitled": "無標題"
31 | }
32 |
--------------------------------------------------------------------------------
/icons.js:
--------------------------------------------------------------------------------
1 | const svgSymbol = [
2 | '
',
3 | '
',
85 | '
'
86 | ]
87 | document.body.insertAdjacentHTML("afterBegin", svgSymbol.join(""));
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
TikDown
9 |
10 |
11 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | const os = require("os");
2 | const fs = require("fs");
3 | const path = require("path");
4 | const settings = require("electron-settings");
5 | const { app, BrowserWindow, ipcMain, dialog } = require("electron");
6 | const { DownloaderHelper } = require("node-downloader-helper");
7 | const config = {};
8 |
9 | /**
10 | * Add setting here
11 | */
12 | const defaultSettings = {
13 | lang: "en_US",
14 | target: path.join(os.homedir(), "Downloads"),
15 | record: []
16 | };
17 |
18 | function createWindow() {
19 | config.mainWindow = new BrowserWindow({
20 | width: 800,
21 | height: 600,
22 | webPreferences: {
23 | preload: path.join(__dirname, "preload.js")
24 | },
25 | icon: "resource/favicon.ico"
26 | });
27 |
28 | // open debug
29 | // config.mainWindow.webContents.openDevTools();
30 |
31 | // setup user-agent
32 | config.mainWindow.webContents.userAgent = "Mozilla/5.0 (iPad; CPU OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3";
33 | config.mainWindow.loadFile("index.html");
34 | }
35 |
36 | function getUnqueFilename(filepath, filename, n = 1) {
37 | if (fs.existsSync(path.join(filepath, filename + ".mp4"))) {
38 | filename = filename.replace(/\(\d+\)$/, "") + `(${n})`;
39 | return getUnqueFilename(filepath, filename, n + 1);
40 | }
41 | return filename;
42 | }
43 |
44 | function initIPC() {
45 | ipcMain.handle("keepTop", (event, toggle) => {
46 | config.mainWindow.setAlwaysOnTop(toggle);
47 | });
48 |
49 | ipcMain.handle("selectFolder", async () => {
50 | const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }, (folder) => folder);
51 | return result.canceled ? "" : result.filePaths[0];
52 | });
53 |
54 | ipcMain.handle("exit", () => {
55 | app.quit();
56 | });
57 |
58 | ipcMain.handle("getSetting", (event, item) => {
59 | if (Object.keys(defaultSettings).includes(item)) {
60 | config[item] = settings.getSync(item) || defaultSettings[item];
61 | return config[item];
62 | }
63 | });
64 |
65 | ipcMain.handle("setSetting", (event, item, value) => {
66 | if (Object.keys(defaultSettings).includes(item)) {
67 | config[item] = value;
68 | settings.setSync(item, config[item]);
69 | }
70 | });
71 |
72 | ipcMain.handle("download", (event, data) => {
73 | const taskId = data.taskId;
74 |
75 | const dl = new DownloaderHelper(data.fileurl, config.target, {
76 | fileName: getUnqueFilename(config.target, data.filename) + ".mp4"
77 | });
78 |
79 | dl.on("end", (info) => {
80 | config.mainWindow.send("downloadEnd", { taskId, isSuccess: !info.incomplete, openpath: info.filePath });
81 | });
82 | dl.on("error", (info) => {
83 | config.mainWindow.send("downloadError", { taskId, message: info.message });
84 | });
85 | dl.on("download", (info) => {
86 | config.mainWindow.send("downloadStart", { taskId, size: info.totalSize, filename: info.fileName });
87 | });
88 | dl.on("progress", (info) => {
89 | config.mainWindow.send("downloadProgress", { taskId, progress: info.progress });
90 | });
91 | dl.start().catch((info) => {
92 | config.mainWindow.send("downloadError", { taskId, message: info.message });
93 | });
94 | // config.mainWindow.webContents.downloadURL(data.fileurl + "#" + data.taskId);
95 | });
96 |
97 | ipcMain.handle("resize", (event, w, h) => {
98 | config.mainWindow.setSize(w, h, true);
99 | });
100 | }
101 |
102 | const onlyInstance = app.requestSingleInstanceLock();
103 | if (!onlyInstance) {
104 | app.quit();
105 | }
106 |
107 | app.on("ready", () => {
108 | createWindow();
109 |
110 | app.on("activate", function () {
111 | if (BrowserWindow.getAllWindows().length === 0) {
112 | createWindow();
113 | }
114 | });
115 |
116 | if (process.platform === "darwin") {
117 | let forceQuit = false;
118 | app.on("before-quit", () => {
119 | forceQuit = true;
120 | });
121 | config.mainWindow.on("close", (event) => {
122 | if (!forceQuit) {
123 | event.preventDefault();
124 | config.mainWindow.minimize();
125 | }
126 | });
127 | }
128 |
129 | initIPC();
130 |
131 | });
132 |
133 | app.on("second-instance", () => config.mainWindow.show());
134 |
135 | app.on("window-all-closed", function () {
136 | if (process.platform !== "darwin") {
137 | app.quit();
138 | }
139 | });
140 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tiktok-downloader",
3 | "productName": "TikDown",
4 | "description": "Electron version downloader to download TikTok/Douyin video.",
5 | "keywords": [
6 | "TikTok",
7 | "Douyin",
8 | "video",
9 | "downloader"
10 | ],
11 | "main": "./main.js",
12 | "version": "1.2.2",
13 | "author": "Tairraos",
14 | "license": "MIT",
15 | "scripts": {
16 | "start": "electron ./main.js",
17 | "debug": "electron --inspect=8888 ./main.js",
18 | "buildsvg": "node tool/buildSvgSymbol.js",
19 | "buildicon": "electron-icon-builder --input=./build/favicon.png --output=build --flatten",
20 | "buildbackground": "tiffutil -cathidpicheck build/background.png build/background@2x.png -out build/background.tiff",
21 | "shasum": "shasum -a 256 dist/TikDown-*.dmg",
22 | "build:mac": "electron-builder --mac",
23 | "build:win": "electron-builder --win"
24 | },
25 | "dependencies": {
26 | "electron-settings": "^4.0.2",
27 | "node-downloader-helper": "^2.1.3"
28 | },
29 | "devDependencies": {
30 | "cheerio": "^1.0.0-rc.11",
31 | "electron": "19.0.1",
32 | "electron-builder": "^23.0.3",
33 | "electron-icon-builder": "^2.0.1"
34 | },
35 | "build": {
36 | "appId": "com.tairraos.tikdown",
37 | "productName": "TikDown",
38 | "copyright": "Copyright © 2022 Tairraos",
39 | "compression": "maximum",
40 | "directories": {
41 | "buildResources": "build",
42 | "output": "dist"
43 | },
44 | "asar": true,
45 | "mac": {
46 | "target": [
47 | "dmg",
48 | "zip"
49 | ],
50 | "category": "public.app-category.utilities"
51 | },
52 | "dmg": {
53 | "icon": "build/icon.icns",
54 | "iconSize": 128,
55 | "background": "build/background.tiff",
56 | "contents": [
57 | {
58 | "x": 134,
59 | "y": 289,
60 | "type": "file"
61 | },
62 | {
63 | "x": 405,
64 | "y": 289,
65 | "type": "link",
66 | "path": "/Applications"
67 | }
68 | ],
69 | "window": {
70 | "width": 540,
71 | "height": 490
72 | }
73 | },
74 | "win": {
75 | "target": [
76 | "portable",
77 | "nsis"
78 | ],
79 | "icon": "build/icon.ico"
80 | },
81 | "nsis": {
82 | "oneClick": false,
83 | "language": "2052",
84 | "perMachine": true,
85 | "allowToChangeInstallationDirectory": true
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/preload.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const package = require(path.join(__dirname, "./package.json"));
4 | const { contextBridge, ipcRenderer, clipboard, shell } = require("electron");
5 |
6 | const utils = {
7 | //node bridge
8 | getVersion: () => package.version,
9 | openGithub: () => shell.openExternal("https://github.com/Tairraos/tiktok-downloader"),
10 | openFolder: (target) => shell.showItemInFolder(target),
11 | readClipboard: () => clipboard.readText(),
12 | existDir: (dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory(),
13 | existFile: (filepath) => fs.existsSync(filepath),
14 |
15 | //ipc bridge
16 | exit: () => ipcRenderer.invoke("exit"),
17 | toggleKeepTop: (toggle) => ipcRenderer.invoke("keepTop", toggle),
18 | selectFolder: () => ipcRenderer.invoke("selectFolder"),
19 | getSetting: (item) => ipcRenderer.invoke("getSetting", item),
20 | setSetting: (item, value) => ipcRenderer.invoke("setSetting", item, value),
21 | download: (params) => ipcRenderer.invoke("download", params),
22 | resize: (w, h) => ipcRenderer.invoke("resize", w, h)
23 | };
24 |
25 | const eventList = ["downloadStart", "downloadEnd", "downloadProgress", "downloadPaused", "downloadStopped", "downloadError"];
26 | const ipc = {
27 | eventList,
28 | eventStore: Object.fromEntries(eventList.map((item) => [item, () => {}])),
29 | addEventListener: (event, callback) => {
30 | if (eventList.includes(event) && typeof callback === "function") {
31 | ipc.eventStore[event] = callback;
32 | }
33 | }
34 | };
35 |
36 | function prepareI18n(lang) {
37 | const i18n = {
38 | lang: lang,
39 | langList: [],
40 | select: (lang) => {
41 | if (lang.match(/^[a-z]{2}_[A-Z]{2}$/) && typeof i18n[lang] === "object") {
42 | i18n.lang = lang;
43 | }
44 | },
45 | get: (item, ...args) => {
46 | return (i18n[i18n.lang][item] || item).replace(/\{(\d+)\}/g, function (match, number) {
47 | return args[+number];
48 | });
49 | }
50 | };
51 | const root = path.join(__dirname, "i18n");
52 | const files = fs.readdirSync(root);
53 | files.forEach((file) => {
54 | const lang = path.basename(file, ".json");
55 | if (fs.statSync(path.join(root, file)).isFile() && lang.match(/^[a-z]{2}_[A-Z]{2}$/)) {
56 | i18n[lang] = require(path.join(root, file));
57 | i18n.langList.push({
58 | name: lang,
59 | local: i18n[lang]["language_name"]
60 | });
61 | }
62 | });
63 | return i18n;
64 | }
65 |
66 | async function initApp() {
67 | const lang = await utils.getSetting("lang"),
68 | target = await utils.getSetting("target"),
69 | record = await utils.getSetting("record");
70 |
71 | contextBridge.exposeInMainWorld("ipc", ipc);
72 | contextBridge.exposeInMainWorld("utils", utils);
73 | contextBridge.exposeInMainWorld("i18n", prepareI18n(lang));
74 | contextBridge.exposeInMainWorld("setting", { lang, target, record });
75 | eventList.forEach((item) => ipcRenderer.on(item, (event, data) => ipc.eventStore[item](data)));
76 | }
77 |
78 | contextBridge.exposeInMainWorld("initApp", initApp);
79 |
--------------------------------------------------------------------------------
/readme/feature.cn.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/readme/install chrome app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/install chrome app.png
--------------------------------------------------------------------------------
/readme/miniui.cn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/miniui.cn.png
--------------------------------------------------------------------------------
/readme/miniui.en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/miniui.en.png
--------------------------------------------------------------------------------
/readme/ui.cn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/ui.cn.png
--------------------------------------------------------------------------------
/readme/ui.en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Tairraos/TikDown/919e1ea572fe570ad205c69f8c81211b884594f5/readme/ui.en.png
--------------------------------------------------------------------------------
/renderer.js:
--------------------------------------------------------------------------------
1 | let dom = {
2 | root: $("#app-root"),
3 | header: $("#header-area"),
4 | footer: $("#footer-area"),
5 | taskLog: $("#task-area"),
6 | headerLeft: $("#header-area .left-area"),
7 | headerRight: $("#header-area .right-area"),
8 | footerLeft: $("#footer-area .left-area"),
9 | footerRight: $("#footer-area .right-area")
10 | },
11 | taskQue = {},
12 | config = {};
13 |
14 | function prepareConfig() {
15 | config.lang = setting.lang;
16 | config.target = setting.target;
17 | config.record = Array.from(setting.record);
18 | }
19 |
20 | function createUI() {
21 | document.title = i18n.get("TikDown", utils.getVersion());
22 |
23 | dom.btnPaste = genIconTextButton("paste", "Paste/Download");
24 | dom.btnKeepTop = genIconButton("keeptop", "Keep window on top");
25 | dom.btnWatch = genIconButton("watch", "Start clipboard monitoring (auto paste)");
26 | dom.btnStopWatch = genIconButton("stopwatch", "Stop clipboard monitoring");
27 | dom.btnQuitTop = genIconButton("quittop", "Exit window on top mode");
28 | dom.btnMiniWin = genIconButton("minimize", "Mini window mode");
29 | dom.btnNormalWin = genIconButton("maximize", "Normal window mode");
30 | dom.btnFolder = genIconButton("folder", "Change download folder");
31 | dom.btnFolderText = genFolderTextBtn();
32 | dom.selectLang = genLangSelector();
33 | dom.btnExit = genIconButton("exit", "Exit");
34 |
35 | dom.btnStopWatch.classList.add("hide");
36 | dom.btnQuitTop.classList.add("hide");
37 | dom.btnNormalWin.classList.add("hide");
38 |
39 | dom.headerLeft.appendChild(dom.btnPaste);
40 | dom.headerRight.appendChild(dom.btnWatch);
41 | dom.headerRight.appendChild(dom.btnStopWatch);
42 | dom.headerRight.appendChild(dom.btnKeepTop);
43 | dom.headerRight.appendChild(dom.btnQuitTop);
44 | dom.headerRight.appendChild(dom.btnMiniWin);
45 | dom.headerRight.appendChild(dom.btnNormalWin);
46 | dom.headerRight.appendChild(dom.btnFolder);
47 | dom.headerRight.appendChild(dom.btnFolderText);
48 | dom.headerRight.appendChild(dom.selectLang);
49 | dom.headerRight.appendChild(dom.btnExit);
50 |
51 | dom.btnGithub = genIconButton("github", "Github source");
52 | dom.staLogText = $(`
`);
53 | dom.statDownloading = genIconDataStat("downloading", "Downloading...", 0);
54 | dom.dataDownloading = dom.statDownloading.querySelector(".data");
55 | dom.statWaiting = genIconDataStat("waiting", "Waiting...", 0);
56 | dom.dataWaiting = dom.statWaiting.querySelector(".data");
57 | dom.statDownloaded = genIconDataStat("downloaded", "Downloaded", 0);
58 | dom.dataDownloaded = dom.statDownloaded.querySelector(".data");
59 | dom.statFailed = genIconDataStat("failed", "Failed", 0);
60 | dom.dataFailed = dom.statFailed.querySelector(".data");
61 |
62 | dom.footerLeft.appendChild(dom.btnGithub);
63 | dom.footerLeft.appendChild(dom.staLogText);
64 | dom.footerRight.appendChild(dom.statDownloading);
65 | dom.footerRight.appendChild(dom.statWaiting);
66 | dom.footerRight.appendChild(dom.statDownloaded);
67 | dom.footerRight.appendChild(dom.statFailed);
68 | }
69 |
70 | function createTaskUI(task) {
71 | const domtask = genTaskBox(task);
72 | dom.taskLog.appendChild(domtask);
73 | dom.taskLog.scrollTo(0, dom.taskLog.scrollHeight);
74 | return domtask;
75 | }
76 |
77 | function bindUIEvent() {
78 | dom.btnPaste.addEventListener("click", () => {
79 | manageClipboard(utils.readClipboard());
80 | });
81 |
82 | dom.btnGithub.addEventListener("click", () => {
83 | utils.openGithub();
84 | });
85 |
86 | dom.btnWatch.addEventListener("click", () => {
87 | watchClipboard(true);
88 | dom.btnStopWatch.classList.remove("hide");
89 | dom.btnWatch.classList.add("hide");
90 | });
91 |
92 | dom.btnStopWatch.addEventListener("click", () => {
93 | watchClipboard(false);
94 | dom.btnStopWatch.classList.add("hide");
95 | dom.btnWatch.classList.remove("hide");
96 | });
97 |
98 | dom.btnKeepTop.addEventListener("click", () => {
99 | utils.toggleKeepTop(true);
100 | dom.btnQuitTop.classList.remove("hide");
101 | dom.btnKeepTop.classList.add("hide");
102 | });
103 |
104 | dom.btnQuitTop.addEventListener("click", () => {
105 | utils.toggleKeepTop(false);
106 | dom.btnQuitTop.classList.add("hide");
107 | dom.btnKeepTop.classList.remove("hide");
108 | });
109 |
110 | dom.btnMiniWin.addEventListener("click", () => {
111 | dom.root.classList.add("mini");
112 | dom.btnMiniWin.classList.add("hide");
113 | dom.root.classList.remove("normal");
114 | dom.btnNormalWin.classList.remove("hide");
115 | utils.resize(166, 128);
116 | });
117 |
118 | dom.btnNormalWin.addEventListener("click", () => {
119 | dom.root.classList.add("normal");
120 | dom.btnNormalWin.classList.add("hide");
121 | dom.root.classList.remove("mini");
122 | dom.btnMiniWin.classList.remove("hide");
123 | utils.resize(800, 600);
124 | });
125 |
126 | dom.btnFolder.addEventListener("click", async () => {
127 | updateFolderTextUI(await utils.selectFolder());
128 | });
129 |
130 | dom.btnFolderText.addEventListener("click", () => {
131 | const target = dom.btnFolderText.innerText;
132 | if (utils.existDir(target)) {
133 | utils.openFolder(target);
134 | }
135 | });
136 |
137 | dom.selectLang.addEventListener("change", () => {
138 | updateI18nStringUI(dom.selectLang.value);
139 | });
140 |
141 | dom.btnExit.addEventListener("click", () => {
142 | utils.exit();
143 | });
144 | }
145 |
146 | function bindDownloadEvent() {
147 | Object.keys(downloadEventHandler).forEach((item) => {
148 | ipc.addEventListener(item, downloadEventHandler[item]);
149 | });
150 | }
151 |
152 | //start rendering
153 | initApp().then(() => {
154 | prepareConfig();
155 | createUI();
156 | bindUIEvent(); //bind UI event to DOM
157 | bindDownloadEvent(); //bind download event to IPC
158 | });
159 |
--------------------------------------------------------------------------------
/resource/downloaded.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/downloading.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/exit.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/failed.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/folder.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/resource/github.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/keeptop.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/maximize.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/minimize.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/paste.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/resource/pause.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/resource/quittop.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/resume.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/resource/stop.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/resource/stopwatch.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/resource/unknown.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/waiting.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/resource/watch.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | body,
2 | html {
3 | height: 100%;
4 | margin: 0;
5 | background-color: #d4d4d4;
6 | }
7 |
8 | button,
9 | span,
10 | div {
11 | font-family: Helvetica, Tahoma, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei";
12 | cursor: default;
13 | }
14 |
15 | button,
16 | select,
17 | button span,
18 | .canopen {
19 | cursor: pointer;
20 | }
21 |
22 | .hide {
23 | display: none !important;
24 | }
25 |
26 | .icon {
27 | display: inline-block;
28 | vertical-align: middle;
29 | }
30 |
31 | .btn-paste .icon {
32 | width: 32px;
33 | height: 32px;
34 | margin-top: 5px;
35 | }
36 |
37 | .normal .icon-btn .icon {
38 | width: 16px;
39 | height: 16px;
40 | }
41 |
42 | .mini .icon-btn .icon {
43 | width: 14px;
44 | height: 14px;
45 | }
46 |
47 | .normal .icon-data-stat .icon {
48 | width: 12px;
49 | height: 12px;
50 | margin-right: 4px;
51 | }
52 |
53 | .mini .icon-data-stat .icon {
54 | width: 12px;
55 | height: 12px;
56 | margin-right: 4px;
57 | }
58 |
59 | #app-root {
60 | display: flex;
61 | flex-direction: column;
62 | height: 100%;
63 | }
64 |
65 | /* layout */
66 | .normal #header-area {
67 | height: 76px;
68 | background: #d4d4d4;
69 | border-bottom: 1px solid #b0b0b0;
70 | display: flex;
71 | flex-direction: row;
72 | }
73 |
74 | .mini #header-area {
75 | height: 76px;
76 | background: #d4d4d4;
77 | display: flex;
78 | flex-direction: row;
79 | width: 165px;
80 | }
81 |
82 | .normal #header-area .left-area {
83 | line-height: 75px;
84 | flex: 1;
85 | }
86 |
87 | .mini #header-area .left-area {
88 | line-height: 75px;
89 | width: 136px;
90 | }
91 |
92 | .normal #header-area .right-area {
93 | padding: 2px 10px 2px 0;
94 | display: flex;
95 | align-items: flex-end;
96 | }
97 |
98 | .mini #header-area .right-area {
99 | display: flex;
100 | flex-direction: column;
101 | justify-content: center;
102 | }
103 |
104 | .normal #footer-area {
105 | height: 29px;
106 | border-top: 1px solid #b0b0b0;
107 | display: flex;
108 | }
109 | .mini #footer-area {
110 | width: 165px;
111 | height: 16px;
112 | line-height: 12px;
113 | }
114 |
115 | .normal #footer-area .right-area {
116 | flex: 1;
117 | text-align: right;
118 | }
119 |
120 | .mini #footer-area .right-area {
121 | text-align: left;
122 | padding-left: 10px;
123 | }
124 |
125 | .normal #task-area {
126 | flex: 1;
127 | overflow-x: hidden;
128 | overflow-y: auto;
129 | background-color: #f0f0f0;
130 | }
131 |
132 | .mini #task-area,
133 | .mini #footer-area .left-area {
134 | display: none;
135 | }
136 |
137 | /* components */
138 | .icon-text-btn .text {
139 | display: block;
140 | margin-top: 5px;
141 | font-size: 12px;
142 | color: #333;
143 | height: 12px;
144 | }
145 |
146 | .btn-paste {
147 | height: 66px;
148 | width: 120px;
149 | border-radius: 5px;
150 | vertical-align: middle;
151 | background-color: #d0d0d0;
152 | border: 2px solid transparent;
153 | margin: 4px 8px;
154 | outline: 1px dashed;
155 | }
156 |
157 | .normal .icon-btn {
158 | height: 28px;
159 | width: 28px;
160 | fill: #333;
161 | padding: 0;
162 | margin-left: 4px;
163 | border-radius: 4px;
164 | border: 0;
165 | vertical-align: middle;
166 | background-color: transparent;
167 | }
168 |
169 | .mini .icon-btn {
170 | height: 22px;
171 | width: 24px;
172 | fill: #333;
173 | padding: 0;
174 | border-radius: 4px;
175 | border: 0;
176 | font-size: 0;
177 | background-color: transparent;
178 | }
179 |
180 | .normal .text-stat,
181 | .normal .btn-stat {
182 | height: 20px;
183 | display: inline-block;
184 | text-align: left;
185 | color: #333;
186 | font-size: 13px;
187 | line-height: 20px;
188 | vertical-align: middle;
189 | overflow: hidden;
190 | text-overflow: ellipsis;
191 | white-space: nowrap;
192 | }
193 |
194 | .normal #header-area .btn-stat {
195 | margin: 4px 10px 4px 2px;
196 | padding: 0 5px;
197 | direction: rtl;
198 | background: #f0f0f0;
199 | border: 1px solid #666;
200 | border-radius: 2px;
201 | width: 220px;
202 | }
203 |
204 | .normal #header-area .btn-stat.error {
205 | border: 1px solid #bd403a;
206 | }
207 |
208 | .normal #header-area .select-lang {
209 | height: 20px;
210 | margin: 4px 10px 4px 0;
211 | background: #f0f0f0;
212 | border: 1px solid #666;
213 | border-radius: 2px;
214 | outline: none;
215 | font-size: 13px;
216 | }
217 |
218 | .mini #header-area .btn-stat,
219 | .mini #header-area .select-lang,
220 | .mini #header-area .btn-folder {
221 | display: none;
222 | }
223 |
224 | .icon-btn:hover,
225 | .icon-text-btn:hover {
226 | background-color: #c0c0c0;
227 | }
228 |
229 | .icon-btn:active,
230 | .icon-text-btn:active {
231 | background-color: #b0b0b0;
232 | }
233 |
234 | .normal #footer-area .icon-data-stat {
235 | display: inline-block;
236 | font-size: 0;
237 | height: 18px;
238 | margin-right: 20px;
239 | }
240 |
241 | .mini #footer-area .icon-data-stat {
242 | display: inline-block;
243 | font-size: 0;
244 | margin-right: 12px;
245 | }
246 |
247 | .icon-data-stat .data {
248 | display: inline-block;
249 | color: #333;
250 | font-size: 12px;
251 | vertical-align: middle;
252 | }
253 |
254 | .normal .btn-github {
255 | width: 24px;
256 | height: 24px;
257 | margin: 2px 5px;
258 | }
259 |
260 | .stat-downloading svg {
261 | fill: #d59720;
262 | }
263 |
264 | .stat-waiting svg {
265 | fill: #929191;
266 | }
267 |
268 | .stat-downloaded svg {
269 | fill: #24a137;
270 | }
271 |
272 | .stat-failed svg {
273 | fill: #bd403a;
274 | }
275 |
276 | /* effect */
277 | body #app-root .border-flash-ok {
278 | animation: flash-green 0.6s linear;
279 | }
280 |
281 | body #app-root .border-flash-error {
282 | animation: flash-red 0.6s linear;
283 | }
284 |
285 | @keyframes flash-green {
286 | 0% {
287 | border-color: #24a137ff;
288 | }
289 | 20% {
290 | border-color: #24a13700;
291 | }
292 | 40% {
293 | border-color: #24a137ff;
294 | }
295 | 60% {
296 | border-color: #24a13700;
297 | }
298 | 80% {
299 | border-color: #24a137ff;
300 | }
301 | 100% {
302 | border-color: #24a13700;
303 | }
304 | }
305 |
306 | @keyframes flash-red {
307 | 0% {
308 | border-color: #bd403aff;
309 | }
310 | 20% {
311 | border-color: #bd403a00;
312 | }
313 | 40% {
314 | border-color: #bd403aff;
315 | }
316 | 60% {
317 | border-color: #bd403a00;
318 | }
319 | 80% {
320 | border-color: #bd403aff;
321 | }
322 | 100% {
323 | border-color: #bd403a00;
324 | }
325 | }
326 |
327 | #task-area .task-box {
328 | height: 80px;
329 | display: flex;
330 | flex-direction: row;
331 | border: 1px solid #929191;
332 | border-radius: 4px;
333 | margin: 6px 5px;
334 | font-size: 13px;
335 | color: #222;
336 | background: #f0f0f0;
337 | }
338 |
339 | #task-area .task-box.downloading {
340 | border-color: #d59720;
341 | }
342 |
343 | #task-area .task-box.waiting {
344 | border-color: #929191;
345 | }
346 |
347 | #task-area .task-box.downloaded {
348 | border-color: #24a137;
349 | }
350 |
351 | #task-area .task-box.failed {
352 | border-color: #bd403a;
353 | }
354 |
355 | #task-area .task-thumb {
356 | width: 135px;
357 | }
358 |
359 | #task-area .task-cover {
360 | width: 120px;
361 | height: 70px;
362 | margin: 4px 5px;
363 | border-radius: 5px;
364 | overflow: hidden;
365 | display: flex;
366 | align-items: center;
367 | }
368 |
369 | #task-area .task-thumb img {
370 | width: 120px;
371 | }
372 | #task-area .task-thumb svg {
373 | width: 96px;
374 | height: 54px;
375 | }
376 |
377 | #task-area .task-info {
378 | display: flex;
379 | align-items: flex-start;
380 | flex-direction: column;
381 | justify-content: center;
382 | width: 540px;
383 | }
384 | #task-area .task-url,
385 | #task-area .task-title,
386 | #task-area .task-download {
387 | height: 20px;
388 | align-items: center;
389 | width: 540px;
390 | overflow: hidden;
391 | text-overflow: ellipsis;
392 | white-space: nowrap;
393 | }
394 |
395 | #task-area .task-download {
396 | display: flex;
397 | flex-direction: row;
398 | align-items: center;
399 | }
400 |
401 | #task-area .task-size {
402 | display: inline-block;
403 | min-width: 60px;
404 | margin-right: 20px;
405 | }
406 | #task-area .task-progressbar {
407 | width: 200px;
408 | height: 4px;
409 | border: 1px solid #ccc;
410 | border-radius: 4px;
411 | display: flex;
412 | }
413 | #task-area .task-progress {
414 | width: 25px;
415 | height: 4px;
416 | display: inline-block;
417 | background: #23a237;
418 | }
419 |
420 | #task-area .task-status {
421 | padding: 10px;
422 | flex: 1;
423 | display: flex;
424 | text-align: center;
425 | align-items: center;
426 | flex-direction: row-reverse;
427 | }
428 |
--------------------------------------------------------------------------------
/tool/buildSvgSymbol.js:
--------------------------------------------------------------------------------
1 | var fs = require("fs"),
2 | path = require("path"),
3 | cheerio = require("cheerio"),
4 | svgSource = path.join(__dirname, "../resource"),
5 | targetJsFile = path.join(__dirname, "../icons.js");
6 |
7 | var svgBuilder = {
8 | getSvgList: () => fs.readdirSync(svgSource).filter((name) => fs.statSync(path.join(svgSource + "/" + name)).isFile() && name.match(/\.svg$/)),
9 | getFileContent: (file) => fs.readFileSync(file, { encoding: "utf8" }),
10 | saveFile: (file, content) => fs.writeFileSync(file, content, { encoding: "utf8" }),
11 | isUselessTag: (name) => ["symbol", "title", "desc", "use", "script"].indexOf(name) > -1,
12 | isEmptyTag: (dom) => JSON.stringify(dom.attr()) === "{}" && !dom.children().length,
13 | fetchSymbol: function (filename) {
14 | var fileContent = svgBuilder.getFileContent(path.join(svgSource, filename)),
15 | $ = cheerio.load(fileContent, {
16 | normalizeWhitespace: false,
17 | xmlMode: true
18 | }),
19 | id = "icon-" + filename.replace(/\.svg$/, "").replace(/\s+/g, "-"),
20 | svg = $("svg"),
21 | children = svg.children(),
22 | symbolContent = [];
23 | console.log("Building:", filename);
24 | svg.find("g,circle,ellipse,image,line,path,pattern,polygon,polyline,rect,text").removeAttr("id");
25 | symbolContent.push('
');
26 | for (var i = 0; i < children.length; i++) {
27 | var child = children.eq(i);
28 | if (!svgBuilder.isUselessTag(children.get(i).tagName) && !svgBuilder.isEmptyTag(child)) {
29 | symbolContent.push(" " + $.html(child));
30 | }
31 | }
32 | symbolContent.push("");
33 | return symbolContent;
34 | },
35 |
36 | doBuild: function () {
37 | var svgContent = [];
38 | var nameList = svgBuilder.getSvgList(svgSource);
39 | svgContent.push('
');
40 | svgContent.push('");
47 | svgContent.push("
");
48 |
49 | console.log("Saving JS file to ", targetJsFile);
50 | let outContent = [
51 | `const svgSymbol = [`,
52 | svgContent.map((line) => `'${line}'`).join(",\n"),
53 | `]`,
54 | `document.body.insertAdjacentHTML("afterBegin", svgSymbol.join(""));`
55 | ];
56 | svgBuilder.saveFile(targetJsFile, outContent.join("\n"));
57 | console.log("Build success.");
58 | }
59 | };
60 |
61 | svgBuilder.doBuild();
62 |
--------------------------------------------------------------------------------