├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github └── issue_template.md ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── main ├── data │ └── extensions.js ├── donate.js ├── download.js ├── index.js ├── menu.js ├── notification.js ├── settings.js ├── sources.js ├── touchbar.js ├── updater.js ├── utils.js └── windows │ ├── about.js │ ├── check.js │ ├── main.js │ └── progress.js ├── package-lock.json ├── package.json └── renderer ├── actions ├── index.js ├── search.js └── ui.js ├── components ├── Content.js ├── Credits.js ├── Drop.js ├── FilePath.js ├── Footer.js ├── FooterAbout.js ├── Info.js ├── LanguageToggle.js ├── Layout.js ├── List.js ├── ListEmpty.js ├── ListItem.js ├── Loading.js ├── Logo.js ├── Meta.js ├── Search.js ├── SearchField.js ├── SoftwareItem.js ├── Title.js ├── TitleBar.js └── __tests__ │ ├── Content.test.js │ ├── Info.test.js │ └── __snapshots__ │ ├── Content.test.js.snap │ └── Info.test.js.snap ├── containers ├── Content.js ├── Footer.js └── Search.js ├── data ├── languages.js └── software.js ├── next.config.js ├── pages ├── about.js ├── check.js ├── progress.js └── start.js ├── reducers ├── index.js ├── search.js └── ui.js ├── static ├── icon.icns ├── icon.ico ├── icon.iconset │ ├── ..icns │ ├── icon_1024x1024.png │ ├── icon_128x128.png │ ├── icon_16x16.png │ ├── icon_256x256.png │ ├── icon_32x32.png │ ├── icon_512x512.png │ └── icon_64x64.png ├── icon.png └── loading.gif ├── store └── index.js ├── types └── index.js └── utils ├── index.js └── tracking.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": "next/babel" 5 | }, 6 | "production": { 7 | "presets": "next/babel" 8 | }, 9 | "test": { 10 | "presets": [["env", { "modules": "commonjs" }], "next/babel"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | server.js 3 | public 4 | build 5 | build-ci 6 | flow-typed 7 | flow 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "env": { 4 | "browser": true, 5 | "jest": true, 6 | "node": true 7 | }, 8 | "globals": { 9 | __CLIENT__: true, 10 | __DEVELOPMENT__: true, 11 | __PRODUCTION__: true 12 | }, 13 | "parser": "babel-eslint", 14 | "rules": { 15 | "class-methods-use-this": "warn", 16 | "global-require": "off", 17 | "indent": ["error", 2, { "SwitchCase": 1 }], 18 | "key-spacing": [ 19 | "error", 20 | { 21 | "mode": "minimum", 22 | "beforeColon": false, 23 | "afterColon": true 24 | } 25 | ], 26 | "react/react-in-jsx-scope": "off", 27 | "arrow-parens": "off", 28 | "react/jsx-curly-brace-presence": "off", 29 | "react/jsx-wrap-multilines": "off", 30 | "max-len": "off", 31 | "new-cap": ["error", { "capIsNewExceptions": ["Immutable"] }], 32 | "no-confusing-arrow": ["error", { "allowParens": true }], 33 | "no-fallthrough": ["error", { "commentPattern": "break[\\s\\w]*omitted" }], 34 | "no-lonely-if": "warn", 35 | "no-mixed-operators": "warn", 36 | "no-multi-spaces": [ 37 | "error", 38 | { 39 | "exceptions": { 40 | "VariableDeclarator": true, 41 | "AssignmentExpression": true, 42 | "ImportDeclaration": true 43 | } 44 | } 45 | ], 46 | "no-plusplus": "off", 47 | "no-underscore-dangle": "off", 48 | "no-use-before-define": "warn", 49 | "no-useless-escape": "warn", 50 | "quotes": 0, 51 | // React rules 52 | "react/forbid-prop-types": "off", 53 | "react/jsx-filename-extension": [ 54 | "error", 55 | { "extensions": [".jsx", ".js"] } 56 | ], 57 | "react/jsx-first-prop-new-line": "off", 58 | "react/no-unused-prop-types": "off", 59 | "react/self-closing-comp": ["error", { "component": true, "html": false }], 60 | // jsx-a11y 61 | "jsx-a11y/img-has-alt": "warn", 62 | "jsx-a11y/label-has-for": "warn", 63 | "jsx-a11y/no-static-element-interactions": "warn", 64 | // Import rules 65 | "import/default": "error", 66 | "import/named": "error", 67 | "import/namespace": "error", 68 | "import/no-extraneous-dependencies": "warn", 69 | "import/no-named-as-default": "warn", 70 | "import/prefer-default-export": "warn", 71 | "jsx-a11y/href-no-hash": "off", 72 | "jsx-a11y/anchor-is-valid": ["warn", { "aspects": ["invalidHref"] }], 73 | "jsx-a11y/no-noninteractive-element-interactions": "off" 74 | }, 75 | "plugins": ["react", "import"], 76 | "settings": { 77 | "import/ignore": ["node_modules", ".(scss|less|css|svg)$"], 78 | "import/parser": "babel-eslint", 79 | "import/resolver": { 80 | "webpack": { 81 | "config": { 82 | "resolve": { 83 | "extensions": ["", ".js", ".jsx", ".json"], 84 | "modulesDirectories": ["node_modules", "src"] 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | - [ ] I have searched the [issues](https://github.com/gielcobben/Caption/issues) of this repository and believe that this is not a duplicate. 10 | 11 | ## Expected Behavior 12 | 13 | 14 | 15 | ## Current Behavior 16 | 17 | 18 | 19 | ## Steps to Reproduce (for bugs) 20 | 21 | 22 | 1. 23 | 2. 24 | 3. 25 | 4. 26 | 27 | ## Context 28 | 29 | 30 | 31 | ## Your Environment 32 | 33 | | Tech | Version | 34 | |----------------|---------| 35 | | app version | | 36 | | OS | | 37 | | etc | | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist 3 | renderer/.next 4 | renderer/out 5 | 6 | # dependencies 7 | node_modules 8 | 9 | # logs 10 | npm-debug.log 11 | 12 | # mac 13 | .DS_Store 14 | 15 | # dotenv 16 | .env 17 | 18 | # update files 19 | dev-app-update.yml -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | 5 | jobs: 6 | include: 7 | - stage: test 8 | script: npm run test 9 | - stage: build 10 | script: npm run build -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at g.cobben@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to Caption 2 | 3 | 1. Fork this repository to your own GitHub account and then clone it to your local device 4 | 2. Install the dependencies: npm install 5 | 3. Run the app by building the code and watch for changes: npm start 6 | 4. Build the actual app for all platforms (Mac, Windows and Linux): `npm run dist` 7 | 8 | ## Financial contributions 9 | 10 | We also welcome financial contributions in full transparency on our [open collective](https://opencollective.com/caption). 11 | Anyone can file an expense. If the expense makes sense for the development of the community, it will be "merged" in the ledger of our open collective by the core contributors and the person who filed the expense will be reimbursed. 12 | 13 | ### Contributors 14 | 15 | Thank you to all the people who have already contributed to Caption! 16 | 17 | 18 | 19 | ### Backers 20 | 21 | Thank you to all our backers! [[Become a backer](https://opencollective.com/caption#backer)] 22 | 23 | 24 | 25 | 26 | ### Sponsors 27 | 28 | Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/caption#sponsor)) 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Giel Cobben (gielcobben.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | icon
3 | Caption 4 |
5 |
6 |

7 | 8 |
9 | 10 |

11 | banner 12 |
13 |

INTRODUCTION
14 |

Caption takes the effort out of finding and setting up the right subtitles. A simple design, drag & drop search, and automatic downloading & renaming let you just start watching. Caption is multi-platform, open-source, and built entirely on web technology.

15 |

Download Caption.

16 |

17 | 18 | 19 | 20 |

21 |

22 | 23 |
24 | 25 | ## ⚡️ Contribute 26 | 27 | Caption is completely open-source. We've tried to make it as easy as possible to 28 | contribute. If you'd like to help out by adding features, working on bug fixes, 29 | or assisting in other parts of development, here's how to get started: 30 | 31 | ###### To begin working locally: 32 | 33 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your 34 | own GitHub account 35 | 2. [Clone](https://help.github.com/articles/cloning-a-repository/) it to your 36 | local device: `git clone git@github.com:gielcobben/caption.git` 37 | 3. Install the dependencies: `npm install` 38 | 4. Run the app by starting electron, building the code and watch for changes: 39 | `npm start` 40 | ###### To build for production (should not generally be used): 41 | 5. Build the actual app for all platforms (Mac, Windows and Linux): `npm run 42 | dist` 43 | 44 |
45 | 46 | ## 📦 Sources 47 | 48 | Caption currently uses 2 sources to gather subtitles. We're continuously adding 49 | sources, but the app's open-source nature also allows you to add your own when 50 | desired. This can be done in 51 | [Caption Core](https://github.com/gielcobben/caption-core). 52 | 53 | ###### Standard sources: 54 | 55 | * [x] OpenSubtitles 56 | * [x] Addic7ed 57 | 58 |
59 | 60 | ## ⭐️ Links 61 | 62 | ###### Makers: 63 | 64 | * [Giel Cobben](https://github.com/gielcobben) 65 | * [Vernon de Goede](https://github.com/vernondegoede) 66 | 67 | ###### Friends: 68 | 69 | * [Rick Wong](https://github.com/RickWong) 70 | * [Huub Gelissen](https://twitter.com/gelissenhuub) 71 | 72 | ###### Repositories: 73 | 74 | * [Caption Core](https://github.com/gielcobben/caption-core) 75 | * [Caption Website](https://github.com/gielcobben/getcaption.co) 76 | 77 |
78 | 79 | ## 👨‍👨‍👧‍👦 Open-source 80 | 81 | ###### Contributors: 82 | 83 | 84 | 85 | ###### Backers: 86 | 87 | 88 | 89 | ###### Sponsors: 90 | 91 | Support this project by becoming a sponsor.
Your logo will show up here 92 | with a link to your website. 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | ## 🔑 License 116 | 117 | [MIT](https://github.com/gielcobben/Caption/blob/master/LICENSE) © 118 | [Giel Cobben](https://twitter.com/gielcobben) 119 | -------------------------------------------------------------------------------- /main/data/extensions.js: -------------------------------------------------------------------------------- 1 | const extensions = [ 2 | "3g2", 3 | "3gp", 4 | "3gp2", 5 | "3gpp", 6 | "60d", 7 | "ajp", 8 | "asf", 9 | "asx", 10 | "avchd", 11 | "avi", 12 | "bik", 13 | "bix", 14 | "box", 15 | "cam", 16 | "dat", 17 | "divx", 18 | "dmf", 19 | "dv", 20 | "dvr-ms", 21 | "evo", 22 | "flc", 23 | "fli", 24 | "flic", 25 | "flv", 26 | "flx", 27 | "gvi", 28 | "gvp", 29 | "h264", 30 | "m1v", 31 | "m2p", 32 | "m2ts", 33 | "m2v", 34 | "m4e", 35 | "m4v", 36 | "mjp", 37 | "mjpeg", 38 | "mjpg", 39 | "mkv", 40 | "moov", 41 | "mov", 42 | "movhd", 43 | "movie", 44 | "movx", 45 | "mp4", 46 | "mpe", 47 | "mpeg", 48 | "mpg", 49 | "mpv", 50 | "mpv2", 51 | "mxf", 52 | "nsv", 53 | "nut", 54 | "ogg", 55 | "ogm", 56 | "omf", 57 | "ps", 58 | "qt", 59 | "ram", 60 | "rm", 61 | "rmvb", 62 | "swf", 63 | "ts", 64 | "vfw", 65 | "vid", 66 | "video", 67 | "viv", 68 | "vivo", 69 | "vob", 70 | "vro", 71 | "wm", 72 | "wmv", 73 | "wmx", 74 | "wrap", 75 | "wvx", 76 | "wx", 77 | "x264", 78 | "xvid", 79 | ]; 80 | 81 | module.exports = extensions; 82 | -------------------------------------------------------------------------------- /main/donate.js: -------------------------------------------------------------------------------- 1 | const { dialog, shell } = require("electron"); 2 | 3 | const getDownloadCount = () => 4 | parseInt(global.store.get("download-count", 0), 10); 5 | 6 | const increaseDownloadCounter = () => { 7 | const currentCount = getDownloadCount(); 8 | global.store.set("download-count", currentCount + 1); 9 | }; 10 | 11 | const preventFuturePopups = () => 12 | global.store.set("prevent-donate-popups", true); 13 | 14 | const allowFuturePopups = () => 15 | global.store.set("prevent-donate-popups", false); 16 | 17 | const shouldHidePopups = () => global.store.get("prevent-donate-popups", false); 18 | 19 | const showDonatePopup = () => { 20 | if (shouldHidePopups()) { 21 | return; 22 | } 23 | 24 | const callback = (clickedButtonIndex, hideFuturePopups = false) => { 25 | const { mainWindow } = global.windows; 26 | 27 | if (clickedButtonIndex === 0) { 28 | shell.openExternal("https://www.paypal.me/gielcobben"); 29 | mainWindow.webContents.send("logDonated"); 30 | } 31 | 32 | if (hideFuturePopups) { 33 | preventFuturePopups(); 34 | } 35 | }; 36 | 37 | dialog.showMessageBox( 38 | { 39 | buttons: ["Donate", "Later"], 40 | defaultId: 0, 41 | title: "Thank you for using Caption!", 42 | message: 43 | "Thanks for using Caption! Caption is and will always be free. If you enjoy using it, please consider a donation to the authors.", 44 | checkboxLabel: "Don't show this again", 45 | }, 46 | callback, 47 | ); 48 | }; 49 | 50 | const triggerDonateWindow = () => { 51 | increaseDownloadCounter(); 52 | const currentDownloadCount = getDownloadCount(); 53 | 54 | if (currentDownloadCount >= 9 && currentDownloadCount % 3 === 0) { 55 | showDonatePopup(); 56 | } 57 | }; 58 | 59 | module.exports = { triggerDonateWindow, allowFuturePopups }; 60 | -------------------------------------------------------------------------------- /main/download.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { dialog } = require("electron"); 3 | const notification = require("./notification"); 4 | const Caption = require("caption-core"); 5 | const { triggerDonateWindow } = require("./donate"); 6 | 7 | const multiDownload = files => { 8 | const resultSet = []; 9 | const { mainWindow } = global.windows; 10 | 11 | try { 12 | const downloadFiles = files.map(item => 13 | new Promise(resolve => { 14 | const downloadLocation = path.dirname(item.file.path); 15 | const originalFileName = item.file.name; 16 | const subtitleFilename = originalFileName.replace(/\.[^/.]+$/, ""); 17 | 18 | return Caption.download( 19 | item, 20 | item.source, 21 | `${downloadLocation}/${subtitleFilename}.srt`, 22 | ).then(() => { 23 | resultSet.push(`${downloadLocation}/${subtitleFilename}.srt`); 24 | mainWindow.webContents.send("updateFileSearchStatus", { 25 | filePath: item.file.path, 26 | status: "done", 27 | }); 28 | resolve(); 29 | }); 30 | })); 31 | 32 | Promise.all(downloadFiles).then(() => { 33 | const message = 34 | resultSet.length > 0 35 | ? `${resultSet.length} subtitles have been successfully downloaded!` 36 | : "Could not find any subtitles."; 37 | 38 | notification(message); 39 | mainWindow.webContents.send("allFilesDownloaded"); 40 | 41 | triggerDonateWindow(); 42 | }); 43 | } catch (err) { 44 | console.log("error", err); 45 | } 46 | }; 47 | 48 | const singleDownload = async item => { 49 | const hasExtension = item.name.includes(".srt"); 50 | const filename = hasExtension ? item.name : `${item.name}.srt`; 51 | const { mainWindow } = global.windows; 52 | const saveToPath = await new Promise(resolve => { 53 | dialog.showSaveDialog( 54 | mainWindow, 55 | { 56 | title: "Download", 57 | defaultPath: filename, 58 | }, 59 | resolve, 60 | ); 61 | }); 62 | 63 | if (!saveToPath) { 64 | return; 65 | } 66 | 67 | try { 68 | Caption.download(item, item.source, saveToPath) 69 | .then(() => { 70 | notification(`${item.name} is successfully downloaded!`); 71 | mainWindow.webContents.send("singleDownloadSuccesfull", item); 72 | 73 | triggerDonateWindow(); 74 | }) 75 | .catch(err => console.log("error", err)); 76 | } catch (err) { 77 | console.log("err", err); 78 | } 79 | }; 80 | 81 | module.exports = { multiDownload, singleDownload }; 82 | -------------------------------------------------------------------------------- /main/index.js: -------------------------------------------------------------------------------- 1 | const Store = require("electron-store"); 2 | const prepareNext = require("electron-next"); 3 | const { app, ipcMain, dialog } = require("electron"); 4 | const { moveToApplications } = require("electron-lets-move"); 5 | 6 | const buildMenu = require("./menu"); 7 | const initSettings = require("./settings"); 8 | const notification = require("./notification"); 9 | const { processFiles } = require("./utils"); 10 | const { checkForUpdates } = require("./updater"); 11 | const { singleDownload } = require("./download"); 12 | const { textSearch, fileSearch } = require("./sources"); 13 | 14 | // Windows 15 | const { createMainWindow } = require("./windows/main"); 16 | const { createAboutWindow, closeAboutWindow } = require("./windows/about"); 17 | const { createCheckWindow, closeCheckWindow } = require("./windows/check"); 18 | const { 19 | createProgressWindow, 20 | closeProgressWindow, 21 | } = require("./windows/progress"); 22 | 23 | const store = new Store(); 24 | 25 | // Window variables 26 | let willQuitApp = false; 27 | 28 | // Functions 29 | const downloadSubtitle = item => { 30 | if (!item) { 31 | return false; 32 | } 33 | 34 | return singleDownload(item); 35 | }; 36 | 37 | const showErrorDialog = online => { 38 | if (!online) { 39 | dialog.showErrorBox( 40 | "Oops, something went wrong", 41 | "It seems like your computer is offline! Please connect to the internet to use Caption.", 42 | ); 43 | } 44 | }; 45 | 46 | // App Events 47 | app.on("before-quit", () => { 48 | global.windows.checkWindow = null; 49 | willQuitApp = true; 50 | }); 51 | 52 | app.on("window-all-closed", () => { 53 | if (process.platform !== "darwin") { 54 | app.quit(); 55 | } 56 | }); 57 | 58 | app.on("activate", () => { 59 | const { mainWindow } = global.windows; 60 | 61 | if (mainWindow === null) { 62 | global.windows.mainWindow = createMainWindow(); 63 | } 64 | }); 65 | 66 | app.on("ready", async () => { 67 | await prepareNext("./renderer"); 68 | 69 | if (!store.get("moved")) { 70 | await moveToApplications(); 71 | store.set("moved", true); 72 | } 73 | 74 | global.store = store; 75 | 76 | global.updater = { 77 | onStartup: true, 78 | }; 79 | 80 | // Windows 81 | global.windows = { 82 | mainWindow: createMainWindow(), 83 | aboutWindow: createAboutWindow(), 84 | checkWindow: createCheckWindow(), 85 | progressWindow: createProgressWindow(), 86 | }; 87 | 88 | const { 89 | mainWindow, 90 | aboutWindow, 91 | checkWindow, 92 | progressWindow, 93 | } = global.windows; 94 | 95 | mainWindow.on("close", () => { 96 | global.windows = null; 97 | app.exit(); 98 | app.quit(); 99 | }); 100 | aboutWindow.on("close", event => closeAboutWindow(event, willQuitApp)); 101 | checkWindow.on("close", event => closeCheckWindow(event, willQuitApp)); 102 | progressWindow.on("close", event => closeProgressWindow(event, willQuitApp)); 103 | 104 | // Setup 105 | buildMenu(); 106 | initSettings(); 107 | checkForUpdates(); 108 | 109 | // IPC events 110 | ipcMain.on("textSearch", (event, query, language) => { 111 | textSearch(query, language, "all"); 112 | }); 113 | 114 | ipcMain.on("fileSearch", (event, files, language) => { 115 | fileSearch(files, language, "best"); 116 | }); 117 | 118 | ipcMain.on("downloadSubtitle", (event, item) => { 119 | downloadSubtitle(item); 120 | }); 121 | 122 | ipcMain.on("online", (event, online) => { 123 | showErrorDialog(online); 124 | }); 125 | 126 | ipcMain.on("notification", (event, message) => { 127 | notification(message); 128 | }); 129 | 130 | ipcMain.on("processFiles", (event, droppedItems) => { 131 | processFiles(droppedItems); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /main/menu.js: -------------------------------------------------------------------------------- 1 | const { app, shell, Menu } = require("electron"); 2 | const isDev = require("electron-is-dev"); 3 | const { checkForUpdates } = require("./updater"); 4 | const { showAboutWindow } = require("./windows/about"); 5 | const { allowFuturePopups: allowDonationPopups } = require("./donate"); 6 | const { platform } = require("os"); 7 | 8 | const isWindows = platform() === "win32"; 9 | const isLinux = platform() === "linux"; 10 | 11 | const helpMenu = [ 12 | { 13 | label: "Donate", 14 | click: () => { 15 | const { mainWindow } = global.windows; 16 | shell.openExternal("https://www.paypal.me/gielcobben"); 17 | mainWindow.webContents.send("logDonated"); 18 | }, 19 | }, 20 | { 21 | label: "Learn More", 22 | click: () => shell.openExternal("https://getcaption.co/"), 23 | }, 24 | { 25 | label: "Support", 26 | click: () => shell.openExternal("https://twitter.com/gielcobben"), 27 | }, 28 | { 29 | label: "Report Issue", 30 | click: () => 31 | shell.openExternal("https://github.com/gielcobben/caption/issues/new"), 32 | }, 33 | { 34 | label: "Search Issues", 35 | click: () => 36 | shell.openExternal("https://github.com/gielcobben/Caption/issues"), 37 | }, 38 | ]; 39 | 40 | if (isWindows || isLinux) { 41 | helpMenu.splice(0, 0, { 42 | label: "Check for updates...", 43 | click: () => checkForUpdates(), 44 | }); 45 | } 46 | 47 | const buildMenu = () => { 48 | const template = [ 49 | { 50 | label: "Edit", 51 | submenu: [ 52 | { role: "undo" }, 53 | { role: "redo" }, 54 | { type: "separator" }, 55 | { role: "cut" }, 56 | { role: "copy" }, 57 | { role: "paste" }, 58 | { role: "pasteandmatchstyle" }, 59 | { role: "delete" }, 60 | { role: "selectall" }, 61 | ], 62 | }, 63 | { 64 | label: "View", 65 | submenu: isDev 66 | ? [ 67 | { role: "reload" }, 68 | { role: "forcereload" }, 69 | { role: "toggledevtools" }, 70 | { type: "separator" }, 71 | { 72 | label: "Allow donation popups", 73 | click: () => allowDonationPopups(), 74 | }, 75 | { type: "separator" }, 76 | ] 77 | : [{ role: "togglefullscreen" }], 78 | }, 79 | { 80 | role: "window", 81 | submenu: [{ role: "minimize" }, { role: "close" }], 82 | }, 83 | { 84 | role: "help", 85 | submenu: helpMenu, 86 | }, 87 | ]; 88 | 89 | if (process.platform === "darwin") { 90 | template.unshift({ 91 | label: app.getName(), 92 | submenu: [ 93 | { 94 | label: `About ${app.getName()}`, 95 | click: () => showAboutWindow(), 96 | }, 97 | { label: "Check for updates...", click: () => checkForUpdates() }, 98 | { type: "separator" }, 99 | { role: "services", submenu: [] }, 100 | { type: "separator" }, 101 | { role: "hide" }, 102 | { role: "hideothers" }, 103 | { role: "unhide" }, 104 | { type: "separator" }, 105 | { role: "quit" }, 106 | ], 107 | }); 108 | 109 | // Edit menu 110 | template[1].submenu.push( 111 | { type: "separator" }, 112 | { 113 | label: "Speech", 114 | submenu: [{ role: "startspeaking" }, { role: "stopspeaking" }], 115 | }, 116 | ); 117 | 118 | // Window menu 119 | template[3].submenu = [ 120 | { role: "close" }, 121 | { role: "minimize" }, 122 | { type: "separator" }, 123 | { role: "front" }, 124 | ]; 125 | } 126 | 127 | const menu = Menu.buildFromTemplate(template); 128 | Menu.setApplicationMenu(menu); 129 | }; 130 | 131 | module.exports = buildMenu; 132 | -------------------------------------------------------------------------------- /main/notification.js: -------------------------------------------------------------------------------- 1 | const { Notification } = require("electron"); 2 | 3 | const notification = message => { 4 | if (Notification.isSupported()) { 5 | const notify = new Notification({ 6 | title: "Caption", 7 | body: message, 8 | }); 9 | notify.show(); 10 | } 11 | }; 12 | 13 | module.exports = notification; 14 | -------------------------------------------------------------------------------- /main/settings.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require("electron"); 2 | 3 | const initSettings = () => { 4 | const { store } = global; 5 | 6 | if (!store.has("language")) { 7 | store.set("language", "eng"); 8 | } 9 | 10 | ipcMain.on("getStore", (event, setting) => { 11 | if (setting === "language") { 12 | const { mainWindow } = global.windows; 13 | const language = store.get("language"); 14 | mainWindow.webContents.send("language", language); 15 | } 16 | }); 17 | 18 | ipcMain.on("setStore", (event, key, value) => { 19 | store.set(key, value); 20 | }); 21 | }; 22 | 23 | module.exports = initSettings; 24 | -------------------------------------------------------------------------------- /main/sources.js: -------------------------------------------------------------------------------- 1 | const { multiDownload } = require("./download"); 2 | const Caption = require("caption-core"); 3 | 4 | const textSearch = async (...args) => { 5 | const { mainWindow } = global.windows; 6 | 7 | Caption.searchByQuery(...args) 8 | .on("fastest", results => { 9 | const subtitles = { 10 | results, 11 | isFinished: false, 12 | }; 13 | 14 | mainWindow.webContents.send("results", subtitles); 15 | }) 16 | .on("completed", results => { 17 | const subtitles = { 18 | results, 19 | isFinished: true, 20 | }; 21 | 22 | mainWindow.webContents.send("results", subtitles); 23 | }); 24 | }; 25 | 26 | const markFilesNotFound = files => { 27 | const { mainWindow } = global.windows; 28 | 29 | files.forEach(file => { 30 | mainWindow.webContents.send("updateFileSearchStatus", { 31 | filePath: file.path, 32 | status: "not_found", 33 | }); 34 | }); 35 | }; 36 | 37 | const fileSearch = async (files, ...args) => { 38 | Caption.searchByFiles(files, ...args).on("completed", results => { 39 | const foundFilePaths = results.map(({ file }) => file.path); 40 | const notFound = files.filter(({ path }) => !foundFilePaths.includes(path)); 41 | 42 | markFilesNotFound(notFound); 43 | multiDownload(results); 44 | }); 45 | }; 46 | 47 | module.exports = { textSearch, fileSearch }; 48 | -------------------------------------------------------------------------------- /main/touchbar.js: -------------------------------------------------------------------------------- 1 | const { TouchBar, shell } = require("electron"); 2 | 3 | const { TouchBarButton, TouchBarSpacer } = TouchBar; 4 | const { showAboutWindow } = require("./windows/about"); 5 | 6 | const aboutCaptionButton = new TouchBarButton({ 7 | label: "🎬 About Caption", 8 | click: () => { 9 | showAboutWindow(); 10 | }, 11 | }); 12 | 13 | const donateButton = new TouchBarButton({ 14 | label: "💰 Donate", 15 | click: () => { 16 | const { mainWindow } = global.windows; 17 | shell.openExternal("https://www.paypal.me/gielcobben"); 18 | mainWindow.webContents.send("logDonated"); 19 | }, 20 | }); 21 | 22 | const touchBar = new TouchBar([ 23 | new TouchBarSpacer({ size: "flexible" }), 24 | aboutCaptionButton, 25 | new TouchBarSpacer({ size: "large" }), 26 | donateButton, 27 | new TouchBarSpacer({ size: "flexible" }), 28 | ]); 29 | 30 | module.exports = touchBar; 31 | -------------------------------------------------------------------------------- /main/updater.js: -------------------------------------------------------------------------------- 1 | const { dialog, ipcMain } = require("electron"); 2 | const isDev = require("electron-is-dev"); 3 | const { autoUpdater } = require("electron-updater"); 4 | const { showCheckWindow, closeCheckWindow } = require("./windows/check"); 5 | const { showProgressWindow } = require("./windows/progress"); 6 | 7 | // Functions 8 | const cancelUpdater = () => { 9 | const { progressWindow } = global.windows; 10 | const { cancellationToken } = global.updater; 11 | 12 | cancellationToken.cancel(); 13 | progressWindow.hide(); 14 | }; 15 | 16 | const checkForUpdates = async () => { 17 | const checking = await autoUpdater.checkForUpdates(); 18 | const { cancellationToken } = checking; 19 | 20 | global.updater = { 21 | cancellationToken, 22 | onStartup: false, 23 | }; 24 | }; 25 | 26 | // IPC Events 27 | ipcMain.on("cancelUpdate", event => { 28 | cancelUpdater(); 29 | }); 30 | 31 | ipcMain.on("installUpdate", event => { 32 | autoUpdater.quitAndInstall(); 33 | }); 34 | 35 | // UPDATER 36 | autoUpdater.allowPrerelease = isDev; 37 | autoUpdater.autoDownload = false; 38 | 39 | autoUpdater.on("checking-for-update", () => { 40 | const { onStartup } = global.updater; 41 | if (!onStartup) { 42 | showCheckWindow(); 43 | } 44 | }); 45 | 46 | autoUpdater.on("update-available", info => { 47 | const { cancellationToken } = global.updater; 48 | closeCheckWindow(); 49 | showProgressWindow(); 50 | autoUpdater.downloadUpdate(cancellationToken); 51 | }); 52 | 53 | autoUpdater.on("update-not-available", info => { 54 | const { onStartup } = global.updater; 55 | closeCheckWindow(); 56 | 57 | if (!onStartup) { 58 | const options = { 59 | type: "info", 60 | message: "Caption is up to date", 61 | detail: "It looks like you're already rocking the latest version!", 62 | }; 63 | 64 | dialog.showMessageBox(null, options); 65 | } 66 | }); 67 | 68 | autoUpdater.on("error", (event, error) => { 69 | console.log(error); 70 | closeCheckWindow(); 71 | }); 72 | 73 | autoUpdater.on("download-progress", progressObj => { 74 | const { progressWindow } = global.windows; 75 | progressWindow.webContents.send("progress", progressObj); 76 | }); 77 | 78 | autoUpdater.on("update-downloaded", info => { 79 | console.log(`Update downloaded; will install in 5 seconds.`, info); 80 | }); 81 | 82 | module.exports = { checkForUpdates }; 83 | -------------------------------------------------------------------------------- /main/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const movieExtension = require("./data/extensions"); 4 | 5 | const transform = filePaths => 6 | filePaths.map(file => { 7 | const extension = file.substr(file.lastIndexOf(".") + 1); 8 | const { size } = fs.statSync(file); 9 | const name = file.replace(/^.*[\\\/]/, ""); 10 | 11 | return { 12 | extension, 13 | size, 14 | name, 15 | path: file, 16 | status: "loading", 17 | }; 18 | }); 19 | 20 | const checkExtension = file => { 21 | const extension = file.substr(file.lastIndexOf(".") + 1); 22 | return movieExtension.indexOf(extension) > 0; 23 | }; 24 | 25 | const readDir = dir => 26 | fs 27 | .readdirSync(dir) 28 | .filter(file => { 29 | const isDirectory = fs.statSync(path.join(dir, file)).isDirectory(); 30 | 31 | if (isDirectory) { 32 | return true; 33 | } 34 | 35 | return checkExtension(file); 36 | }) 37 | .reduce((files, file) => { 38 | const isDirectory = fs.statSync(path.join(dir, file)).isDirectory(); 39 | 40 | if (isDirectory) { 41 | return files.concat(readDir(path.join(dir, file))); 42 | } 43 | 44 | return files.concat(path.join(dir, file)); 45 | }, []); 46 | 47 | const processFiles = droppedItems => { 48 | const { mainWindow } = global.windows; 49 | const filePaths = []; 50 | 51 | droppedItems.map(item => { 52 | if (fs.statSync(item).isDirectory()) { 53 | filePaths.push(...readDir(item)); 54 | } else if (checkExtension(item)) { 55 | filePaths.push(item); 56 | } 57 | 58 | return false; 59 | }); 60 | 61 | const transformedObject = transform(filePaths); 62 | mainWindow.webContents.send("processedFiles", transformedObject); 63 | }; 64 | 65 | module.exports = { processFiles }; 66 | -------------------------------------------------------------------------------- /main/windows/about.js: -------------------------------------------------------------------------------- 1 | const { format } = require("url"); 2 | const { BrowserWindow } = require("electron"); 3 | const isDev = require("electron-is-dev"); 4 | const { resolve } = require("app-root-path"); 5 | 6 | const createAboutWindow = () => { 7 | const aboutWindow = new BrowserWindow({ 8 | width: 260, 9 | height: 340, 10 | resizable: false, 11 | minimizable: false, 12 | maximizable: false, 13 | fullscreenable: false, 14 | vibrancy: "sidebar", 15 | title: "About", 16 | titleBarStyle: "hidden-inset", 17 | show: false, 18 | center: true, 19 | autoHideMenuBar: true, 20 | acceptFirstMouse: true, 21 | webPreferences: { 22 | backgroundThrottling: false, 23 | webSecurity: false, 24 | }, 25 | }); 26 | 27 | const devPath = "http://localhost:8000/about"; 28 | const prodPath = format({ 29 | pathname: resolve("renderer/out/about/index.html"), 30 | protocol: "file:", 31 | slashes: true, 32 | }); 33 | const url = isDev ? devPath : prodPath; 34 | aboutWindow.loadURL(url); 35 | 36 | return aboutWindow; 37 | }; 38 | 39 | const showAboutWindow = () => { 40 | const { aboutWindow, mainWindow } = global.windows; 41 | aboutWindow.show(); 42 | aboutWindow.focus(); 43 | mainWindow.webContents.send("logAbout"); 44 | }; 45 | 46 | const closeAboutWindow = (event, willQuitApp) => { 47 | const { aboutWindow } = global.windows; 48 | if (willQuitApp) { 49 | global.windows.aboutWindow = null; 50 | return; 51 | } 52 | 53 | event.preventDefault(); 54 | aboutWindow.hide(); 55 | }; 56 | 57 | module.exports = { createAboutWindow, showAboutWindow, closeAboutWindow }; 58 | -------------------------------------------------------------------------------- /main/windows/check.js: -------------------------------------------------------------------------------- 1 | const { format } = require("url"); 2 | const { BrowserWindow } = require("electron"); 3 | const isDev = require("electron-is-dev"); 4 | const { resolve } = require("app-root-path"); 5 | 6 | const createCheckWindow = () => { 7 | const checkWindow = new BrowserWindow({ 8 | width: 400, 9 | height: 130, 10 | title: "Looking for Updates...", 11 | center: true, 12 | show: false, 13 | resizable: false, 14 | minimizable: false, 15 | maximizable: false, 16 | closable: false, 17 | fullscreenable: false, 18 | backgroundColor: "#ECECEC", 19 | webPreferences: { 20 | backgroundThrottling: false, 21 | webSecurity: false, 22 | }, 23 | }); 24 | 25 | const devPath = "http://localhost:8000/check"; 26 | 27 | const prodPath = format({ 28 | pathname: resolve("renderer/out/check/index.html"), 29 | protocol: "file:", 30 | slashes: true, 31 | }); 32 | 33 | const url = isDev ? devPath : prodPath; 34 | checkWindow.loadURL(url); 35 | 36 | return checkWindow; 37 | }; 38 | 39 | const showCheckWindow = () => { 40 | const { checkWindow } = global.windows; 41 | checkWindow.show(); 42 | checkWindow.focus(); 43 | }; 44 | 45 | const closeCheckWindow = () => { 46 | const { checkWindow } = global.windows; 47 | checkWindow.hide(); 48 | }; 49 | 50 | module.exports = { 51 | createCheckWindow, 52 | showCheckWindow, 53 | closeCheckWindow, 54 | }; 55 | -------------------------------------------------------------------------------- /main/windows/main.js: -------------------------------------------------------------------------------- 1 | const { format } = require("url"); 2 | const { BrowserWindow } = require("electron"); 3 | const isDev = require("electron-is-dev"); 4 | const { resolve } = require("app-root-path"); 5 | const windowStateKeeper = require("electron-window-state"); 6 | const touchBar = require("./../touchbar"); 7 | 8 | const createMainWindow = () => { 9 | const windowState = windowStateKeeper({ 10 | defaultWidth: 360, 11 | defaultHeight: 440, 12 | }); 13 | 14 | const mainWindow = new BrowserWindow({ 15 | width: windowState.width, 16 | height: windowState.height, 17 | x: windowState.x, 18 | y: windowState.y, 19 | title: "Caption", 20 | minWidth: 300, 21 | minHeight: 300, 22 | vibrancy: "sidebar", 23 | titleBarStyle: "hidden-inset", 24 | show: false, 25 | center: true, 26 | autoHideMenuBar: true, 27 | acceptFirstMouse: true, 28 | opacity: 1, 29 | webPreferences: { 30 | backgroundThrottling: false, 31 | webSecurity: false, 32 | }, 33 | }); 34 | 35 | windowState.manage(mainWindow); 36 | 37 | const devPath = "http://localhost:8000/start"; 38 | const prodPath = format({ 39 | pathname: resolve("renderer/out/start/index.html"), 40 | protocol: "file:", 41 | slashes: true, 42 | }); 43 | const url = isDev ? devPath : prodPath; 44 | mainWindow.loadURL(url); 45 | 46 | mainWindow.webContents.on("did-finish-load", () => { 47 | mainWindow.show(); 48 | mainWindow.focus(); 49 | }); 50 | 51 | // Add Touchbar support for MacOS 52 | if (process.platform === "darwin") { 53 | mainWindow.setTouchBar(touchBar); 54 | } 55 | 56 | return mainWindow; 57 | }; 58 | 59 | module.exports = { createMainWindow }; 60 | -------------------------------------------------------------------------------- /main/windows/progress.js: -------------------------------------------------------------------------------- 1 | const { format } = require("url"); 2 | const { BrowserWindow } = require("electron"); 3 | const isDev = require("electron-is-dev"); 4 | const { resolve } = require("app-root-path"); 5 | 6 | const createProgressWindow = () => { 7 | const progressWindow = new BrowserWindow({ 8 | width: 400, 9 | height: 130, 10 | title: "Updating Caption", 11 | center: true, 12 | show: false, 13 | resizable: false, 14 | minimizable: false, 15 | maximizable: false, 16 | closable: false, 17 | fullscreenable: false, 18 | backgroundColor: "#ECECEC", 19 | webPreferences: { 20 | backgroundThrottling: false, 21 | webSecurity: false, 22 | }, 23 | }); 24 | 25 | const devPath = "http://localhost:8000/progress"; 26 | 27 | const prodPath = format({ 28 | pathname: resolve("renderer/out/progress/index.html"), 29 | protocol: "file:", 30 | slashes: true, 31 | }); 32 | 33 | const url = isDev ? devPath : prodPath; 34 | progressWindow.loadURL(url); 35 | 36 | return progressWindow; 37 | }; 38 | 39 | const showProgressWindow = () => { 40 | const { progressWindow } = global.windows; 41 | progressWindow.show(); 42 | progressWindow.focus(); 43 | }; 44 | 45 | const closeProgressWindow = (event, willQuitApp) => { 46 | const { progressWindow } = global.windows; 47 | 48 | if (willQuitApp) { 49 | global.windows.progressWindow = null; 50 | return; 51 | } 52 | 53 | event.preventDefault(); 54 | progressWindow.hide(); 55 | }; 56 | 57 | module.exports = { 58 | createProgressWindow, 59 | showProgressWindow, 60 | closeProgressWindow, 61 | }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caption", 3 | "description": "Find the right subtitles. Easy.", 4 | "author": { 5 | "name": "Giel Cobben", 6 | "email": "g.cobben@gmail.com", 7 | "url": "https://gielcobben.com" 8 | }, 9 | "contributors": [ 10 | { 11 | "name": "Vernon de Goede", 12 | "email": "info@vernondegoede.com", 13 | "url": "https://github.com/vernondegoede" 14 | }, 15 | { 16 | "name": "Giel Cobben", 17 | "email": "g.cobben@gmail.com", 18 | "url": "https://github.com/gielcobben" 19 | } 20 | ], 21 | "productName": "Caption", 22 | "version": "2.0.1", 23 | "main": "main/index.js", 24 | "license": "MIT", 25 | "repository": "gielcobben/Caption", 26 | "scripts": { 27 | "start": "electron --inspect=5858 .", 28 | "build": "npm run test && next build renderer && next export renderer", 29 | "dist:mac": "export CSC_IDENTITY_AUTO_DISCOVERY=\"true\"; node -r dotenv/config node_modules/.bin/build --mac -p always", 30 | "dist:windows": "export CSC_NAME=\"Defringe\"; export CSC_IDENTITY_AUTO_DISCOVERY=\"false\"; export CSC_LINK=\"~/Defringe.p12\"; node -r dotenv/config node_modules/.bin/build --windows -p always", 31 | "dist:linux": "node -r dotenv/config node_modules/.bin/build --linux -p always", 32 | "dist": "npm run build && npm run dist:mac && npm run dist:windows && npm run dist:linux", 33 | "test": "jest", 34 | "test:watch": "jest --watch", 35 | "postinstall": "opencollective postinstall" 36 | }, 37 | "devDependencies": { 38 | "babel-eslint": "^8.0.1", 39 | "babel-preset-stage-0": "^6.24.1", 40 | "electron": "^1.8.2", 41 | "electron-builder": "^19.37.2", 42 | "eslint": "^4.10.0", 43 | "eslint-config-airbnb": "^16.1.0", 44 | "eslint-loader": "^1.9.0", 45 | "eslint-plugin-import": "^2.8.0", 46 | "eslint-plugin-jsx-a11y": "^6.0.2", 47 | "eslint-plugin-react": "^7.4.0", 48 | "jest": "^20.0.4", 49 | "next": "5.0.0", 50 | "react": "16.0.0", 51 | "react-dom": "16.0.0", 52 | "react-test-renderer": "^16.0.0" 53 | }, 54 | "dependencies": { 55 | "addic7ed-api": "^1.3.2", 56 | "app-root-path": "2.0.1", 57 | "bluebird": "^3.5.1", 58 | "caption-core": "^2.1.1", 59 | "electron-is-dev": "0.3.0", 60 | "electron-lets-move": "0.0.5", 61 | "electron-next": "3.1.1", 62 | "electron-store": "^1.3.0", 63 | "electron-updater": "^2.16.1", 64 | "electron-window-state": "^4.1.1", 65 | "lodash": "^4.17.4", 66 | "next-redux-wrapper": "^1.3.4", 67 | "opencollective": "^1.0.3", 68 | "opensubtitles-api": "latest", 69 | "prop-types": "^15.6.0", 70 | "react-ga": "^2.3.5", 71 | "react-redux": "^5.0.6", 72 | "redux": "^3.7.2", 73 | "redux-devtools-extension": "^2.13.2", 74 | "redux-logger": "^3.0.6", 75 | "redux-thunk": "^2.2.0" 76 | }, 77 | "build": { 78 | "publish": [ 79 | { 80 | "provider": "github", 81 | "owner": "gielcobben", 82 | "repo": "Caption" 83 | } 84 | ], 85 | "files": [ 86 | "**/*", 87 | "!.env", 88 | "!renderer", 89 | "renderer/out" 90 | ], 91 | "mac": { 92 | "target": [ 93 | "dmg", 94 | "zip" 95 | ], 96 | "icon": "./renderer/static/icon.icns" 97 | }, 98 | "linux": { 99 | "target": [ 100 | "deb" 101 | ], 102 | "icon": "./renderer/static/icon.iconset/" 103 | }, 104 | "win": { 105 | "target": [ 106 | "nsis" 107 | ], 108 | "icon": "./renderer/static/icon.ico", 109 | "publisherName": "Defringe" 110 | } 111 | }, 112 | "collective": { 113 | "type": "opencollective", 114 | "url": "https://opencollective.com/caption", 115 | "logo": "https://opencollective.com/opencollective/logo.txt" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /renderer/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from "./ui"; 2 | export * from "./search"; 3 | -------------------------------------------------------------------------------- /renderer/actions/search.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { ipcRenderer } from "electron"; 3 | import { sortBy, first } from "lodash"; 4 | import { logQuery } from "./../utils/tracking"; 5 | import * as types from "./../types"; 6 | 7 | export const hideSearchPlaceholder = () => dispatch => { 8 | dispatch({ 9 | type: types.HIDE_SEARCH_PLACEHOLDER, 10 | }); 11 | }; 12 | 13 | export const showSearchPlaceholder = () => dispatch => { 14 | dispatch({ 15 | type: types.SHOW_SEARCH_PLACEHOLDER, 16 | }); 17 | }; 18 | 19 | export const resetSearch = () => dispatch => { 20 | dispatch({ 21 | type: types.RESET_SEARCH, 22 | }); 23 | 24 | dispatch(showSearchPlaceholder()); 25 | }; 26 | 27 | export const updateSearchQuery = query => dispatch => { 28 | dispatch({ 29 | type: types.UPDATE_SEARCH_QUERY, 30 | payload: { 31 | query, 32 | }, 33 | }); 34 | }; 35 | 36 | export const showSearchSpinner = () => dispatch => { 37 | dispatch({ 38 | type: types.SHOW_SEARCH_SPINNER, 39 | }); 40 | }; 41 | 42 | export const downloadComplete = () => dispatch => { 43 | dispatch({ 44 | type: types.DOWNLOAD_COMPLETE, 45 | }); 46 | }; 47 | 48 | export const logSearchQuery = query => dispatch => { 49 | dispatch({ 50 | type: types.LOG_SEARCH_QUERY, 51 | }); 52 | 53 | logQuery(query); 54 | }; 55 | 56 | export const searchByQuery = () => (dispatch, getState) => { 57 | const state = getState(); 58 | const { language } = state.ui; 59 | const { searchQuery } = state.search; 60 | 61 | dispatch({ 62 | type: types.SEARCH_BY_QUERY, 63 | }); 64 | 65 | dispatch(logSearchQuery(searchQuery)); 66 | 67 | ipcRenderer.send("textSearch", searchQuery, language); 68 | }; 69 | 70 | export const updateDroppedFilePath = (realPath, cleanPath) => dispatch => { 71 | dispatch({ 72 | type: types.SET_DROPPED_FILE_PATH, 73 | payload: { 74 | realPath, 75 | cleanPath, 76 | }, 77 | }); 78 | }; 79 | 80 | export const searchByFiles = () => (dispatch, getState) => { 81 | const state = getState(); 82 | const { language } = state.ui; 83 | const { files } = state.search; 84 | 85 | dispatch({ 86 | type: types.SEARCH_BY_FILES, 87 | }); 88 | 89 | if (files.length > 0) { 90 | const folders = files.map(file => path.dirname(file.path)); 91 | const highestFolder = first(sortBy(folders, "length")); 92 | const cleanPath = highestFolder; 93 | const realPath = path.basename(cleanPath); 94 | 95 | dispatch(updateDroppedFilePath(cleanPath, realPath)); 96 | } 97 | 98 | ipcRenderer.send("fileSearch", files, language); 99 | }; 100 | 101 | export const increaseSearchAttempts = () => (dispatch, getState) => { 102 | const state = getState(); 103 | const previousSearchAttempts = state.search.searchAttempts; 104 | 105 | dispatch({ 106 | type: types.INCREASE_SEARCH_ATTEMPTS, 107 | payload: { 108 | attempts: previousSearchAttempts + 1, 109 | }, 110 | }); 111 | }; 112 | 113 | export const startSearch = () => (dispatch, getState) => { 114 | const state = getState(); 115 | const { searchQuery, files } = state.search; 116 | 117 | dispatch(showSearchSpinner()); 118 | dispatch(increaseSearchAttempts()); 119 | 120 | if (searchQuery !== "") { 121 | return dispatch(searchByQuery()); 122 | } 123 | 124 | if (files.length > 0) { 125 | return dispatch(searchByFiles()); 126 | } 127 | 128 | return dispatch(resetSearch()); 129 | }; 130 | 131 | export const dropFiles = files => dispatch => { 132 | dispatch({ 133 | type: types.DROP_FILES, 134 | payload: { 135 | files, 136 | }, 137 | }); 138 | 139 | dispatch(startSearch()); 140 | }; 141 | 142 | export const updateFileSearchStatus = (filePath, status) => dispatch => { 143 | dispatch({ 144 | type: types.UPDATE_FILE_SEARCH_STATUS, 145 | payload: { 146 | filePath, 147 | status, 148 | }, 149 | }); 150 | }; 151 | 152 | export const updateSearchResults = ({ 153 | results, 154 | searchCompleted, 155 | }) => dispatch => { 156 | dispatch({ 157 | type: types.UPDATE_SEARCH_RESULTS, 158 | payload: { 159 | searchCompleted, 160 | results, 161 | }, 162 | }); 163 | }; 164 | -------------------------------------------------------------------------------- /renderer/actions/ui.js: -------------------------------------------------------------------------------- 1 | import * as types from "./../types"; 2 | import { ipcRenderer } from "electron"; 3 | 4 | import { logDonated, logAbout } from "./../utils/tracking"; 5 | import { startSearch } from "./index"; 6 | 7 | export const setLanguage = language => (dispatch, getState) => { 8 | const { search } = getState(); 9 | 10 | dispatch({ 11 | type: types.SET_LANGUAGE, 12 | payload: { 13 | language, 14 | }, 15 | }); 16 | 17 | // Store current language in settings 18 | ipcRenderer.send("setStore", "language", language); 19 | 20 | // If there are any results and user switches language, search again using new language 21 | if (search.results.length > 0 || search.files.length > 0) { 22 | dispatch(startSearch()); 23 | } 24 | }; 25 | 26 | export const showNotification = message => dispatch => { 27 | dispatch({ 28 | type: types.SHOW_NOTIFICATION, 29 | }); 30 | 31 | ipcRenderer.send("notification", message); 32 | }; 33 | 34 | export const logDonatedButtonClicked = () => dispatch => { 35 | dispatch({ 36 | type: types.LOG_DONATED, 37 | }); 38 | 39 | logDonated(); 40 | }; 41 | 42 | export const logAboutWindowOpend = () => dispatch => { 43 | dispatch({ 44 | type: types.LOG_ABOUT, 45 | }); 46 | 47 | logAbout(); 48 | }; 49 | -------------------------------------------------------------------------------- /renderer/components/Content.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | import Drop from "../components/Drop"; 4 | import ListEmpty from "../components/ListEmpty"; 5 | import List from "../components/List"; 6 | 7 | const Content = ({ 8 | searchQuery = "", 9 | files = [], 10 | results = [], 11 | loading, 12 | onDrop, 13 | isWindows, 14 | }) => ( 15 |
16 | {searchQuery !== "" && results.length === 0 && } 17 | {searchQuery === "" && files.length === 0 && } 18 | {files.length > 0 && } 19 | {results.length > 0 && } 20 | 21 | 36 |
37 | ); 38 | 39 | Content.propTypes = { 40 | searchQuery: PropTypes.string, 41 | files: PropTypes.array, 42 | results: PropTypes.array, 43 | loading: PropTypes.bool, 44 | isWindows: PropTypes.bool, 45 | }; 46 | 47 | Content.defaultProps = { 48 | searchQuery: "", 49 | files: [], 50 | results: [], 51 | isWindows: true, 52 | loading: false, 53 | }; 54 | 55 | export default Content; 56 | -------------------------------------------------------------------------------- /renderer/components/Credits.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import { shell } from "electron"; 3 | 4 | // Components 5 | import software from "../data/software"; 6 | import SoftwareItem from "../components/SoftwareItem"; 7 | 8 | const Credits = () => ( 9 |
10 |

Special thanks to:

11 | 25 | 26 |

3rd party software

27 | 32 | 33 | 66 |
67 | ); 68 | 69 | export default Credits; 70 | -------------------------------------------------------------------------------- /renderer/components/Drop.js: -------------------------------------------------------------------------------- 1 | class Drop extends React.Component { 2 | constructor(props) { 3 | super(props); 4 | this.state = { dragging: false }; 5 | this.onDragEnter = this.onDragEnter.bind(this); 6 | this.onDragLeave = this.onDragLeave.bind(this); 7 | this.onDragOver = this.onDragOver.bind(this); 8 | } 9 | 10 | onDragEnter() { 11 | this.setState({ 12 | dragging: true, 13 | }); 14 | } 15 | 16 | onDragLeave() { 17 | if (this.state.dragging) { 18 | this.setState({ 19 | dragging: false, 20 | }); 21 | } 22 | } 23 | 24 | onDragOver() { 25 | if (!this.state.dragging) { 26 | this.setState({ 27 | dragging: true, 28 | }); 29 | } 30 | } 31 | 32 | render() { 33 | const { dragging } = this.state; 34 | 35 | return ( 36 |
42 |
43 |

Drop an episode or season.

44 |
45 | 46 | 77 |
78 | ); 79 | } 80 | } 81 | 82 | export default Drop; 83 | -------------------------------------------------------------------------------- /renderer/components/FilePath.js: -------------------------------------------------------------------------------- 1 | import { platform } from "os"; 2 | import { shell } from "electron"; 3 | import PropTypes from "prop-types"; 4 | 5 | const defaultFolderIcon = ( 6 | 12 | 13 | 17 | 21 | 22 | 23 | ); 24 | 25 | const windowsFilderIcon = ( 26 | 32 | 33 | 37 | 38 | 39 | 40 | 41 | 45 | 50 | 51 | 52 | ); 53 | 54 | class FilePath extends React.Component { 55 | constructor(props) { 56 | super(props); 57 | this.isWindows = platform() === "win32"; 58 | } 59 | 60 | render() { 61 | const { dropFilePath, dropFilePathClean, onReset } = this.props; 62 | 63 | return ( 64 |
65 | 66 | {this.isWindows ? windowsFilderIcon : defaultFolderIcon} 67 | 68 | shell.showItemInFolder(dropFilePath)} 71 | > 72 | {dropFilePathClean} 73 | 74 | 75 | {" "} 76 | 82 | 83 | 84 | 89 | 90 | 91 | 92 | 93 | 131 |
132 | ); 133 | } 134 | } 135 | 136 | FilePath.propTypes = { 137 | dropFilePath: PropTypes.string.isRequired, 138 | dropFilePathClean: PropTypes.string.isRequired, 139 | onReset: PropTypes.func.isRequired, 140 | }; 141 | 142 | export default FilePath; 143 | -------------------------------------------------------------------------------- /renderer/components/Footer.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import LanguageToggle from "./LanguageToggle"; 3 | import Info from "./Info"; 4 | 5 | const Footer = ({ 6 | results = [], 7 | language, 8 | loading, 9 | onLanguageChange, 10 | showResults, 11 | isFileSearch, 12 | totalFiles, 13 | foundFiles, 14 | }) => ( 15 | 45 | ); 46 | 47 | Footer.propTypes = { 48 | results: PropTypes.array.isRequired, 49 | language: PropTypes.string.isRequired, 50 | loading: PropTypes.bool.isRequired, 51 | onLanguageChange: PropTypes.func.isRequired, 52 | showResults: PropTypes.bool.isRequired, 53 | isFileSearch: PropTypes.bool.isRequired, 54 | totalFiles: PropTypes.number.isRequired, 55 | foundFiles: PropTypes.number.isRequired, 56 | }; 57 | 58 | export default Footer; 59 | -------------------------------------------------------------------------------- /renderer/components/FooterAbout.js: -------------------------------------------------------------------------------- 1 | import { shell } from "electron"; 2 | 3 | const FooterAbout = () => ( 4 | 28 | ); 29 | 30 | export default FooterAbout; 31 | -------------------------------------------------------------------------------- /renderer/components/Info.js: -------------------------------------------------------------------------------- 1 | import Loading from "./Loading"; 2 | 3 | const Info = ({ 4 | results = [], 5 | loading = false, 6 | isFileSearch = false, 7 | totalFiles = 0, 8 | foundFiles = 0, 9 | }) => ( 10 |
11 | {!loading && 12 | !isFileSearch && ( 13 | 14 | {results.length <= 0 ? `Nothing Found` : `${results.length} Results`} 15 | 16 | )} 17 | 18 | {!loading && 19 | isFileSearch && ( 20 | {`${foundFiles} / ${totalFiles} Subtitles found`} 21 | )} 22 | 23 | {loading && } 24 | 36 |
37 | ); 38 | 39 | export default Info; 40 | -------------------------------------------------------------------------------- /renderer/components/LanguageToggle.js: -------------------------------------------------------------------------------- 1 | import languages from "../data/languages"; 2 | 3 | const LanguageToggle = ({ language, onLanguageChange }) => ( 4 | 29 | ); 30 | 31 | export default LanguageToggle; 32 | -------------------------------------------------------------------------------- /renderer/components/Layout.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | const Layout = ({ children }) => ( 4 |
5 | 6 | Caption 7 | 8 | 9 | 10 | 11 | {children} 12 | 13 | 163 |
164 | ); 165 | 166 | export default Layout; 167 | -------------------------------------------------------------------------------- /renderer/components/List.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import { Menu, MenuItem, remote, shell, ipcRenderer } from "electron"; 3 | 4 | // Components 5 | import ListItem from "./ListItem"; 6 | 7 | // Global variables 8 | const ARROW_DOWN_KEY = 40; 9 | const ARROW_UP_KEY = 38; 10 | const ENTER_KEY = 13; 11 | 12 | class List extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | 16 | this.state = { 17 | selected: null, 18 | }; 19 | 20 | this.onKeyDown = this.onKeyDown.bind(this); 21 | this.onArrowUp = this.onArrowUp.bind(this); 22 | this.onArrowDown = this.onArrowDown.bind(this); 23 | this.onDoubleClick = this.onDoubleClick.bind(this); 24 | } 25 | 26 | componentDidMount() { 27 | document.addEventListener("keydown", this.onKeyDown); 28 | } 29 | 30 | componentWillUnmount() { 31 | document.removeEventListener("keydown", this.onKeyDown); 32 | } 33 | 34 | onKeyDown(event) { 35 | const { onDoubleClick } = this.props; 36 | 37 | if (event.keyCode === ARROW_DOWN_KEY) { 38 | this.onArrowDown(); 39 | } 40 | 41 | if (event.keyCode === ARROW_UP_KEY) { 42 | this.onArrowUp(); 43 | } 44 | 45 | if (event.keyCode === ENTER_KEY) { 46 | this.onDoubleClick(); 47 | } 48 | } 49 | 50 | onArrowDown() { 51 | const { results } = this.props; 52 | const currentSelected = this.state.selected; 53 | const { length } = results; 54 | const selected = currentSelected !== null ? currentSelected + 1 : 0; 55 | 56 | if (length !== selected) { 57 | this.setState({ selected }); 58 | } 59 | } 60 | 61 | onArrowUp() { 62 | const currentSelected = this.state.selected; 63 | const selected = currentSelected - 1; 64 | 65 | if (currentSelected !== 0) { 66 | this.setState({ selected }); 67 | } 68 | } 69 | 70 | onDoubleClick() { 71 | const { results } = this.props; 72 | const { selected } = this.state; 73 | const item = results[selected]; 74 | 75 | // If size is specified, the file is dropped. 76 | if (item.size) { 77 | shell.openItem(item.path); 78 | } else { 79 | ipcRenderer.send("downloadSubtitle", item); 80 | } 81 | } 82 | 83 | onContextMenu(clicked) { 84 | let template; 85 | const { Menu } = remote; 86 | const { results } = this.props; 87 | const item = results[clicked]; 88 | 89 | // If size is specified, the file is dropped. 90 | if (item.size) { 91 | template = Menu.buildFromTemplate([ 92 | { 93 | label: "Open", 94 | click: () => { 95 | shell.openItem(item.path); 96 | }, 97 | }, 98 | { 99 | label: "Reveal in Folder...", 100 | click: () => { 101 | shell.showItemInFolder(item.path); 102 | }, 103 | }, 104 | ]); 105 | } else { 106 | template = Menu.buildFromTemplate([ 107 | { 108 | label: "Download", 109 | click: () => { 110 | ipcRenderer.send("downloadSubtitle", item); 111 | }, 112 | }, 113 | ]); 114 | } 115 | 116 | // Wait till state is set. 117 | this.setState({ selected: clicked }, () => { 118 | setTimeout(() => { 119 | template.popup(remote.getCurrentWindow()); 120 | }, 10); 121 | }); 122 | } 123 | 124 | render() { 125 | const { results } = this.props; 126 | const { selected } = this.state; 127 | 128 | return ( 129 | 150 | ); 151 | } 152 | } 153 | 154 | export default List; 155 | -------------------------------------------------------------------------------- /renderer/components/ListEmpty.js: -------------------------------------------------------------------------------- 1 | class ListEmpty extends React.Component { 2 | constructor(props) { 3 | super(props); 4 | this.state = { amount: 10 }; 5 | this.setAmount = this.setAmount.bind(this); 6 | } 7 | 8 | setAmount() { 9 | if (typeof window !== "undefined") { 10 | const amount = Math.ceil((window.innerHeight - 125) / 30); 11 | this.setState({ amount }); 12 | } 13 | } 14 | 15 | componentWillMount() { 16 | if (typeof window !== "undefined") { 17 | this.setAmount(); 18 | window.addEventListener("resize", this.setAmount); 19 | } 20 | } 21 | 22 | componentWillUnMount() { 23 | if (typeof window !== "undefined") { 24 | window.removeEventListener("resize", this.setAmount); 25 | } 26 | } 27 | 28 | render() { 29 | const list = [...Array(this.state.amount).keys()]; 30 | 31 | return ( 32 | 57 | ); 58 | } 59 | } 60 | 61 | export default ListEmpty; 62 | -------------------------------------------------------------------------------- /renderer/components/ListItem.js: -------------------------------------------------------------------------------- 1 | import { fileSizeReadable } from "../utils"; 2 | 3 | const ListItem = ({ 4 | item, 5 | selected, 6 | onClick, 7 | onDoubleClick, 8 | onContextMenu, 9 | }) => ( 10 |
  • 16 | {item.name} 17 | 18 | {item.size && ( 19 |
    20 | {fileSizeReadable(item.size)} 21 | 22 | {item.extension} 23 |
    24 | )} 25 | 26 | {item.status === "done" && ( 27 | 28 | {" "} 29 | 30 | 34 | 35 | 36 | )} 37 | 38 | {item.status === "not_found" && ( 39 | 40 | {" "} 41 | 42 | 46 | 47 | 48 | )} 49 | 50 | 120 |
  • 121 | ); 122 | 123 | export default ListItem; 124 | -------------------------------------------------------------------------------- /renderer/components/Loading.js: -------------------------------------------------------------------------------- 1 | const Loading = () => 2 | 3 | 4 | 5 | 12 | ; 13 | 14 | export default Loading; 15 | -------------------------------------------------------------------------------- /renderer/components/Logo.js: -------------------------------------------------------------------------------- 1 | const Logo = ({ size = 80, margin = "0 auto" }) => ( 2 |
    3 | 4 | 5 | 13 |
    14 | ); 15 | 16 | export default Logo; 17 | -------------------------------------------------------------------------------- /renderer/components/Meta.js: -------------------------------------------------------------------------------- 1 | import { remote } from "electron"; 2 | 3 | import Logo from "./Logo"; 4 | 5 | const Meta = ({ appVersion }) => 6 |
    7 | 8 |
    9 |

    Caption

    10 | Version: {appVersion} 11 |
    12 | 13 | 42 |
    ; 43 | 44 | export default Meta; 45 | -------------------------------------------------------------------------------- /renderer/components/Search.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import { shell } from "electron"; 3 | import FilePath from "./FilePath"; 4 | import SearchField from "./SearchField"; 5 | 6 | class Search extends React.Component { 7 | render() { 8 | const { 9 | value, 10 | placeholder, 11 | dropFilePath, 12 | dropFilePathClean, 13 | onReset, 14 | onSubmit, 15 | onChange, 16 | onFocus, 17 | onBlur, 18 | } = this.props; 19 | 20 | return ( 21 |
    22 | {dropFilePath && ( 23 | 28 | )} 29 | 30 | {!dropFilePath && ( 31 | { 39 | this.searchField = searchField; 40 | }} 41 | /> 42 | )} 43 | 44 | 55 |
    56 | ); 57 | } 58 | } 59 | 60 | Search.propTypes = { 61 | value: PropTypes.string.isRequired, 62 | placeholder: PropTypes.string.isRequired, 63 | dropFilePath: PropTypes.string.isRequired, 64 | dropFilePathClean: PropTypes.string, 65 | onReset: PropTypes.func.isRequired, 66 | onSubmit: PropTypes.func.isRequired, 67 | onChange: PropTypes.func.isRequired, 68 | onFocus: PropTypes.func.isRequired, 69 | onBlur: PropTypes.func.isRequired, 70 | }; 71 | 72 | Search.defaultProps = { 73 | dropFilePathClean: undefined, 74 | }; 75 | 76 | export default Search; 77 | -------------------------------------------------------------------------------- /renderer/components/SearchField.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | class SearchField extends React.Component { 4 | render() { 5 | const { 6 | value, 7 | placeholder, 8 | onSubmit, 9 | onChange, 10 | onFocus, 11 | onBlur, 12 | } = this.props; 13 | 14 | return ( 15 |
    16 | { 24 | this.textInput = input; 25 | }} 26 | /> 27 | 28 | 51 |
    52 | ); 53 | } 54 | } 55 | 56 | SearchField.propTypes = { 57 | value: PropTypes.string.isRequired, 58 | placeholder: PropTypes.string.isRequired, 59 | onSubmit: PropTypes.func.isRequired, 60 | onChange: PropTypes.func.isRequired, 61 | onFocus: PropTypes.func.isRequired, 62 | onBlur: PropTypes.func.isRequired, 63 | }; 64 | 65 | export default SearchField; 66 | -------------------------------------------------------------------------------- /renderer/components/SoftwareItem.js: -------------------------------------------------------------------------------- 1 | const arrowRight = ( 2 | 9 | 10 | 11 | ); 12 | 13 | const arrowDown = ( 14 | 21 | 22 | 23 | ); 24 | 25 | class SoftwareItem extends React.Component { 26 | constructor(props) { 27 | super(props); 28 | 29 | this.state = { 30 | open: false 31 | }; 32 | } 33 | 34 | render() { 35 | const { pkg } = this.props; 36 | const { open } = this.state; 37 | 38 | return ( 39 |
  • 40 | {open && arrowDown} 41 | {!open && arrowRight} 42 | { 44 | this.setState({ open: !open }); 45 | }} 46 | > 47 | {pkg.name} 48 | 49 | 50 | {open &&

    {pkg.description}

    } 51 | 52 | 74 |
  • 75 | ); 76 | } 77 | } 78 | 79 | export default SoftwareItem; 80 | -------------------------------------------------------------------------------- /renderer/components/Title.js: -------------------------------------------------------------------------------- 1 | const Title = ({ title }) => 2 |

    3 | {title} 4 | 5 | 14 |

    ; 15 | 16 | export default Title; 17 | -------------------------------------------------------------------------------- /renderer/components/TitleBar.js: -------------------------------------------------------------------------------- 1 | import Title from "./Title"; 2 | 3 | const TitleBar = ({ title }) => ( 4 |
    5 | 6 | 7 | <style jsx>{` 8 | header { 9 | background: #fff; 10 | height: 38px; 11 | -webkit-app-region: drag; 12 | border-radius: 6px 6px 0 0; 13 | } 14 | `}</style> 15 | </header> 16 | ); 17 | 18 | export default TitleBar; 19 | -------------------------------------------------------------------------------- /renderer/components/__tests__/Content.test.js: -------------------------------------------------------------------------------- 1 | import ReactTestRenderer from "react-test-renderer"; 2 | import Content from "./../Content"; 3 | 4 | describe("<Content />", () => { 5 | it("should show <ListEmpty /> when there is a query but no results", () => { 6 | const tree = ReactTestRenderer.create(<Content searchQuery="test" results={[]} />); 7 | expect(tree.toJSON()).toMatchSnapshot(); 8 | }); 9 | 10 | it("should show <Drop /> when there is no searchQuery and no files dropped", () => { 11 | const tree = ReactTestRenderer.create(<Content searchQuery="" files={[]} />); 12 | expect(tree.toJSON()).toMatchSnapshot(); 13 | }); 14 | 15 | it("should render <List /> when files dropped are dropped", () => { 16 | const files = [{}, {}]; 17 | const tree = ReactTestRenderer.create(<Content files={files} />); 18 | expect(tree.toJSON()).toMatchSnapshot(); 19 | }); 20 | 21 | it("should render <List /> when there are results", () => { 22 | const results = [{}, {}]; 23 | const tree = ReactTestRenderer.create(<Content results={results} />); 24 | expect(tree.toJSON()).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /renderer/components/__tests__/Info.test.js: -------------------------------------------------------------------------------- 1 | import ReactTestRenderer from "react-test-renderer"; 2 | import Info from "./../Info"; 3 | 4 | describe("<Info />", () => { 5 | it("should show an empty state when no results are found", () => { 6 | const tree = ReactTestRenderer.create(<Info loading={false} results={[]} />); 7 | expect(tree.toJSON()).toMatchSnapshot(); 8 | }); 9 | 10 | it("should show the total results if any are present", () => { 11 | const results = [{}, {}]; 12 | const tree = ReactTestRenderer.create(<Info loading={false} results={results} />); 13 | expect(tree.toJSON()).toMatchSnapshot(); 14 | }); 15 | 16 | it("should show a loader when there are no results yet", () => { 17 | const tree = ReactTestRenderer.create(<Info loading results={[]} />); 18 | expect(tree.toJSON()).toMatchSnapshot(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /renderer/components/__tests__/__snapshots__/Content.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Content /> should render <List /> when files dropped are dropped 1`] = ` 4 | <section 5 | className="jsx-3838599344 " 6 | onDrop={undefined} 7 | > 8 | <ul 9 | className="jsx-631033484" 10 | > 11 | <li 12 | className="jsx-572805028 " 13 | onClick={[Function]} 14 | onContextMenu={[Function]} 15 | onDoubleClick={[Function]} 16 | /> 17 | <li 18 | className="jsx-572805028 " 19 | onClick={[Function]} 20 | onContextMenu={[Function]} 21 | onDoubleClick={[Function]} 22 | /> 23 | </ul> 24 | </section> 25 | `; 26 | 27 | exports[`<Content /> should render <List /> when there are results 1`] = ` 28 | <section 29 | className="jsx-3838599344 " 30 | onDrop={undefined} 31 | > 32 | <div 33 | className="jsx-3534139585 drop" 34 | onDragEnter={[Function]} 35 | onDragLeave={[Function]} 36 | onDragOver={[Function]} 37 | > 38 | <div 39 | className="jsx-3534139585 zone " 40 | > 41 | <p 42 | className="jsx-3534139585" 43 | > 44 | Drop an episode or season. 45 | </p> 46 | </div> 47 | </div> 48 | <ul 49 | className="jsx-631033484" 50 | > 51 | <li 52 | className="jsx-572805028 " 53 | onClick={[Function]} 54 | onContextMenu={[Function]} 55 | onDoubleClick={[Function]} 56 | /> 57 | <li 58 | className="jsx-572805028 " 59 | onClick={[Function]} 60 | onContextMenu={[Function]} 61 | onDoubleClick={[Function]} 62 | /> 63 | </ul> 64 | </section> 65 | `; 66 | 67 | exports[`<Content /> should show <Drop /> when there is no searchQuery and no files dropped 1`] = ` 68 | <section 69 | className="jsx-3838599344 " 70 | onDrop={undefined} 71 | > 72 | <div 73 | className="jsx-3534139585 drop" 74 | onDragEnter={[Function]} 75 | onDragLeave={[Function]} 76 | onDragOver={[Function]} 77 | > 78 | <div 79 | className="jsx-3534139585 zone " 80 | > 81 | <p 82 | className="jsx-3534139585" 83 | > 84 | Drop an episode or season. 85 | </p> 86 | </div> 87 | </div> 88 | </section> 89 | `; 90 | 91 | exports[`<Content /> should show <ListEmpty /> when there is a query but no results 1`] = ` 92 | <section 93 | className="jsx-3838599344 " 94 | onDrop={undefined} 95 | > 96 | <ul 97 | className="jsx-565981134" 98 | > 99 | <li 100 | className="jsx-565981134" 101 | /> 102 | <li 103 | className="jsx-565981134" 104 | /> 105 | <li 106 | className="jsx-565981134" 107 | /> 108 | <li 109 | className="jsx-565981134" 110 | /> 111 | <li 112 | className="jsx-565981134" 113 | /> 114 | <li 115 | className="jsx-565981134" 116 | /> 117 | <li 118 | className="jsx-565981134" 119 | /> 120 | <li 121 | className="jsx-565981134" 122 | /> 123 | <li 124 | className="jsx-565981134" 125 | /> 126 | <li 127 | className="jsx-565981134" 128 | /> 129 | <li 130 | className="jsx-565981134" 131 | /> 132 | <li 133 | className="jsx-565981134" 134 | /> 135 | <li 136 | className="jsx-565981134" 137 | /> 138 | <li 139 | className="jsx-565981134" 140 | /> 141 | <li 142 | className="jsx-565981134" 143 | /> 144 | <li 145 | className="jsx-565981134" 146 | /> 147 | <li 148 | className="jsx-565981134" 149 | /> 150 | <li 151 | className="jsx-565981134" 152 | /> 153 | <li 154 | className="jsx-565981134" 155 | /> 156 | <li 157 | className="jsx-565981134" 158 | /> 159 | <li 160 | className="jsx-565981134" 161 | /> 162 | <li 163 | className="jsx-565981134" 164 | /> 165 | </ul> 166 | </section> 167 | `; 168 | -------------------------------------------------------------------------------- /renderer/components/__tests__/__snapshots__/Info.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`<Info /> should show a loader when there are no results yet 1`] = ` 4 | <div 5 | className="jsx-3536369025" 6 | > 7 | <span 8 | className="jsx-904518150" 9 | > 10 | <img 11 | className="jsx-904518150" 12 | src="/static/loading.gif" 13 | /> 14 | </span> 15 | </div> 16 | `; 17 | 18 | exports[`<Info /> should show an empty state when no results are found 1`] = ` 19 | <div 20 | className="jsx-3536369025" 21 | > 22 | <span 23 | className="jsx-3536369025" 24 | > 25 | Nothing Found 26 | </span> 27 | </div> 28 | `; 29 | 30 | exports[`<Info /> should show the total results if any are present 1`] = ` 31 | <div 32 | className="jsx-3536369025" 33 | > 34 | <span 35 | className="jsx-3536369025" 36 | > 37 | 2 Results 38 | </span> 39 | </div> 40 | `; 41 | -------------------------------------------------------------------------------- /renderer/containers/Content.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | import { ipcRenderer } from "electron"; 3 | 4 | import Content from "./../components/Content"; 5 | 6 | const mapStateToProps = ({ search }) => ({ 7 | searchQuery: search.searchQuery, 8 | files: search.files, 9 | results: search.results, 10 | loading: search.loading, 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onDrop: event => () => { 15 | const droppedItems = []; 16 | const rawFiles = event.dataTransfer 17 | ? event.dataTransfer.files 18 | : event.target.files; 19 | 20 | Object.keys(rawFiles).map(key => droppedItems.push(rawFiles[key].path)); 21 | 22 | ipcRenderer.send("processFiles", droppedItems); 23 | }, 24 | }; 25 | 26 | export default connect(mapStateToProps, mapDispatchToProps)(Content); 27 | -------------------------------------------------------------------------------- /renderer/containers/Footer.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | 3 | import { setLanguage } from "./../actions"; 4 | import Footer from "./../components/Footer"; 5 | 6 | const mapStateToProps = ({ ui, search }) => ({ 7 | language: ui.language, 8 | loading: !search.searchCompleted, 9 | results: search.results, 10 | showResults: search.searchAttempts > 0, 11 | isFileSearch: search.files.length > 0, 12 | totalFiles: search.files.length, 13 | foundFiles: search.files.filter(({ status }) => status === "done").length, 14 | }); 15 | const mapDispatchToProps = { 16 | onLanguageChange: setLanguage, 17 | }; 18 | 19 | export default connect(mapStateToProps, mapDispatchToProps)(Footer); 20 | -------------------------------------------------------------------------------- /renderer/containers/Search.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | 3 | import Search from "./../components/Search"; 4 | 5 | import { updateSearchQuery, resetSearch } from "./../actions"; 6 | 7 | const mapStateToProps = ({ search }) => ({ 8 | value: search.searchQuery || search.dropFilePath, 9 | placeholder: search.placeholder, 10 | dropFilePath: search.dropFilePath, 11 | dropFilePathClean: search.dropFilePathClean, 12 | }); 13 | 14 | const mapDispatchToProps = { 15 | onChange: event => dispatch => { 16 | const searchQuery = event.target.value; 17 | dispatch(updateSearchQuery(searchQuery)); 18 | }, 19 | onReset: resetSearch, 20 | }; 21 | 22 | export default connect(mapStateToProps, mapDispatchToProps, null, { 23 | withRef: true, 24 | })(Search); 25 | -------------------------------------------------------------------------------- /renderer/data/languages.js: -------------------------------------------------------------------------------- 1 | const languages = [ 2 | { code: "afr", name: "Afrikaans" }, 3 | { code: "alb", name: "Albanian" }, 4 | { code: "ara", name: "Arabic" }, 5 | { code: "arm", name: "Armenian" }, 6 | { code: "aze", name: "Azerbaijani" }, 7 | { code: "baq", name: "Basque" }, 8 | { code: "bel", name: "Belarusian" }, 9 | { code: "ben", name: "Bengali" }, 10 | { code: "bos", name: "Bosnian" }, 11 | { code: "bre", name: "Breton" }, 12 | { code: "bul", name: "Bulgarian" }, 13 | { code: "bur", name: "Burmese" }, 14 | { code: "cat", name: "Catalan" }, 15 | { code: "chi", name: "Chinese (simplified)" }, 16 | { code: "zht", name: "Chinese (traditional)" }, 17 | { code: "zhe", name: "Chinese bilingual" }, 18 | { code: "hrv", name: "Croatian" }, 19 | { code: "cze", name: "Czech" }, 20 | { code: "dan", name: "Danish" }, 21 | { code: "dut", name: "Dutch" }, 22 | { code: "eng", name: "English" }, 23 | { code: "epo", name: "Esperanto" }, 24 | { code: "est", name: "Estonian" }, 25 | { code: "eus", name: "Euskera" }, 26 | { code: "fin", name: "Finnish" }, 27 | { code: "fre", name: "French" }, 28 | { code: "glg", name: "Galician" }, 29 | { code: "geo", name: "Georgian" }, 30 | { code: "ger", name: "German" }, 31 | { code: "ell", name: "Greek" }, 32 | { code: "heb", name: "Hebrew" }, 33 | { code: "hin", name: "Hindi" }, 34 | { code: "hun", name: "Hungarian" }, 35 | { code: "ice", name: "Icelandic" }, 36 | { code: "ind", name: "Indonesian" }, 37 | { code: "ita", name: "Italian" }, 38 | { code: "jpn", name: "Japanese" }, 39 | { code: "kaz", name: "Kazakh" }, 40 | { code: "khm", name: "Khmer" }, 41 | { code: "kor", name: "Korean" }, 42 | { code: "lav", name: "Latvian" }, 43 | { code: "lit", name: "Lithuanian" }, 44 | { code: "ltz", name: "Luxembourgish" }, 45 | { code: "mac", name: "Macedonian" }, 46 | { code: "may", name: "Malay" }, 47 | { code: "mal", name: "Malayalam" }, 48 | { code: "mni", name: "Manipuri" }, 49 | { code: "mon", name: "Mongolian" }, 50 | { code: "mne", name: "Montenegrin" }, 51 | { code: "nor", name: "Norwegian" }, 52 | { code: "oci", name: "Occitan" }, 53 | { code: "per", name: "Persian" }, 54 | { code: "pol", name: "Polish" }, 55 | { code: "por", name: "Portuguese" }, 56 | { code: "pob", name: "Portuguese (BR)" }, 57 | { code: "rum", name: "Romanian" }, 58 | { code: "rus", name: "Russian" }, 59 | { code: "scc", name: "Serbian" }, 60 | { code: "sin", name: "Sinhalese" }, 61 | { code: "slo", name: "Slovak" }, 62 | { code: "slv", name: "Slovenian" }, 63 | { code: "spa", name: "Spanish" }, 64 | { code: "swa", name: "Swahili" }, 65 | { code: "swe", name: "Swedish" }, 66 | { code: "syr", name: "Syriac" }, 67 | { code: "tgl", name: "Tagalog" }, 68 | { code: "tam", name: "Tamil" }, 69 | { code: "tel", name: "Telugu" }, 70 | { code: "tha", name: "Thai" }, 71 | { code: "tur", name: "Turkish" }, 72 | { code: "ukr", name: "Ukrainian" }, 73 | { code: "urd", name: "Urdu" }, 74 | { code: "vie", name: "Vietnamese" }, 75 | ]; 76 | 77 | export default languages; 78 | -------------------------------------------------------------------------------- /renderer/data/software.js: -------------------------------------------------------------------------------- 1 | const software = [ 2 | { 3 | name: "next.js", 4 | description: `The MIT License (MIT) Copyright (c) 2016 Zeit, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, 5 | }, 6 | { 7 | name: "electron-is-dev", 8 | description: `The MIT License (MIT) Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, 9 | }, 10 | { 11 | name: "electron-next", 12 | description: `MIT License (MIT) Copyright (c) 2017 Leo Lamprecht Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, 13 | }, 14 | { 15 | name: "electron-store", 16 | description: `MIT License Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, 17 | }, 18 | { 19 | name: "electron-window-state", 20 | description: `The MIT License (MIT) Copyright (c) 2015 Jakub Szwacz Copyright (c) Marcel Wiehle <marcel@wiehle.me> (http://marcel.wiehle.me) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE`, 21 | }, 22 | ]; 23 | 24 | export default software; 25 | -------------------------------------------------------------------------------- /renderer/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack(config) { 3 | config.target = "electron-renderer"; 4 | 5 | // config.plugins = config.plugins.filter(plugin => { 6 | // return plugin.constructor.name !== "UglifyJsPlugin"; 7 | // }); 8 | 9 | return config; 10 | }, 11 | 12 | exportPathMap() { 13 | // Let Next.js know where to find the entry page 14 | // when it's exporting the static bundle for the use 15 | // in the production version of your app 16 | return { 17 | "/start": { page: "/start" }, 18 | "/about": { page: "/about" }, 19 | "/check": { page: "/check" }, 20 | "/progress": { page: "/progress" }, 21 | }; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /renderer/pages/about.js: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | import { Component } from "react"; 3 | import isDev from "electron-is-dev"; 4 | 5 | import Layout from "../components/Layout"; 6 | import TitleBar from "../components/TitleBar"; 7 | import Meta from "../components/Meta"; 8 | import Credits from "../components/Credits"; 9 | import FooterAbout from "../components/FooterAbout"; 10 | 11 | class About extends Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | version: "0.0.0", 17 | }; 18 | 19 | this.remote = electron.remote || false; 20 | } 21 | 22 | componentWillMount() { 23 | if (!this.remote) { 24 | return; 25 | } 26 | 27 | let version; 28 | 29 | if (isDev) { 30 | version = this.remote.process.env.npm_package_version; 31 | } else { 32 | version = this.remote.app.getVersion(); 33 | } 34 | 35 | this.setState({ 36 | version, 37 | }); 38 | } 39 | 40 | render() { 41 | return ( 42 | <Layout> 43 | <TitleBar title="About" /> 44 | <Meta appVersion={this.state.version} /> 45 | <Credits /> 46 | <FooterAbout /> 47 | </Layout> 48 | ); 49 | } 50 | } 51 | 52 | export default About; 53 | -------------------------------------------------------------------------------- /renderer/pages/check.js: -------------------------------------------------------------------------------- 1 | import Layout from "../components/Layout"; 2 | import Logo from "../components/Logo"; 3 | 4 | const Check = () => ( 5 | <Layout> 6 | <section> 7 | <Logo size={62} margin={0} /> 8 | <div> 9 | <h2>Checking for updates...</h2> 10 | <progress value="100" max="100" /> 11 | </div> 12 | 13 | <style jsx> 14 | {` 15 | section { 16 | padding: 16px 25px 10px; 17 | display: flex; 18 | font-size: 13px; 19 | } 20 | 21 | div { 22 | padding: 5px 0 5px 16px; 23 | width: 100%; 24 | } 25 | 26 | h2 { 27 | font-size: 14px; 28 | font-weight: 700; 29 | letter-spacing: -0.3px; 30 | } 31 | 32 | progress { 33 | width: 100%; 34 | margin: 9px 0; 35 | } 36 | `} 37 | </style> 38 | </section> 39 | </Layout> 40 | ); 41 | 42 | export default Check; 43 | -------------------------------------------------------------------------------- /renderer/pages/progress.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { fileSizeReadable } from "../utils"; 3 | import Layout from "../components/Layout"; 4 | import Logo from "../components/Logo"; 5 | 6 | class Progress extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | total: 0, 11 | transferred: 0, 12 | percent: 0, 13 | }; 14 | } 15 | 16 | componentDidMount() { 17 | ipcRenderer.on("progress", (event, { transferred, total, percent }) => { 18 | this.setState({ 19 | percent, 20 | total, 21 | transferred, 22 | }); 23 | }); 24 | } 25 | 26 | render() { 27 | const { total, transferred, percent } = this.state; 28 | 29 | return ( 30 | <Layout> 31 | <section> 32 | <Logo size={62} margin={0} /> 33 | <div> 34 | {percent !== 100 && <h2>Downloading update...</h2>} 35 | {percent === 100 && <h2>Ready to Install</h2>} 36 | <progress value={percent} max="100" /> 37 | {percent !== 100 && ( 38 | <footer> 39 | <span> 40 | {fileSizeReadable(transferred)} of {fileSizeReadable(total)} 41 | </span> 42 | <button onClick={() => ipcRenderer.send("cancelUpdate")}> 43 | Cancel 44 | </button> 45 | </footer> 46 | )} 47 | {percent === 100 && ( 48 | <footer> 49 | <button onClick={() => ipcRenderer.send("installUpdate")}> 50 | Install and Relaunch 51 | </button> 52 | </footer> 53 | )} 54 | </div> 55 | </section> 56 | 57 | <style jsx>{` 58 | section { 59 | padding: 16px 25px 10px; 60 | display: flex; 61 | font-size: 13px; 62 | } 63 | 64 | div { 65 | padding: 5px 0 5px 16px; 66 | width: 100%; 67 | } 68 | 69 | h2 { 70 | font-size: 14px; 71 | font-weight: 700; 72 | letter-spacing: -0.3px; 73 | } 74 | 75 | progress { 76 | width: 100%; 77 | margin: 9px 0; 78 | } 79 | 80 | footer { 81 | display: flex; 82 | flex-direction: row; 83 | align-items: center; 84 | justify-content: space-between; 85 | } 86 | 87 | span { 88 | font-weight: 500; 89 | } 90 | 91 | button { 92 | font-size: 13px; 93 | } 94 | `} 95 | </style> 96 | </Layout> 97 | ); 98 | } 99 | } 100 | 101 | export default Progress; 102 | -------------------------------------------------------------------------------- /renderer/pages/start.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import { platform } from "os"; 3 | import { ipcRenderer } from "electron"; 4 | import withRedux from "next-redux-wrapper"; 5 | import React, { Component } from "react"; 6 | import PropTypes from "prop-types"; 7 | 8 | // Components 9 | import Layout from "../components/Layout"; 10 | import TitleBar from "../components/TitleBar"; 11 | 12 | // Containers 13 | import Search from "../containers/Search"; 14 | import Content from "../containers/Content"; 15 | import Footer from "../containers/Footer"; 16 | 17 | // Redux store 18 | import initStore from "./../store"; 19 | 20 | // Redux action creators 21 | import { 22 | setLanguage, 23 | showNotification, 24 | resetSearch, 25 | showSearchPlaceholder, 26 | hideSearchPlaceholder, 27 | updateSearchQuery, 28 | startSearch, 29 | searchByQuery, 30 | downloadComplete, 31 | showSearchSpinner, 32 | searchByFiles, 33 | dropFiles, 34 | updateSearchResults, 35 | logDonatedButtonClicked, 36 | logAboutWindowOpend, 37 | updateFileSearchStatus, 38 | } from "./../actions"; 39 | 40 | // Analytics 41 | import { initGA, logPageView } from "./../utils/tracking"; 42 | 43 | // Global variables 44 | const ESC_KEY = 27; 45 | 46 | class MainApp extends Component { 47 | constructor(props) { 48 | super(props); 49 | 50 | this.isWindows = platform() === "win32"; 51 | this.onLanguageChange = this.onLanguageChange.bind(this); 52 | this.checkIfOnline = this.checkIfOnline.bind(this); 53 | this.onKeyDown = this.onKeyDown.bind(this); 54 | this.onSearch = this.onSearch.bind(this); 55 | this.onFocus = this.onFocus.bind(this); 56 | this.onBlur = this.onBlur.bind(this); 57 | } 58 | 59 | // handling escape close 60 | componentDidMount() { 61 | initGA(); 62 | logPageView(); 63 | this.checkIfOnline(); 64 | 65 | ipcRenderer.on("results", (event, { results, isFinished }) => { 66 | this.props.updateSearchResults({ 67 | results, 68 | searchCompleted: isFinished, 69 | }); 70 | }); 71 | 72 | ipcRenderer.on("language", (event, language) => { 73 | this.props.setLanguage(language); 74 | }); 75 | 76 | ipcRenderer.on("allFilesDownloaded", () => { 77 | this.props.downloadComplete(); 78 | }); 79 | 80 | ipcRenderer.on("openFile", async (event, file) => { 81 | const rawFiles = [file]; 82 | this.props.dropFiles(rawFiles); 83 | }); 84 | 85 | ipcRenderer.on("processedFiles", (event, files) => { 86 | this.props.dropFiles(files); 87 | }); 88 | 89 | ipcRenderer.on("logDonated", () => { 90 | this.props.logDonatedButtonClicked(); 91 | }); 92 | 93 | ipcRenderer.on("logAbout", () => { 94 | this.props.logAboutWindowOpend(); 95 | }); 96 | 97 | ipcRenderer.on("updateFileSearchStatus", (event, { filePath, status }) => { 98 | this.props.updateFileSearchStatus(filePath, status); 99 | }); 100 | 101 | ipcRenderer.send("getStore", "language"); 102 | document.addEventListener("keydown", this.onKeyDown); 103 | 104 | // Prevent drop on document 105 | document.addEventListener( 106 | "dragover", 107 | event => { 108 | event.preventDefault(); 109 | return false; 110 | }, 111 | false, 112 | ); 113 | 114 | document.addEventListener( 115 | "drop", 116 | event => { 117 | event.preventDefault(); 118 | return false; 119 | }, 120 | false, 121 | ); 122 | } 123 | 124 | componentWillUnmount() { 125 | document.removeEventListener("keydown", this.onKeyDown); 126 | window.removeEventListener("online", this.checkIfOnline); 127 | } 128 | 129 | onKeyDown(event) { 130 | if (event.keyCode >= 48 && event.keyCode <= 90) { 131 | this.onFocus(); 132 | } 133 | 134 | if (event.keyCode === ESC_KEY) { 135 | this.props.resetSearch(); 136 | this.onBlur(); 137 | } 138 | } 139 | 140 | onFocus() { 141 | this.props.hideSearchPlaceholder(); 142 | this.search.getWrappedInstance().searchField.textInput.focus(); 143 | } 144 | 145 | onBlur() { 146 | this.props.showSearchPlaceholder(); 147 | this.search.getWrappedInstance().searchField.textInput.blur(); 148 | } 149 | 150 | onLanguageChange(event) { 151 | const language = event.target.value; 152 | 153 | this.props.setLanguage(language); 154 | } 155 | 156 | onSearch(event) { 157 | if (event) { 158 | event.preventDefault(); 159 | } 160 | 161 | this.props.startSearch(); 162 | } 163 | 164 | checkIfOnline() { 165 | ipcRenderer.send("online", navigator.onLine); 166 | window.addEventListener("offline", () => { 167 | ipcRenderer.send("online", navigator.onLine); 168 | }); 169 | } 170 | 171 | render() { 172 | return ( 173 | <Layout> 174 | {!this.isWindows && <TitleBar title="Caption" />} 175 | <Search 176 | onSubmit={this.onSearch} 177 | onFocus={this.onFocus} 178 | onBlur={this.onBlur} 179 | ref={search => { 180 | this.search = search; 181 | }} 182 | /> 183 | <Content isWindows={this.isWindows} /> 184 | <Footer /> 185 | </Layout> 186 | ); 187 | } 188 | } 189 | 190 | MainApp.propTypes = { 191 | downloadComplete: PropTypes.func.isRequired, 192 | updateSearchResults: PropTypes.func.isRequired, 193 | setLanguage: PropTypes.func.isRequired, 194 | resetSearch: PropTypes.func.isRequired, 195 | hideSearchPlaceholder: PropTypes.func.isRequired, 196 | showSearchPlaceholder: PropTypes.func.isRequired, 197 | startSearch: PropTypes.func.isRequired, 198 | showNotification: PropTypes.func.isRequired, 199 | dropFiles: PropTypes.func.isRequired, 200 | logDonatedButtonClicked: PropTypes.func.isRequired, 201 | logAboutWindowOpend: PropTypes.func.isRequired, 202 | updateFileSearchStatus: PropTypes.func.isRequired, 203 | }; 204 | 205 | const mapStateToProps = ({ ui, search }) => ({ 206 | language: ui.language, 207 | searchQuery: search.searchQuery, 208 | files: search.files, 209 | placeholder: search.placeholder, 210 | results: search.results, 211 | loading: search.loading, 212 | searchCompleted: search.searchCompleted, 213 | }); 214 | 215 | const mapDispatchToProps = { 216 | setLanguage, 217 | showNotification, 218 | resetSearch, 219 | showSearchPlaceholder, 220 | hideSearchPlaceholder, 221 | startSearch, 222 | searchByQuery, 223 | updateSearchQuery, 224 | downloadComplete, 225 | showSearchSpinner, 226 | searchByFiles, 227 | dropFiles, 228 | updateSearchResults, 229 | logDonatedButtonClicked, 230 | logAboutWindowOpend, 231 | updateFileSearchStatus, 232 | }; 233 | 234 | export default withRedux(initStore, mapStateToProps, mapDispatchToProps)(MainApp); 235 | -------------------------------------------------------------------------------- /renderer/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import ui from "./ui"; 4 | import search from "./search"; 5 | 6 | export default combineReducers({ 7 | ui, 8 | search, 9 | }); 10 | -------------------------------------------------------------------------------- /renderer/reducers/search.js: -------------------------------------------------------------------------------- 1 | /* eslint no-case-declarations: 0 */ 2 | 3 | import * as types from "./../types"; 4 | 5 | const initialState = { 6 | files: [], 7 | results: [], 8 | loading: false, 9 | searchCompleted: true, 10 | searchAttempts: 0, 11 | searchQuery: "", 12 | placeholder: "Search for a show...", 13 | dropFilePath: "", 14 | }; 15 | 16 | export default function reducer(state = initialState, action) { 17 | switch (action.type) { 18 | case types.RESET_SEARCH: 19 | return { 20 | ...state, 21 | files: [], 22 | results: [], 23 | loading: false, 24 | searchCompleted: true, 25 | searchAttempts: 0, 26 | searchQuery: "", 27 | placeholder: "Search for a show...", 28 | dropFilePath: "", 29 | }; 30 | case types.SHOW_SEARCH_PLACEHOLDER: 31 | return { 32 | ...state, 33 | placeholder: "Search for a show...", 34 | }; 35 | case types.HIDE_SEARCH_PLACEHOLDER: 36 | return { 37 | ...state, 38 | placeholder: "", 39 | }; 40 | case types.UPDATE_SEARCH_QUERY: 41 | return { 42 | ...state, 43 | files: [], 44 | results: [], 45 | searchQuery: action.payload.query, 46 | dropFilePath: "", 47 | }; 48 | case types.SHOW_SEARCH_SPINNER: 49 | return { 50 | ...state, 51 | loading: true, 52 | searchCompleted: false, 53 | }; 54 | case types.DOWNLOAD_COMPLETE: 55 | return { 56 | ...state, 57 | loading: false, 58 | searchCompleted: true, 59 | }; 60 | case types.UPDATE_SEARCH_RESULTS: 61 | return { 62 | ...state, 63 | loading: false, 64 | results: action.payload.results, 65 | searchCompleted: action.payload.searchCompleted, 66 | }; 67 | case types.DROP_FILES: 68 | return { 69 | ...state, 70 | searchQuery: "", 71 | files: action.payload.files, 72 | }; 73 | case types.INCREASE_SEARCH_ATTEMPTS: 74 | return { 75 | ...state, 76 | searchAttempts: action.payload.attempts, 77 | }; 78 | case types.SET_DROPPED_FILE_PATH: 79 | return { 80 | ...state, 81 | dropFilePath: action.payload.realPath, 82 | dropFilePathClean: action.payload.cleanPath, 83 | searchQuery: "", 84 | }; 85 | case types.UPDATE_FILE_SEARCH_STATUS: 86 | const correspondingFile = state.files.find(file => file.path === action.payload.filePath); 87 | const otherFiles = state.files.filter(file => file.path !== action.payload.filePath); 88 | 89 | return { 90 | ...state, 91 | files: [ 92 | ...otherFiles, 93 | { 94 | ...correspondingFile, 95 | status: action.payload.status, 96 | }, 97 | ], 98 | }; 99 | default: 100 | return state; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /renderer/reducers/ui.js: -------------------------------------------------------------------------------- 1 | import * as types from "./../types"; 2 | 3 | const initialState = { 4 | language: "eng", 5 | }; 6 | 7 | export default function reducer(state = initialState, action) { 8 | switch (action.type) { 9 | case types.SET_LANGUAGE: 10 | return { 11 | ...state, 12 | language: action.payload.language, 13 | }; 14 | default: 15 | return state; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /renderer/static/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.icns -------------------------------------------------------------------------------- /renderer/static/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.ico -------------------------------------------------------------------------------- /renderer/static/icon.iconset/..icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/..icns -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_1024x1024.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /renderer/static/icon.iconset/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.iconset/icon_64x64.png -------------------------------------------------------------------------------- /renderer/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/icon.png -------------------------------------------------------------------------------- /renderer/static/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gielcobben/caption/6e0b067b986639bdab7ef148d520814ea5a13b71/renderer/static/loading.gif -------------------------------------------------------------------------------- /renderer/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from "redux"; 2 | import { composeWithDevTools } from "redux-devtools-extension"; 3 | 4 | // Middleware 5 | import thunkMiddleware from "redux-thunk"; 6 | import { createLogger } from "redux-logger"; 7 | 8 | // Root reducer 9 | import rootReducer from "./../reducers"; 10 | 11 | const initStore = (initialState = {}) => { 12 | const loggerMiddleware = createLogger(); 13 | 14 | return createStore( 15 | rootReducer, 16 | initialState, 17 | composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)), 18 | ); 19 | }; 20 | 21 | export default initStore; 22 | -------------------------------------------------------------------------------- /renderer/types/index.js: -------------------------------------------------------------------------------- 1 | // ui 2 | export const SET_LANGUAGE = "SET_LANGUAGE"; 3 | export const SHOW_NOTIFICATION = "SHOW_NOTIFICATION"; 4 | export const LOG_DONATED = "LOG_DONATED"; 5 | export const LOG_ABOUT = "LOG_ABOUT"; 6 | 7 | // search 8 | export const RESET_SEARCH = "RESET_SEARCH"; 9 | export const HIDE_SEARCH_PLACEHOLDER = "HIDE_SEARCH_PLACEHOLDER"; 10 | export const SHOW_SEARCH_PLACEHOLDER = "SHOW_SEARCH_PLACEHOLDER"; 11 | export const UPDATE_SEARCH_QUERY = "UPDATE_SEARCH_QUERY"; 12 | export const SHOW_SEARCH_SPINNER = "SHOW_SEARCH_SPINNER"; 13 | export const DOWNLOAD_COMPLETE = "DOWNLOAD_COMPLETE"; 14 | export const SEARCH_BY_QUERY = "SEARCH_BY_QUERY"; 15 | export const SEARCH_BY_FILES = "SEARCH_BY_FILES"; 16 | export const DROP_FILES = "DROP_FILES"; 17 | export const UPDATE_SEARCH_RESULTS = "UPDATE_SEARCH_RESULTS"; 18 | export const INCREASE_SEARCH_ATTEMPTS = "INCREASE_SEARCH_ATTEMPTS"; 19 | export const LOG_SEARCH_QUERY = "LOG_SEARCH_QUERY"; 20 | export const SET_DROPPED_FILE_PATH = "SET_DROPPED_FILE_PATH"; 21 | export const UPDATE_FILE_SEARCH_STATUS = "UPDATE_FILE_SEARCH_STATUS"; 22 | -------------------------------------------------------------------------------- /renderer/utils/index.js: -------------------------------------------------------------------------------- 1 | // File size readable 2 | const fileSizeReadable = size => { 3 | if (size >= 1000000000) { 4 | return `${Math.ceil(size / 1000000000)}GB`; 5 | } else if (size >= 1000000) { 6 | return `${Math.ceil(size / 1000000)}MB`; 7 | } else if (size >= 1000) { 8 | return `${Math.ceil(size / 1000)}kB`; 9 | } 10 | return `${Math.ceil(size)}B`; 11 | }; 12 | 13 | // Export 14 | export { fileSizeReadable }; 15 | -------------------------------------------------------------------------------- /renderer/utils/tracking.js: -------------------------------------------------------------------------------- 1 | import ReactGA from "react-ga"; 2 | 3 | export const initGA = () => { 4 | ReactGA.initialize("UA-89238300-2"); 5 | }; 6 | 7 | export const logPageView = () => { 8 | ReactGA.set({ page: "/v2/start" }); 9 | ReactGA.set({ version: "2" }); 10 | ReactGA.pageview("/v2/start"); 11 | }; 12 | 13 | export const logQuery = query => { 14 | ReactGA.event({ 15 | category: "search", 16 | action: query, 17 | label: `Searched for ${query}`, 18 | }); 19 | }; 20 | 21 | export const logDonated = () => { 22 | ReactGA.event({ 23 | category: "interaction", 24 | action: "Donate button clicked", 25 | }); 26 | }; 27 | 28 | export const logAbout = () => { 29 | ReactGA.event({ 30 | category: "interaction", 31 | action: "About window opened", 32 | }); 33 | }; 34 | --------------------------------------------------------------------------------