.
28 | install_name_tool -change /System/Library/Frameworks/CoreImage.framework/Versions/A/CoreImage /System/Library/Frameworks/QuartzCore.framework/Versions/A/Frameworks/CoreImage.framework/Versions/A/CoreImage dist/libavfilter.7.dylib
29 | install_name_tool -change /usr/local/opt/mpv/lib/libmpv.1.dylib '@loader_path/libmpv.1.dylib' ../src/node_modules/mpv.js/build/Release/mpvjs.node
30 |
31 | chmod +x dist/*.dylib
32 | cp -r dist/* ../src/node_modules/mpv.js/build/Release/
33 |
34 | rm -f ../src/app/build/renderer.js.map
35 |
36 | npm run dev_no_watch --prefix ../src
37 |
38 | electron-packager ../src --overwrite --ignore=app/js --platform=darwin --arch=x64 --out=../build/dev --icon=../icon_pro.icns --prune \
39 | --electron-version=5.0.13 --extend-info extend-info-pro.plist
--------------------------------------------------------------------------------
/scripts/build_lite.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sh ./0_build_mas.sh lite
4 | sh ./1_packageAppStore.sh lite
--------------------------------------------------------------------------------
/scripts/build_pro.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sh ./0_build_mas.sh
4 | sh ./1_packageAppStore.sh
--------------------------------------------------------------------------------
/scripts/change_dep.sh:
--------------------------------------------------------------------------------
1 | change_deps() {
2 | local dep=$1
3 | local depname=$(basename $dep)
4 | echo $depname
5 | # [[ -e dist/$depname ]] || install -m755 $dep dist
6 | otool -L $dep | awk '/\/usr\/local.*\.dylib /{print $1}' | while read lib; do
7 | local libname=$(basename $lib)
8 | [[ $depname = $libname ]] && continue
9 | echo $libname
10 | install_name_tool -change $lib @loader_path/$libname dist/$depname
11 | # [[ -e dist/$libname ]] && continue
12 | # install -m755 $lib dist
13 | done
14 | }
15 |
16 | change_deps $1
--------------------------------------------------------------------------------
/scripts/child.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.inherit
8 |
9 |
10 |
--------------------------------------------------------------------------------
/scripts/copy_mpv_deps.sh:
--------------------------------------------------------------------------------
1 | copy_deps() {
2 | local dep=$1
3 | local depname=$(basename $dep)
4 | [[ -e dist/$depname ]] || install -m755 $dep dist
5 | otool -L $dep | awk '/\/usr\/local.*\.dylib /{print $1}' | while read lib; do
6 | local libname=$(basename $lib)
7 | [[ $depname = $libname ]] && continue
8 | echo $libname
9 | install_name_tool -change $lib @loader_path/$libname dist/$depname
10 | [[ -e dist/$libname ]] && continue
11 | install -m755 $lib dist
12 | copy_deps $lib
13 | done
14 | }
15 |
16 | set +x
17 | copy_deps /usr/local/lib/libmpv.1.dylib
18 | set -x
--------------------------------------------------------------------------------
/scripts/dist/ffmpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/ffmpeg
--------------------------------------------------------------------------------
/scripts/dist/ffprobe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/ffprobe
--------------------------------------------------------------------------------
/scripts/dist/libavcodec.58.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libavcodec.58.dylib
--------------------------------------------------------------------------------
/scripts/dist/libavdevice.58.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libavdevice.58.dylib
--------------------------------------------------------------------------------
/scripts/dist/libavfilter.7.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libavfilter.7.dylib
--------------------------------------------------------------------------------
/scripts/dist/libavformat.58.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libavformat.58.dylib
--------------------------------------------------------------------------------
/scripts/dist/libavutil.56.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libavutil.56.dylib
--------------------------------------------------------------------------------
/scripts/dist/libjpeg.9.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libjpeg.9.dylib
--------------------------------------------------------------------------------
/scripts/dist/libmpv.1.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libmpv.1.dylib
--------------------------------------------------------------------------------
/scripts/dist/libogg.0.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libogg.0.dylib
--------------------------------------------------------------------------------
/scripts/dist/libopus.0.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libopus.0.dylib
--------------------------------------------------------------------------------
/scripts/dist/libpostproc.55.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libpostproc.55.dylib
--------------------------------------------------------------------------------
/scripts/dist/libswresample.3.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libswresample.3.dylib
--------------------------------------------------------------------------------
/scripts/dist/libswscale.5.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libswscale.5.dylib
--------------------------------------------------------------------------------
/scripts/dist/libtheoradec.1.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libtheoradec.1.dylib
--------------------------------------------------------------------------------
/scripts/dist/libtheoraenc.1.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libtheoraenc.1.dylib
--------------------------------------------------------------------------------
/scripts/dist/libvorbis.0.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libvorbis.0.dylib
--------------------------------------------------------------------------------
/scripts/dist/libvorbisenc.2.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libvorbisenc.2.dylib
--------------------------------------------------------------------------------
/scripts/dist/libx264.155.dylib:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/scripts/dist/libx264.155.dylib
--------------------------------------------------------------------------------
/scripts/i18n_linter:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const assert = require("assert").strict;
4 | const { difference, keys } = require("../src/node_modules/lodash/lodash");
5 | const en = require("../src/app/i18n/en.json");
6 | const codes = ['uk', 'zh-CN', 'zh-TW', 'ru'];
7 |
8 | codes.forEach(code=>{
9 | const translation = require(`../src/app/i18n/${code}.json`);
10 | const diff = difference(keys(en), keys(translation));
11 |
12 | assert(diff.length === 0, `en i18n file has more keys than the ${code} one, keys = ${diff}`);
13 | })
14 |
--------------------------------------------------------------------------------
/scripts/install_ffmpeg.sh:
--------------------------------------------------------------------------------
1 | brew tap circleapps/ffmpeg
2 | brew install circleapps/ffmpeg/ffmpeg --with-disable-securetransport
--------------------------------------------------------------------------------
/scripts/install_mpv.sh:
--------------------------------------------------------------------------------
1 | brew tap circleapps/mpv
2 | brew install circleapps/mpv/mpv
--------------------------------------------------------------------------------
/scripts/install_mpvjs.sh:
--------------------------------------------------------------------------------
1 | cd ../src
2 | rm -rf node_modules
3 | npm install
4 | cd ../scripts
5 | cp -r dist/* ../src/node_modules/mpv.js/build/Release/
6 | install_name_tool -change /usr/local/opt/mpv/lib/libmpv.1.dylib '@loader_path/libmpv.1.dylib' ../src/node_modules/mpv.js/build/Release/mpvjs.node
--------------------------------------------------------------------------------
/scripts/lite.parent.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.application-groups
8 | G9SLV5C872.co.circleapps.sourceplayerlite
9 | com.apple.security.network.client
10 |
11 | com.apple.security.files.user-selected.read-only
12 |
13 |
14 |
--------------------------------------------------------------------------------
/scripts/loginhelper.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 |
8 |
--------------------------------------------------------------------------------
/scripts/parent.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.application-groups
8 | G9SLV5C872.co.circleapps.sourceplayer
9 | com.apple.security.network.client
10 |
11 | com.apple.security.network.server
12 |
13 | com.apple.security.files.user-selected.read-only
14 |
15 | com.apple.security.files.user-selected.read-write
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/src/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "babel-eslint",
4 | "parserOptions": {
5 | "sourceType": "module",
6 | "allowImportExportEverywhere": false,
7 | "codeFrame": false
8 | },
9 | "extends": ["airbnb", "prettier"],
10 | "env": {
11 | "browser": true,
12 | "es6": true,
13 | "jest": true
14 | },
15 | "globals": {
16 | "PRO_VERSION": "readonly"
17 | },
18 | "rules": {
19 | "max-len": ["error", { "code": 150 }],
20 | "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
21 | "prefer-promise-reject-errors": ["off"],
22 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
23 | "react/prop-types": ["warn"],
24 | "no-return-assign": ["off"],
25 | "import/prefer-default-export": "off",
26 | "no-shadow": "warn",
27 | "consistent-return": "warn",
28 | "no-restricted-syntax": "off",
29 | "no-await-in-loop": "off",
30 | "react/require-default-props": "off",
31 | "no-underscore-dangle": ["warn", { "allowAfterThis": false }],
32 | "react/prefer-stateless-function": "warn",
33 | "global-require": "off",
34 | "no-unused-expressions": [
35 | "warn",
36 | {
37 | "allowShortCircuit": false,
38 | "allowTernary": false
39 | }
40 | ],
41 | "quotes": [
42 | 2,
43 | "single",
44 | {
45 | "avoidEscape": true
46 | }
47 | ],
48 | "jsx-a11y/anchor-is-valid": "off",
49 | "indent": ["error", 4, { "SwitchCase": 1 }],
50 | "react/jsx-indent": ["error", 4],
51 | "jsx-quotes": ["error", "prefer-double"],
52 | "comma-dangle": ["error", "never"],
53 | "func-style": "off",
54 | "react/forbid-prop-types": [
55 | "error",
56 | {
57 | "forbid": ["any"]
58 | }
59 | ],
60 | "jsx-a11y/no-static-element-interactions": "off",
61 | "jsx-a11y/click-events-have-key-events": "off",
62 | "jsx-a11y/role-has-required-aria-props": "off",
63 | "react/jsx-one-expression-per-line": "off",
64 | "react/jsx-props-no-spreading": "warn",
65 | "react/destructuring-assignment": "warn",
66 | "react/jsx-boolean-value": "warn",
67 | "react/jsx-closing-bracket-location": "off",
68 | "react/jsx-curly-spacing": "warn",
69 | "react/jsx-indent-props": "off",
70 | "react/jsx-key": "warn",
71 | "react/jsx-max-props-per-line": [1, { "maximum": 3 }],
72 | "react/jsx-no-bind": "off",
73 | "react/jsx-no-literals": "off",
74 | "react/jsx-pascal-case": "warn",
75 | "import/extensions": "off",
76 | "react/jsx-sort-prop-types": "off",
77 | "react/jsx-sort-props": "off",
78 | "react/jsx-wrap-multilines": "error",
79 | "react/no-multi-comp": "off",
80 | "react/no-set-state": "off",
81 | "react/prefer-es6-class": "warn",
82 | "react/self-closing-comp": "warn",
83 |
84 | "react/sort-comp": [
85 | "error",
86 | {
87 | "order": ["static-variables", "static-methods", "lifecycle", "render", "everything-else"]
88 | }
89 | ],
90 | "react/sort-prop-types": "warn"
91 | },
92 | "settings": {
93 | "import/core-modules": ["electron"]
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 150,
3 | "useTabs": false,
4 | "tabWidth": 4,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": true
10 | }
11 |
--------------------------------------------------------------------------------
/src/README.md:
--------------------------------------------------------------------------------
1 | # README
2 |
3 | This README would normally document whatever steps are necessary to get your application up and running.
4 |
5 | ### What is this repository for?
6 |
7 | - Quick summary
8 | - Version
9 | - [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo)
10 |
11 | ### How do I get set up?
12 |
13 | - Summary of set up
14 | - Configuration
15 | - Dependencies
16 | - Database configuration
17 | - How to run tests
18 | - Deployment instructions
19 |
20 | ### Contribution guidelines
21 |
22 | - Writing tests
23 | - Code review
24 | - Other guidelines
25 |
26 | ### Who do I talk to?
27 |
28 | - Repo owner or admin
29 | - Other community or team contact
30 |
--------------------------------------------------------------------------------
/src/app/assets/css/react-search-input.css:
--------------------------------------------------------------------------------
1 | .k-search-input {
2 | padding: 10px 10px;
3 | height: 52px;
4 | position: relative;
5 | color: white;
6 | }
7 |
8 | .k-search-input::before {
9 | content: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxMzggNzkuMTU5ODI0LCAyMDE2LzA5LzE0LTAxOjA5OjAxICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+IEmuOgAAAUxJREFUOI2N0z1LnUEQBeDHj86oWKSxFqJom8Y0CoqYFMYirY1YCX4hFqKQCLERNJVFChttgkVUIiiIWouFaKXkF3ixuRrSRLR45w3LRa/3wDIHds/Z2ZnZqqmZWQmaMI6PaEUVrvALyygoQW3C32MjTFJ0xBrFMDbTzeqIfdgJ8T560YBX6MYW6vEDn0oNGrCOGnxFPw5wiz84xiBm4klraE4NRvAaR5jHQ+k7A0vYjqzGUoOB4N/KiHOsRPyQGrwJfvqCGE4i5pr/RVTB7Sn+pQaXwd9WIMzP/E4NdoJPyKpcDpMRc41qfJdNWDcWyoinZRNaxGpqUMQQ7jGHPfTIBqcOXfgpayNcS2qQj/KerJ0bsqnseyKDYohbcBgZF9Iu7MbmF5zhDn9xgUW0oRPnaA+TxvQzwQ0+x3oOPSE+RrHUoBIU8E72Vx4eAfJWQ43lt2BiAAAAAElFTkSuQmCC');
10 | display: block;
11 | position: absolute;
12 | width: 15px;
13 | z-index: 3;
14 | height: 15px;
15 | font-size: 20px;
16 | top: 11px;
17 | left: 16px;
18 | line-height: 32px;
19 | opacity: 0.6;
20 | }
21 |
22 | .k-search-input > input {
23 | width: 100%;
24 | font-size: 18px;
25 | border: none;
26 | line-height: 22px;
27 | padding: 5px 10px 5px 25px;
28 | height: 32px;
29 | position: relative;
30 | background-color: rgb(46, 46, 46);
31 | border-radius: 6px;
32 | }
33 |
34 | .k-search-input > span {
35 | position: absolute;
36 | right: 15px;
37 | top: 5px;
38 | padding: 10px;
39 | cursor: pointer;
40 | visibility: hidden;
41 | }
42 |
43 | .k-search-input > input:focus {
44 | outline: none;
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/assets/documentation/vid_tutorial.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
41 |
42 |
43 |
44 |
BeyondPlayer Video Tutorial
45 |
46 |
BeyondPlayer Essential
47 |
48 |
49 |
52 |
53 |
54 |
55 |
56 |
BeyondPlayer Advanced (Pro Only Features)
57 |
58 |
59 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/app/assets/documentation/vids/advanced.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/documentation/vids/advanced.mp4
--------------------------------------------------------------------------------
/src/app/assets/documentation/vids/essential.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/documentation/vids/essential.mp4
--------------------------------------------------------------------------------
/src/app/assets/fonts/FiraSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/FiraSans-Regular.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/FiraSansCondensed-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/FiraSansCondensed-Regular.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/Merriweather-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/Merriweather-Regular.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/Oswald-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/Oswald-Regular.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/TitilliumWeb-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/TitilliumWeb-Bold.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/TitilliumWeb-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/TitilliumWeb-Regular.ttf
--------------------------------------------------------------------------------
/src/app/assets/fonts/TitilliumWeb-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/assets/fonts/TitilliumWeb-SemiBold.ttf
--------------------------------------------------------------------------------
/src/app/i18n/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const fs = require('fs');
3 | let loadedLanguage;
4 | const util = require('util');
5 |
6 | module.exports = new i18n();
7 |
8 | function i18n() {}
9 |
10 | i18n.prototype.init = function(locale) {
11 | if (fs.existsSync(path.join(__dirname, locale + '.json'))) {
12 | loadedLanguage = JSON.parse(fs.readFileSync(path.join(__dirname, locale + '.json'), 'utf8'));
13 | } else {
14 | loadedLanguage = JSON.parse(fs.readFileSync(path.join(__dirname, 'en.json'), 'utf8'));
15 | }
16 | };
17 |
18 | i18n.prototype.t = function(phrase) {
19 | let translation = loadedLanguage[phrase];
20 | if (translation === undefined) {
21 | translation = phrase;
22 | }
23 | return translation;
24 | };
25 |
26 | i18n.prototype.tf = function(phrase, ...args) {
27 | let translation = loadedLanguage[phrase];
28 | if (translation === undefined) {
29 | translation = phrase;
30 | }
31 | return util.format(translation, ...args);
32 | };
33 |
--------------------------------------------------------------------------------
/src/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BeyondPlayer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
17 |
18 |
--------------------------------------------------------------------------------
/src/app/inter_op_webpane.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 |
3 | document.addEventListener(
4 | 'contextmenu',
5 | function(e) {
6 | e = e || window.event;
7 | var selection = window.getSelection();
8 | if (selection) {
9 | var msg = {
10 | text: selection.toString(),
11 | x: e.clientX,
12 | y: e.clientY
13 | };
14 | ipcRenderer.sendToHost('right-click-selection', msg);
15 | }
16 | },
17 | false
18 | );
19 |
20 | document.addEventListener(
21 | 'mouseup',
22 | function(e) {
23 | e = e || window.event;
24 | var selection = window.getSelection();
25 | if (selection) {
26 | ipcRenderer.sendToHost('change-selection', selection.toString());
27 | }
28 | },
29 | false
30 | );
31 |
--------------------------------------------------------------------------------
/src/app/inter_op_youtube.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 |
3 | ipcRenderer.on('videoId', function(event, data) {
4 | global.videoId = data;
5 | });
6 |
7 | ipcRenderer.on('loadVideo', function(event, data) {
8 | global.loadVideo(data);
9 | });
10 |
11 | ipcRenderer.on('playVideo', function(event, data) {
12 | global.player.playVideo();
13 | });
14 |
15 | ipcRenderer.on('pauseVideo', function(event, data) {
16 | global.player.pauseVideo();
17 | });
18 |
19 | ipcRenderer.on('startProgress', function(event, data) {
20 | global.startProgress();
21 | });
22 |
23 | ipcRenderer.on('seek', function(event, time) {
24 | global.player.seekTo(time);
25 | });
26 |
27 | ipcRenderer.on('speed', function(event, speed) {
28 | global.player.setPlaybackRate(speed);
29 | });
30 |
31 | ipcRenderer.on('checkSubtitleAndAd', function(event) {
32 | global.checkSubtitleAndAd();
33 | });
34 |
35 | ipcRenderer.on('openSubtitle', function(event) {
36 | global.openSubtitle();
37 | });
38 |
39 | ipcRenderer.on('checkAndHideControls', function(event, delay, hideControls) {
40 | global.checkAndHideControls();
41 | });
42 |
43 | ipcRenderer.on('hideSubtitle', function(event) {
44 | global.hideSubtitle();
45 | });
46 |
47 | global.sendToHost = (channel, data) => {
48 | ipcRenderer.sendToHost(channel, data);
49 | };
50 |
--------------------------------------------------------------------------------
/src/app/inter_op_youtube_browser.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 | const getVideoId = require('get-video-id');
3 |
4 | document.addEventListener('DOMContentLoaded', function() {
5 | document.addEventListener(
6 | 'click',
7 | function(e) {
8 | let a;
9 | if (e.target.nodeNmae == 'A') {
10 | a = e.target;
11 | } else {
12 | a = e.target.closest('a');
13 | }
14 | if (!a) return true;
15 |
16 | let path = a.href;
17 | let id;
18 |
19 | if (!path.includes('/user/')) {
20 | id = getVideoId(path).id;
21 | }
22 |
23 | if (id) {
24 | //e.preventDefault();
25 | //e.stopPropagation();
26 | setTimeout(function() {
27 | ipcRenderer.sendToHost('click-video', path);
28 | }, 100);
29 | return false;
30 | } else {
31 | return true;
32 | }
33 | },
34 | true
35 | );
36 |
37 | setInterval(() => {
38 | if (document.activeElement && document.activeElement.nodeName == 'INPUT') {
39 | ipcRenderer.sendToHost('is-focus', true);
40 | } else {
41 | ipcRenderer.sendToHost('is-focus', false);
42 | }
43 | }, 1000);
44 |
45 | document.ondragover = document.ondrop = ev => {
46 | ev.preventDefault();
47 | ev.stopPropagation();
48 | };
49 |
50 | document.body.ondrop = ev => {
51 | ev.preventDefault();
52 | ev.stopPropagation();
53 | };
54 | });
55 |
56 | ipcRenderer.on('pause', function(event) {
57 | var video = document.getElementsByTagName('video')[0];
58 | if (video) {
59 | setTimeout(() => {
60 | video.pause();
61 | }, 5000);
62 | }
63 | });
64 |
65 | global.sendToHost = (channel, data) => {
66 | ipcRenderer.sendToHost(channel, data);
67 | };
68 |
--------------------------------------------------------------------------------
/src/app/js/Components/BaseSubtitleSettingsPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 |
4 | export default class BaseSubtitleSettingsPane extends React.Component {
5 | simulateClickElement(e) {
6 | const offset = this.getOffset(e);
7 | offset.x += 10;
8 | offset.y += 10;
9 | const wc = remote.getCurrentWindow().webContents;
10 | wc.sendInputEvent({ type: 'mouseDown', x: offset.x, y: offset.y, button: 'left', clickCount: 1 });
11 | wc.sendInputEvent({ type: 'mouseUp', x: offset.x, y: offset.y, button: 'left', clickCount: 1 });
12 | }
13 |
14 | getOffset(el) {
15 | let x = 0;
16 | let y = 0;
17 |
18 | while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) {
19 | x += el.offsetLeft - el.scrollLeft;
20 | y += el.offsetTop - el.scrollTop;
21 | el = el.offsetParent;
22 | }
23 |
24 | return { y, x };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/js/Components/Common.less:
--------------------------------------------------------------------------------
1 | .byp-button {
2 | outline: none;
3 | border: none;
4 | background-color: transparent;
5 | text-align: center;
6 | display: inline-block;
7 | }
8 |
9 | .byp-toolbar-button {
10 | width: 39px;
11 | height: 100%;
12 | font-size: 22px;
13 | border: none;
14 | outline: none;
15 | background-color: rgba(39, 39, 39, 0);
16 | color: rgb(240, 240, 240);
17 | padding: 0;
18 | }
19 |
20 | .byp-toolbar-button.byp-active {
21 | background-color: rgba(19, 19, 19, 0.75);
22 | }
23 |
24 | .byp-icon {
25 | display: inline-block;
26 | height: 24px;
27 | width: 24px;
28 | background-size: cover;
29 | }
30 |
31 | .byp-anim {
32 | -webkit-animation: rotation 2s infinite linear;
33 | }
34 |
35 | @-webkit-keyframes rotation {
36 | to {
37 | -webkit-transform: rotate(0deg);
38 | }
39 | from {
40 | -webkit-transform: rotate(359deg);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/js/Components/DictionaryItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Dictionary from '../Model/Dictionary';
3 |
4 | export default class DictionaryItem extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.audio = null;
8 | this.onClickPlaySound = this.onClickPlaySound.bind(this);
9 | this.state = { isPaused: true };
10 | }
11 |
12 | onClickPlaySound(evt) {
13 | this.play();
14 | }
15 |
16 | play() {
17 | Dictionary.pronounce(this.props.data.word);
18 | }
19 |
20 | render() {
21 | return (
22 |
23 |
{this.props.data.name}
24 |
25 | {this.props.data.definition.map((line, i) => (
26 |
{line}
27 | ))}
28 |
29 |
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/js/Components/DictionaryPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DictionaryItem from './DictionaryItem.jsx';
3 |
4 | export default class DictionaryPane extends React.Component {
5 | render() {
6 | return (
7 |
8 | {this.props.data.map((item, i) => (
9 |
10 | ))}
11 |
12 | );
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/AnkiVideoCard.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Card, Tooltip, Checkbox } from 'antd';
4 | import { card, thumb, text, checkbox } from './AnkiVideoCard.module.less';
5 |
6 | const AnkiVideoCard = ({ id, frontPreview, frontText, checked, onSelectVideo }) => (
7 | onSelectVideo(id, !checked)}>
8 |
9 |
10 |
11 | {frontText}
12 |
13 |
14 | );
15 |
16 | AnkiVideoCard.propTypes = {
17 | checked: PropTypes.bool.isRequired,
18 | frontPreview: PropTypes.string.isRequired,
19 | frontText: PropTypes.string.isRequired,
20 | id: PropTypes.string.isRequired,
21 | onSelectVideo: PropTypes.func.isRequired
22 | };
23 | export { AnkiVideoCard };
24 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/AnkiVideoCard.module.less:
--------------------------------------------------------------------------------
1 | .card {
2 | height: 150px;
3 | width: 150px !important;
4 | padding: 0 !important;
5 | margin: 5px;
6 | position: relative;
7 | cursor: pointer;
8 |
9 | * {
10 | cursor: pointer;
11 | }
12 | }
13 |
14 | .text {
15 | display: inline-block;
16 | white-space: nowrap;
17 | overflow: hidden;
18 | width: 100%;
19 | padding: 5px;
20 | text-overflow: ellipsis;
21 | }
22 |
23 | .thumb {
24 | max-width: 100%;
25 | max-height: 120px;
26 | }
27 |
28 | .checkbox {
29 | position: absolute;
30 | right: 5px;
31 | bottom: 5px;
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/ExportAnkiDialog.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 | import { Button, Tabs, Modal, Spin, notification } from 'antd';
4 | import { ExportAnkiVidLib } from './ExportAnkiVidLib.jsx';
5 | import { AnkiFileDestination } from '../../Model/Export/destinations/anki';
6 |
7 | const i18n = remote.require('./i18n');
8 |
9 | export class ExportAnkiDialog extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.activeTab = 'ankiVidLib';
13 | this.state = { exporting: false };
14 | }
15 |
16 | render() {
17 | const { onClose } = this.props;
18 | const { exporting } = this.state;
19 |
20 | return (
21 |
31 | {i18n.t('close')}
32 | ,
33 |
36 | ]}>
37 |
38 |
39 |
(this.currentSource = activeTab)}>
40 |
41 |
42 | (this.ankiVidLib = e)} />
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | onExport = () => {
53 | this[this.activeTab].getParams().then(async params => {
54 | const { deckName, source, frontTemplate, backTemplate } = params;
55 | const defaultPath = `${deckName.replace(/\s/g, '').toLowerCase()}.anki.apkg`;
56 | const file = remote.dialog.showSaveDialog(remote.getCurrentWindow(), { defaultPath });
57 |
58 | if (file) {
59 | this.setState(() => ({ exporting: true }));
60 |
61 | new AnkiFileDestination({ deckName, file, frontTemplate, backTemplate })
62 | .export(source)
63 | .then(() => {
64 | notification.success({
65 | placement: 'topRight',
66 | message: i18n.t('anki.export.dialog.success')
67 | });
68 | })
69 | .catch(() => {
70 | notification.error({
71 | placement: 'topRight',
72 | message: i18n.t('anki.export.dialog.fail')
73 | });
74 | })
75 | .finally(() => {
76 | this.setState(() => ({ exporting: false }));
77 | });
78 | }
79 | });
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/ExportAnkiDialog.less:
--------------------------------------------------------------------------------
1 | .anki-export-modal {
2 | .ant-modal-body {
3 | overflow: scroll;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/ExportAnkiVidLib.module.less:
--------------------------------------------------------------------------------
1 | .hasError > :global(.ant-input),
2 | .hasError > :global(.ant-card) {
3 | border: 1px solid #f5222d;
4 | }
5 |
6 | .cardsContainer {
7 | max-height: 250px;
8 | min-height: 250px;
9 | overflow: scroll;
10 | }
11 |
12 | .selectorLink.selectorLink {
13 | padding-right: 0;
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/js/Components/ExportAnkiDialog/index.js:
--------------------------------------------------------------------------------
1 | export { ExportAnkiDialog } from './ExportAnkiDialog.jsx';
2 |
--------------------------------------------------------------------------------
/src/app/js/Components/Loader/Loader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Modal from 'react-modal';
3 | import PropTypes from 'prop-types';
4 | import MessageDialog from '../MessageDialog.jsx';
5 | import { root } from './Loader.module.less';
6 |
7 | /**
8 | * A default wait popup dialog
9 | * @param {Object} params
10 | * @param {boolean} params.isOpen - a proxy option to the modal dialog, shows/hides dialog
11 | * @param {function} params.onAfterOpen - a proxy option to the modal dialog, on after open callback
12 | * @param {string=} params.message - a popup message
13 | */
14 | const Loader = ({ isOpen = true, onAfterOpen = () => {}, message = '' }) => (
15 |
16 |
17 |
18 | );
19 |
20 | Loader.propTypes = {
21 | isOpen: PropTypes.bool,
22 | message: PropTypes.string,
23 | onAfterOpen: PropTypes.func
24 | };
25 |
26 | export { Loader };
27 |
--------------------------------------------------------------------------------
/src/app/js/Components/Loader/Loader.module.less:
--------------------------------------------------------------------------------
1 | .root {
2 | top: 50%;
3 | left: 50%;
4 | right: auto;
5 | bottom: auto;
6 | margin-right: -50%;
7 | transform: translate(-50%; -50%);
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/js/Components/Loader/Loader.spec.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import React from 'react';
3 | import Modal from 'react-modal';
4 | import { render, screen } from '@testing-library/react';
5 | import { Loader } from './Loader.jsx';
6 |
7 | describe('Loader specs', () => {
8 | beforeAll(() => {
9 | const modalRoot = document.createElement('div');
10 |
11 | modalRoot.setAttribute('id', 'modal-root');
12 | document.body.appendChild(modalRoot);
13 |
14 | Modal.setAppElement(modalRoot);
15 | });
16 |
17 | it('should correctly render a message', () => {
18 | const message = 'Test message';
19 | render();
20 |
21 | expect(screen.queryByText(message)).not.toBeNull();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/app/js/Components/Loader/index.js:
--------------------------------------------------------------------------------
1 | export { Loader } from './Loader.jsx';
2 |
--------------------------------------------------------------------------------
/src/app/js/Components/MRUItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import _ from 'lodash';
3 | import path from 'path-extra';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 | import { faYoutube } from '@fortawesome/free-brands-svg-icons';
6 | import { faHdd } from '@fortawesome/free-solid-svg-icons';
7 |
8 | export default class MRUItem extends React.Component {
9 | handleOnClickOpen = e => {
10 | if (_.isString(this.props.file)) {
11 | this.props.onOpenRecentFile(this.props.file);
12 | } else {
13 | this.props.onOpenRecentURL(this.props.file.url);
14 | }
15 | };
16 |
17 | getTitle = mruItem => {
18 | if (_.isString(mruItem)) {
19 | return path.basename(mruItem);
20 | } else {
21 | return mruItem.title;
22 | }
23 | };
24 |
25 | render() {
26 | return (
27 |
28 | {_.isString(this.props.file) ? (
29 |
30 | ) : (
31 |
32 | )}
33 | {this.getTitle(this.props.file)}
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/js/Components/MRUPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MRUItem from './MRUItem.jsx';
3 | import MRUFiles from '../Model/MRUFiles';
4 |
5 | export default class MRUPane extends React.Component {
6 | constructor(props) {
7 | super(props);
8 |
9 | this.state = {
10 | mru: []
11 | };
12 |
13 | MRUFiles.load(files => {
14 | this.setState({ mru: files });
15 | });
16 | }
17 |
18 | render() {
19 | return (
20 |
21 |
22 |
23 | {' '}
24 | Open Movie File ...{' '}
25 |
26 |
27 | {' '}
28 | Open Youtube Video ...{' '}
29 |
30 |
31 |
32 |
33 | {this.state.mru.map((file, i) => (
34 |
40 | ))}
41 |
42 |
43 |
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/js/Components/MessageDialog.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Loader from 'react-loader-spinner';
3 |
4 | export default class MessageDialog extends React.Component {
5 | render() {
6 | return (
7 |
8 |
(this.message = ref)}>
9 | {this.props.message}
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/js/Components/MessagePane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import lerp from 'lerp';
3 |
4 | export default class MessagePane extends React.Component {
5 | static ANIM_TIME = 300;
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | opacity: 0,
10 | show: false
11 | };
12 | this.start = 0;
13 | }
14 |
15 | showMessage(message, ms) {
16 | this.setState({ opacity: 1, show: true }, () => {
17 | this.refs.message.innerHTML = message;
18 | this.playAnim = false;
19 | clearTimeout(this.timer);
20 | if (ms) {
21 | this.timer = setTimeout(() => {
22 | this.hide();
23 | }, ms);
24 | }
25 | });
26 | }
27 |
28 | hide() {
29 | this.start = 0;
30 | this.playAnim = true;
31 | window.requestAnimationFrame(this.hideUpdate);
32 | }
33 |
34 | hideUpdate = timestamp => {
35 | if (!this.playAnim) return;
36 |
37 | if (!this.start) this.start = timestamp;
38 | let progress = timestamp - this.start;
39 |
40 | this.setState({
41 | opacity: lerp(1, 0, progress / MessagePane.ANIM_TIME)
42 | });
43 |
44 | if (progress < MessagePane.ANIM_TIME) {
45 | window.requestAnimationFrame(this.hideUpdate);
46 | } else {
47 | this.start = 0;
48 | this.setState({ show: false });
49 | }
50 | };
51 |
52 | render() {
53 | return (
54 |
55 |
56 | Message form
57 |
58 |
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/js/Components/OpenMediaPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { faYoutube } from '@fortawesome/free-brands-svg-icons';
4 | import { faHdd, faCompass } from '@fortawesome/free-solid-svg-icons';
5 | import { remote } from 'electron';
6 |
7 | const i18n = remote.require('./i18n');
8 |
9 | export default class OpenMediaPane extends React.Component {
10 | render() {
11 | return (
12 |
13 |
14 |
17 |
{i18n.t('open.file')}
18 |
19 | {PRO_VERSION ? (
20 |
21 |
24 |
{i18n.t('open.youtube.video')}
25 |
26 | ) : null}
27 |
28 |
31 |
{i18n.t('tutorial')}
32 |
33 |
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/js/Components/PlayerControls.less:
--------------------------------------------------------------------------------
1 | .byp-player-controls-container {
2 | height: 62px;
3 | margin-bottom: 4px;
4 | width: 100%;
5 | display: flex;
6 | flex-direction: column;
7 | z-index: 10;
8 | }
9 |
10 | .byp-player-controls-toolbar {
11 | border-radius: 5px;
12 | margin: 0px 0px 2px 15px;
13 | background-color: rgba(102, 102, 102, 0.355);
14 | }
15 |
16 | .byp-player-controls {
17 | height: 42px;
18 | width: 100%;
19 | display: flex;
20 | background-color: rgba(75, 75, 75, 0);
21 | padding: 0 10px 4px 10px;
22 | }
23 |
24 | .byp-seek {
25 | height: 20px;
26 | padding: 0px 7px 0px 7px;
27 | outline: none;
28 | }
29 |
30 | .byp-timer-current {
31 | height: 42px;
32 | flex: 1;
33 | font-family: 'k-font-numbers', sans-serif;
34 | font-size: 15px;
35 | padding: 6px 23px 8px 12px;
36 | color: rgba(255, 255, 255, 0.5);
37 | }
38 |
39 | .byp-timer-duration {
40 | height: 40px;
41 | padding: 11px 15px 11px 3px;
42 | color: rgb(211, 211, 211);
43 | }
44 |
45 | .byp-seek input {
46 | height: 100%;
47 | width: 100%;
48 | }
49 |
50 | .byp-loop-counter {
51 | display: inline-block;
52 | width: 14px;
53 | height: 14px;
54 | font-size: 10px;
55 | line-height: 100%;
56 | top: 0px;
57 | left: 12px;
58 | text-align: center;
59 | position: relative;
60 | background-color: #127bc8;
61 | padding-top: 1px;
62 | padding-left: 1px;
63 |
64 | border-radius: 7px;
65 | color: white;
66 | /*text-shadow: rgb(243, 243, 243) 0px 0px 2px;*/
67 | }
68 |
69 | .byp-player-button {
70 | width: 43px;
71 | height: 40px;
72 | font-size: 22px;
73 | border: none;
74 | outline: none;
75 | background-color: rgba(39, 39, 39, 0);
76 | color: rgb(240, 240, 240);
77 | text-align: center;
78 | display: flex;
79 | align-items: center;
80 | justify-content: center;
81 | }
82 |
83 | .byp-icon {
84 | &-play {
85 | background-image: url('../Images/play.svg');
86 | width: 32px;
87 | height: 32px;
88 | }
89 | &-pause {
90 | background-image: url('../Images/pause.svg');
91 | width: 32px;
92 | height: 32px;
93 | }
94 |
95 | &-equalizer {
96 | background-image: url('../Images/equalizer.svg');
97 | height: 22px;
98 | width: 22px;
99 | }
100 |
101 | &-pm-normal {
102 | background-image: url('../Images/pm_sequence.svg');
103 | width: 29px;
104 | height: 29px;
105 | }
106 |
107 | &-pm-auto-repeat {
108 | background-image: url('../Images/pm_auto_repeat.svg');
109 | height: 29px;
110 | width: 29px;
111 | }
112 |
113 | &-pm-auto-pause {
114 | background-image: url('../Images/pm_auto_pause.svg');
115 | height: 29px;
116 | width: 29px;
117 | }
118 |
119 | &-loop {
120 | background-image: url('../Images/loop.svg');
121 | width: 26px;
122 | height: 26px;
123 | margin-top: 4px;
124 | }
125 |
126 | &-book {
127 | background-image: url('../Images/book.svg');
128 | margin-top: 5px;
129 | height: 20px;
130 | width: 20px;
131 | }
132 |
133 | &-switches {
134 | background-image: url('../Images/switches.svg');
135 | height: 23px;
136 | width: 23px;
137 | }
138 | }
139 |
140 | .byp-player-button.byp-active {
141 | margin: 0px;
142 | padding: 0px;
143 | background-color: rgba(26, 26, 26);
144 | }
145 |
--------------------------------------------------------------------------------
/src/app/js/Components/PopupPane.less:
--------------------------------------------------------------------------------
1 | .popup-pane() {
2 | position: absolute;
3 | bottom: 52px;
4 | left: 27px;
5 | width: 320px;
6 | height: 340px;
7 | background-color: rgb(59, 59, 59);
8 | z-index: 1;
9 | cursor: default;
10 | padding: 20px 28px;
11 | color: white;
12 | border: 1px solid #e6edec;
13 | border-radius: 6px;
14 | z-index: 11;
15 | }
16 |
17 | .popup-pane-after-and-before(@offset) {
18 | top: 100%;
19 | left: @offset;
20 | border: solid transparent;
21 | content: ' ';
22 | height: 0;
23 | width: 0;
24 | position: absolute;
25 | pointer-events: none;
26 | z-index: 11;
27 | }
28 |
29 | .popup-pane-after() {
30 | border-color: rgba(61, 61, 61, 0);
31 | border-top-color: #3d3d3d;
32 | border-width: 10px;
33 | margin-left: -10px;
34 | }
35 |
36 | .popup-pane-before() {
37 | border-color: rgba(230, 237, 236, 0);
38 | border-top-color: #e6edec;
39 | border-width: 12px;
40 | margin-left: -12px;
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/js/Components/ProgressPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Loader from 'react-loader-spinner';
3 |
4 | export default class ProgressPane extends React.Component {
5 | render() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/js/Components/PromptDialog.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 |
4 | const i18n = remote.require('./i18n');
5 |
6 | export default class PromptDialog extends React.Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | url: this.props.defaultValue
12 | };
13 | }
14 |
15 | componentDidMount() {
16 | this.refs.urlInput.select();
17 | }
18 |
19 | handlePromptOk = () => {
20 | this.props.onPromptOk(this.state.url);
21 | };
22 |
23 | handlePromptCancel = () => {
24 | this.props.onPromptCancel(this.state.url);
25 | };
26 |
27 | handleUrlChange = v => {
28 | this.setState({
29 | url: v.target.value
30 | });
31 | };
32 |
33 | render() {
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
49 |
50 |
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/app/js/Components/SearchInput.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | export default class SearchInput extends Component {
5 | constructor(props) {
6 | super(props);
7 | this.state = {
8 | searchTerm: this.props.value || ''
9 | };
10 | this.updateSearch = this.updateSearch.bind(this);
11 | }
12 |
13 | componentWillReceiveProps(nextProps) {
14 | if (typeof nextProps.value !== 'undefined' && nextProps.value !== this.props.value) {
15 | const e = {
16 | target: {
17 | value: nextProps.value
18 | }
19 | };
20 | this.updateSearch(e);
21 | }
22 | }
23 |
24 | render() {
25 | const {
26 | onChange,
27 | onClickClear,
28 | sortResults,
29 | throttle,
30 | filterKeys,
31 | value,
32 | fuzzy,
33 | flex,
34 | inputClassName,
35 | inputStyle,
36 | ...inputProps
37 | } = this.props; // eslint-disable-line no-unused-vars
38 | inputProps.type = inputProps.type || 'search';
39 | inputProps.value = this.state.searchTerm;
40 | inputProps.onChange = this.updateSearch;
41 | inputProps.placeholder = inputProps.placeholder || 'Search';
42 |
43 | return (
44 |
45 | {' '}
46 |
47 | ×
48 |
49 |
50 | );
51 | }
52 |
53 | handleClickClear = () => {
54 | this.props.onClickClear();
55 | this.refs.clear.style.visibility = 'hidden';
56 | };
57 |
58 | updateSearch(e) {
59 | const searchTerm = e.target.value;
60 | this.setState(
61 | {
62 | searchTerm: searchTerm
63 | },
64 | () => {
65 | this.refs.clear.style.visibility = searchTerm ? 'visible' : 'hidden';
66 | if (this._throttleTimeout) {
67 | clearTimeout(this._throttleTimeout);
68 | }
69 |
70 | this._throttleTimeout = setTimeout(() => this.props.onChange(searchTerm), this.props.throttle);
71 | }
72 | );
73 | }
74 | }
75 |
76 | SearchInput.defaultProps = {
77 | onChange() {},
78 | onClickClear() {},
79 | fuzzy: false,
80 | flex: false,
81 | throttle: 200
82 | };
83 |
84 | SearchInput.propTypes = {
85 | onChange: PropTypes.func,
86 | onClickClear: PropTypes.func,
87 | sortResults: PropTypes.bool,
88 | fuzzy: PropTypes.bool,
89 | flex: PropTypes.bool,
90 | throttle: PropTypes.number,
91 | flex: PropTypes.bool,
92 | filterKeys: PropTypes.oneOf([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
93 | value: PropTypes.string
94 | };
95 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './SettingsPane.jsx';
2 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/ExternalDictionary.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const ExternalDictionary = ({ i18n, onChange, value }) => {
6 | const onChangeExternalDictionary = onValueChange(Settings.SKEY_EXT_DIC, onChange);
7 |
8 | return (
9 |
10 |
13 |
39 |
40 | );
41 | };
42 |
43 | export { ExternalDictionary };
44 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/Language.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const languages = [
6 | {
7 | key: 'en',
8 | name: 'English'
9 | },
10 | {
11 | key: 'zh-CN',
12 | name: '简体中文'
13 | },
14 | {
15 | key: 'zh-TW',
16 | name: '傳統中文'
17 | },
18 | {
19 | key: 'uk',
20 | name: 'Українська'
21 | },
22 | {
23 | key: 'ru',
24 | name: 'Русский'
25 | }
26 | ];
27 | const Language = ({ i18n, onChange, value }) => {
28 | const onChangeLanguage = onValueChange(Settings.UI_LNG, onChange);
29 |
30 | return (
31 |
32 |
35 |
36 |
43 |
44 |
45 | );
46 | };
47 |
48 | export { Language };
49 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/LoopCount.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const LOOP_COUNTS = [
6 | {
7 | name: '2',
8 | key: 2
9 | },
10 | {
11 | name: '3',
12 | key: 3
13 | },
14 | {
15 | name: '4',
16 | key: 4
17 | },
18 | {
19 | name: '5',
20 | key: 5
21 | },
22 | {
23 | name: '6',
24 | key: 6
25 | },
26 | {
27 | name: '7',
28 | key: 7
29 | },
30 | {
31 | name: '8',
32 | key: 8
33 | },
34 | {
35 | name: '9',
36 | key: 9
37 | }
38 | ];
39 |
40 | const LoopCount = ({ i18n, onChange, value }) => {
41 | const onChangeLoopCount = onValueChange(Settings.SKEY_SINGLE_LINE_LOOP_COUNT, onChange);
42 |
43 | return (
44 |
45 |
48 |
49 |
56 |
57 |
58 | );
59 | };
60 |
61 | export { LoopCount };
62 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/MiscSection.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange, extractors } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const { checked } = extractors;
6 |
7 | const MiscSection = ({ i18n, onChange, fastForwardPause, fixedSubtitlePosition, displaySubtitleBackground, wordNotification, autoPauseResume }) => {
8 | const onToggleFastForwardPause = onValueChange(Settings.SKEY_PWFF, onChange, checked);
9 | const onToggleFixedSubtitlePosition = onValueChange(Settings.SKEY_FSP, onChange, checked);
10 | const onToggleDisplaySubtitleBackground = onValueChange(Settings.SKEY_DSB, onChange, checked);
11 | const onToggleWordNotification = onValueChange(Settings.SKEY_DWN, onChange, checked);
12 |
13 | return (
14 |
15 |
18 |
19 |
{i18n.t('pause.playback.when.fast.forward')}
20 |
21 |
22 |
23 |
{i18n.t('fixed.subtitle.position')}
24 |
25 |
26 |
27 |
{i18n.t('display.subtitle.background')}
28 |
29 |
30 |
31 |
{i18n.t('enable.word.notification')}
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export { MiscSection };
39 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/PlayerSubtitleColor.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const PlayerSubtitleColor = ({ i18n, value, onChange }) => {
6 | const onChangeColor = onValueChange(Settings.SKEY_PLAYER_SUB_COLOR, onChange);
7 |
8 | return (
9 |
10 |
13 |
14 | {Settings.COLORS.map((color, index) => (
15 |
16 |
26 |
27 | ))}
28 |
29 |
30 | );
31 | };
32 |
33 | export { PlayerSubtitleColor };
34 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/Socks5Proxy.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange, extractors } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const Socks5Proxy = ({ i18n, onChange, port, ipAddress, enabled }) => {
6 | const onChangePort = onValueChange(Settings.SKEY_SOCKS5_PORT, onChange);
7 | const onToggleEnabled = onValueChange(Settings.SKEY_ENABLE_SOCKS5, onChange, extractors.checked);
8 | const onChangeIpAddress = onValueChange(Settings.SKEY_SOCKS5_IP, onChange);
9 |
10 | return (
11 |
12 |
15 |
16 |
21 |
22 |
23 |
28 |
29 |
30 |
34 |
35 |
36 | - {i18n.t('hint.proxy.1')}
37 | - {i18n.t('hint.proxy.2')}
38 |
39 |
40 | );
41 | };
42 |
43 | export { Socks5Proxy };
44 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/SubtitleFont.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { shell } from 'electron';
3 | import { onValueChange } from './hooks';
4 | import Settings from '../../../Model/Settings';
5 |
6 | const SubtitleFont = ({ i18n, onChange, value }) => {
7 | const onChangeFont = onValueChange(Settings.SKEY_PLAYER_SUB_FONT, onChange);
8 | const onCredit = useCallback(credit => {
9 | return () => shell.openExternal(credit);
10 | }, []);
11 |
12 | return (
13 |
14 |
17 |
18 | {Settings.FONTS.map((font, index) => (
19 |
20 |
33 |
34 | ))}
35 |
36 |
37 | );
38 | };
39 |
40 | export { SubtitleFont };
41 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/Voices.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from 'react';
2 | import Settings from '../../../Model/Settings';
3 | import Dictionary from '../../../Model/Dictionary';
4 |
5 | const Voices = ({ i18n, onChange, value }) => {
6 | const [voices, setVoices] = useState([
7 | {
8 | name: i18n.t('voice.off'),
9 | key: 'Off'
10 | },
11 | {
12 | name: i18n.t('system.voice'),
13 | key: ''
14 | }
15 | ]);
16 |
17 | useEffect(() => {
18 | const processVoices = speechVoices => {
19 | const items = speechVoices.map(({ name, lang }) => ({ name: `${name} [${lang}]`, key: name }));
20 |
21 | setVoices([...voices, ...items]);
22 | };
23 |
24 | const speechVoices = window.speechSynthesis.getVoices();
25 |
26 | if (speechVoices.length) {
27 | processVoices(speechVoices);
28 | } else {
29 | window.speechSynthesis.onvoiceschanged = () => {
30 | window.speechSynthesis.onvoiceschanged = undefined;
31 | processVoices(window.speechSynthesis.getVoices());
32 | };
33 | }
34 | }, [setVoices]);
35 |
36 | const onChangeVoice = useCallback(
37 | ({ target }) => {
38 | const { value } = target;
39 |
40 | if (!value) {
41 | Dictionary.pronounceDefault('Hello, this is the default system voice.');
42 | } else if (value !== 'Off') {
43 | Dictionary.pronounce(`Hello, this is ${value}. I hope you like my voice.`, value);
44 | }
45 |
46 | onChange(Settings.SKEY_VOICE, value);
47 | },
48 | [onChange]
49 | );
50 |
51 | return (
52 |
53 |
56 |
57 |
64 |
65 |
66 | {i18n.t('hint.install.voice.before.change')}
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export { Voices };
74 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/WordBehavior.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { onValueChange } from './hooks';
3 | import Settings from '../../../Model/Settings';
4 |
5 | const WordBehavior = ({ i18n, onChange, value }) => {
6 | const onChangeWordBehavior = onValueChange(Settings.SKEY_CWB, onChange);
7 |
8 | return (
9 |
10 |
13 |
52 |
53 | );
54 | };
55 |
56 | export { WordBehavior };
57 |
--------------------------------------------------------------------------------
/src/app/js/Components/SettingsPane/sections/hooks.js:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 |
3 | const value = target => target.value;
4 | const checked = target => target.checked;
5 | const extractors = { value, checked };
6 |
7 | const onValueChange = (key, onChange, extractor = value) => {
8 | return useCallback(({ target }) => onChange(key, extractor(target)), [onChange]);
9 | };
10 |
11 | export { onValueChange, extractors };
12 |
--------------------------------------------------------------------------------
/src/app/js/Components/SubControls.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 | import { faEllipsisV } from '@fortawesome/free-solid-svg-icons';
5 | import './Common.less';
6 | import './SubControls.less';
7 |
8 | const i18n = remote.require('./i18n');
9 | const { Menu, MenuItem } = remote;
10 |
11 | export default class SubControls extends React.Component {
12 | constructor(props) {
13 | super(props);
14 |
15 | const { onOpenYouTubeVideo, onOpenFile, onSettings } = this.props;
16 |
17 | this.menu = new Menu();
18 | this.menu.append(
19 | new remote.MenuItem({
20 | label: i18n.t('open.file'),
21 | click: () => {
22 | onOpenFile();
23 | }
24 | })
25 | );
26 | if (PRO_VERSION) {
27 | this.menu.append(
28 | new MenuItem({
29 | label: i18n.t('open.youtube.video'),
30 | click: () => {
31 | onOpenYouTubeVideo();
32 | }
33 | })
34 | );
35 | }
36 | this.menu.append(new MenuItem({ type: 'separator' }));
37 | this.menu.append(
38 | new MenuItem({
39 | role: 'recentDocuments',
40 | label: i18n.t('open.recent'),
41 | submenu: [
42 | {
43 | label: i18n.t('clear.recent'),
44 | click() {
45 | remote.app.clearRecentDocuments();
46 | }
47 | }
48 | ]
49 | })
50 | );
51 | this.menu.append(new MenuItem({ type: 'separator' }));
52 | this.menu.append(
53 | new MenuItem({
54 | label: i18n.t('settings'),
55 | click: () => {
56 | onSettings();
57 | }
58 | })
59 | );
60 | this.menu.append(
61 | new MenuItem({
62 | label: i18n.t('quit'),
63 | click: () => {
64 | remote.app.quit();
65 | }
66 | })
67 | );
68 | }
69 |
70 | render() {
71 | return (
72 |
73 |
76 |
77 | );
78 | }
79 |
80 | handleClickMenu = e => {
81 | e.stopPropagation();
82 | this.menu.popup({
83 | x: e.clientX - 180,
84 | y: e.clientY
85 | });
86 | };
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/js/Components/SubControls.less:
--------------------------------------------------------------------------------
1 | .byp-sub-controls {
2 | position: absolute;
3 | height: 100%;
4 | right: 2px;
5 | margin: 0;
6 | text-align: right;
7 | display: inline-block;
8 | z-index: 10;
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/js/Components/SubtitlePane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import LineItemList from './LineItemList.jsx';
3 | import SearchInput from './SearchInput.jsx';
4 | import { remote } from 'electron';
5 | import { breakSentence } from '../Model/KUtils';
6 | import LocalSubtitleSettingsPane from './LocalSubtitleSettingsPane.jsx';
7 | import YouTubeSubtitleSettingsPane from './YouTubeSubtitleSettingsPane.jsx';
8 |
9 | const i18n = remote.require('./i18n');
10 |
11 | export default class SubtitlePane extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.handleClickToggle = this.handleClickToggle.bind(this);
15 | this.onSearchUpdated = this.onSearchUpdated.bind(this);
16 | this.state = {
17 | searchTerm: '',
18 | searchTermWords: [],
19 | filterMode: true,
20 | showSettings: this.props.subtitles.length > 1 ? false : true,
21 | ccList: []
22 | };
23 |
24 | this.list = null;
25 | }
26 |
27 | scrollToSub(index) {
28 | this.list.scrollToSub(index);
29 | }
30 |
31 | updateSubtitleFontSize = () => {
32 | this.list.updateFontSize();
33 | };
34 |
35 | handleClickToggle(e) {
36 | this.setState({ showSettings: !this.state.showSettings });
37 | }
38 |
39 | showSettings(v) {
40 | this.setState({ showSettings: v });
41 | }
42 |
43 | onSearchUpdated(term) {
44 | var words = breakSentence(term);
45 | this.setState({ searchTerm: term, searchTermWords: words });
46 | }
47 |
48 | handleFocusSearch = () => {
49 | this.props.onStartFilterSubtitle();
50 | };
51 |
52 | handleClickClear = () => {
53 | this.setState({ searchTerm: '', searchTermWords: [] }, () => {
54 | if (this.props.lineIndex != -1) {
55 | this.list.scrollToSub(this.props.lineIndex);
56 | }
57 | });
58 | };
59 |
60 | containsElement(el) {}
61 |
62 | setCCList = ccList => {
63 | this.setState({ ccList });
64 | };
65 |
66 | render() {
67 | const filteredLines = this.props.filterBySearchTerm
68 | ? this.props.lines.filter(line => line.text.toLowerCase().includes(this.state.searchTerm.toLowerCase()))
69 | : this.props.lines;
70 |
71 | return (
72 |
73 |
81 |
82 |
(this.list = ref)}
87 | searchTerm={this.state.searchTerm}
88 | searchTermWords={this.state.searchTermWords}
89 | />
90 | {this.state.showSettings ? (
91 | this.props.isLocal ? (
92 |
93 | ) : (
94 |
95 | )
96 | ) : null}
97 |
102 |
103 |
104 |
105 | );
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/app/js/Components/SwitchesPane/SwitchesPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 | import './SwitchesPane.less';
4 | import { Switch } from 'antd';
5 |
6 | const i18n = remote.require('./i18n');
7 |
8 | export default class SwitchesPane extends React.Component {
9 | render() {
10 | const { fullScreen, skipNoDialogueClips, onToggleFullScreen, onToggleSkipNoDialogueClips } = this.props;
11 | return (
12 | (this.container = r)}>
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | containsElement = element => {
26 | return this.container.contains(element);
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/js/Components/SwitchesPane/SwitchesPane.less:
--------------------------------------------------------------------------------
1 | @import '../PopupPane.less';
2 |
3 | .byp-switches-pane {
4 | .popup-pane;
5 | height: 125px;
6 | left: 89px;
7 | width: 290px;
8 | }
9 |
10 | .byp-switches-pane:after,
11 | .byp-switches-pane:before {
12 | .popup-pane-after-and-before(40%);
13 | }
14 |
15 | .byp-switches-pane:after {
16 | .popup-pane-after;
17 | }
18 |
19 | .byp-switches-pane:before {
20 | .popup-pane-before;
21 | }
22 |
23 | .byp-switches-pane > div {
24 | display: flex;
25 | align-items: center;
26 | justify-content: center;
27 | height: 45px;
28 | }
29 |
30 | .byp-switches-pane-prefix {
31 | display: inline-block;
32 | width: 220px;
33 | padding-right: 15px;
34 | text-align: left;
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/js/Components/TagEditor/Input.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 |
3 | const SIZER_STYLES = {
4 | position: 'absolute',
5 | width: 0,
6 | height: 0,
7 | visibility: 'hidden',
8 | overflow: 'scroll',
9 | whiteSpace: 'pre'
10 | };
11 |
12 | const STYLE_PROPS = ['fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'letterSpacing'];
13 |
14 | class Input extends React.Component {
15 | constructor(props) {
16 | super(props);
17 | this.state = { inputWidth: null };
18 | }
19 |
20 | componentDidMount() {
21 | const { autoresize, autofocus } = this.props;
22 |
23 | if (autoresize) {
24 | this.copyInputStyles();
25 | this.updateInputWidth();
26 | }
27 |
28 | if (autofocus) {
29 | this.input.focus();
30 | }
31 | }
32 |
33 | componentDidUpdate() {
34 | this.updateInputWidth();
35 | }
36 |
37 | render() {
38 | const { inputAttributes, inputEventHandlers, query, placeholder, expandable, listboxId, selectedIndex } = this.props;
39 | const { inputWidth } = this.state;
40 |
41 | return (
42 |
43 |
{
47 | this.input = c;
48 | }}
49 | value={query}
50 | placeholder={placeholder}
51 | role="combobox"
52 | aria-autocomplete="list"
53 | aria-label={placeholder}
54 | aria-owns={listboxId}
55 | aria-activedescendant={selectedIndex > -1 ? `${listboxId}-${selectedIndex}` : null}
56 | aria-expanded={expandable}
57 | style={{ width: inputWidth }}
58 | />
59 |
{
61 | this.sizer = c;
62 | }}
63 | style={SIZER_STYLES}>
64 | {query || placeholder}
65 |
66 |
67 | );
68 | }
69 |
70 | copyInputStyles() {
71 | const inputStyle = window.getComputedStyle(this.input);
72 |
73 | STYLE_PROPS.forEach(prop => {
74 | this.sizer.style[prop] = inputStyle[prop];
75 | });
76 | }
77 |
78 | updateInputWidth() {
79 | let inputWidth;
80 | const { autoresize } = this.props;
81 | const { inputWidth: stateInputWidth } = this.state;
82 |
83 | if (autoresize) {
84 | // scrollWidth is designed to be fast not accurate.
85 | // +2 is completely arbitrary but does the job.
86 | inputWidth = Math.ceil(this.sizer.scrollWidth) + 2;
87 | }
88 |
89 | if (inputWidth !== stateInputWidth) {
90 | this.setState({ inputWidth });
91 | }
92 | }
93 | }
94 |
95 | module.exports = Input;
96 |
--------------------------------------------------------------------------------
/src/app/js/Components/TagEditor/Suggestions.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 |
3 | function escapeForRegExp(query) {
4 | return query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
5 | }
6 |
7 | function markIt(input, query) {
8 | let result = input;
9 |
10 | if (query) {
11 | const regex = RegExp(escapeForRegExp(query), 'gi');
12 |
13 | result = input.replace(regex, '$&');
14 | }
15 |
16 | return {
17 | __html: result
18 | };
19 | }
20 |
21 | function filterSuggestions(query, suggestions, length, suggestionsFilter) {
22 | let filter = suggestionsFilter;
23 |
24 | if (!filter) {
25 | const regex = new RegExp(`(?:^|\\s)${escapeForRegExp(query)}`, 'i');
26 | filter = item => regex.test(item.name);
27 | }
28 |
29 | return suggestions.filter(item => filter(item, query)).slice(0, length);
30 | }
31 |
32 | class Suggestions extends React.Component {
33 | constructor(props) {
34 | super(props);
35 |
36 | const { query, suggestions, maxSuggestionsLength, suggestionsFilter } = this.props;
37 |
38 | this.state = {
39 | options: filterSuggestions(query, suggestions, maxSuggestionsLength, suggestionsFilter)
40 | };
41 | }
42 |
43 | // eslint-disable-next-line camelcase
44 | UNSAFE_componentWillReceiveProps(newProps) {
45 | this.setState({
46 | options: filterSuggestions(newProps.query, newProps.suggestions, newProps.maxSuggestionsLength, newProps.suggestionsFilter)
47 | });
48 | }
49 |
50 | render() {
51 | const { expandable, selectedIndex, listboxId, query } = this.props;
52 | const { options: stateOptions } = this.state;
53 |
54 | if (!expandable || !stateOptions.length) {
55 | return null;
56 | }
57 |
58 | const options = stateOptions.map((item, i) => {
59 | const key = `${listboxId}-${i}`;
60 | const classNames = [];
61 |
62 | if (selectedIndex === i) {
63 | classNames.push('is-active');
64 | }
65 |
66 | if (item.disabled) {
67 | classNames.push('is-disabled');
68 | }
69 |
70 | return (
71 |
78 |
79 |
80 | );
81 | });
82 |
83 | return (
84 |
89 | );
90 | }
91 |
92 | handleMouseDown(item, e) {
93 | // focus is shifted on mouse down but calling preventDefault prevents this
94 | e.preventDefault();
95 |
96 | // eslint-disable-next-line react/destructuring-assignment
97 | this.props.addTag(item);
98 | }
99 | }
100 |
101 | module.exports = Suggestions;
102 |
--------------------------------------------------------------------------------
/src/app/js/Components/TagEditor/Tag.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 |
3 | const Tag = ({ classNames, tag, onDelete }) => (
4 |
7 | );
8 |
9 | module.exports = Tag;
10 |
--------------------------------------------------------------------------------
/src/app/js/Components/TagEditorDialog.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TagEditor from './TagEditor/TagEditor.jsx';
3 | import VidLib from '../Model/VidLib';
4 | import { remote } from 'electron';
5 | const i18n = remote.require('./i18n');
6 |
7 | export default class TagEditorDialog extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {
11 | tags: this.props.tags.map(tag => {
12 | return { name: tag };
13 | }),
14 | suggestions: VidLib.getAllTags().map(tag => {
15 | return { name: tag };
16 | })
17 | };
18 | }
19 |
20 | handleDelete = i => {
21 | const tags = this.state.tags.slice(0);
22 | tags.splice(i, 1);
23 | this.setState({ tags });
24 | };
25 |
26 | handleAddition = tag => {
27 | const tags = [].concat(this.state.tags, tag);
28 | this.setState({ tags });
29 | };
30 |
31 | handleClickOK = () => {
32 | let query = this.tagEditor.getQuery();
33 | if (query) {
34 | const tags = [].concat(this.state.tags, { name: query });
35 | this.setState({ tags }, () => {
36 | this.props.onClickOK(
37 | this.state.tags.map(tag => {
38 | return tag.name;
39 | })
40 | );
41 | });
42 | } else {
43 | this.props.onClickOK(
44 | this.state.tags.map(tag => {
45 | return tag.name;
46 | })
47 | );
48 | }
49 | };
50 |
51 | handleAddRecentTag = tag => {
52 | return e => {
53 | this.handleAddition({ name: tag });
54 | };
55 | };
56 |
57 | render() {
58 | const { tags, suggestions } = this.state;
59 | return (
60 |
61 |
62 |
{i18n.t('editing.tags')}
63 |
64 |
{
72 | this.tagEditor = r;
73 | }}
74 | />
75 |
76 | {this.props.recentTags.reverse().map((tag, i) => (
77 |
80 | ))}
81 |
82 |
83 |
87 |
91 |
92 |
93 | );
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/app/js/Components/TitlePane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default class TitlePane extends React.Component {
4 | render() {
5 | return {this.props.title}
;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/js/Components/TunerPane.less:
--------------------------------------------------------------------------------
1 | @import url('./PopupPane.less');
2 |
3 | .byp-tuner-pane {
4 | .popup-pane;
5 | }
6 |
7 | .byp-tuner-pane:after,
8 | .byp-tuner-pane:before {
9 | .popup-pane-after-and-before(42%);
10 | }
11 |
12 | .byp-tuner-pane:after {
13 | .popup-pane-after;
14 | }
15 |
16 | .byp-tuner-pane:before {
17 | .popup-pane-before;
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/js/Components/VidItem.module.less:
--------------------------------------------------------------------------------
1 | .videoNum {
2 | position: absolute;
3 | display: inline-block;
4 | top: 3px;
5 | left: 3px;
6 | color: rgb(235, 235, 235);
7 | font-size: 12px;
8 | background-color: rgba(107, 107, 107, 0.151);
9 | padding: 0 10 0 10;
10 | border-radius: 2px;
11 | border: none;
12 | width: 22px;
13 | text-align: center;
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/js/Components/VidLibPane.module.less:
--------------------------------------------------------------------------------
1 | .icon {
2 | width: 29px;
3 | height: 29px;
4 |
5 | &Normal {
6 | margin-top: 5px;
7 | background-image: url('../Images/pm_sequence.svg');
8 | }
9 |
10 | &AutoRepeat {
11 | margin-top: 5px;
12 | background-image: url('../Images/pm_auto_repeat.svg');
13 | }
14 |
15 | &AutoPause {
16 | margin-top: 5px;
17 | background-image: url('../Images/pm_auto_pause.svg');
18 | }
19 | }
20 |
21 | .toolbar {
22 | height: 52px;
23 | width: 100%;
24 | padding-left: 10px;
25 | text-align: right;
26 | position: relative;
27 | background-color: rgb(40, 40, 40);
28 | }
29 |
30 | .toolbarSeparator {
31 | width: 1px;
32 | height: 18px;
33 | border: none;
34 | outline: none;
35 | margin-top: 8px;
36 | background-color: rgb(94, 92, 92);
37 | }
38 |
39 | .toolbarButton {
40 | width: 35px;
41 | height: 100%;
42 | font-size: 22px;
43 | border: none;
44 | outline: none;
45 | background-color: rgba(39, 39, 39, 0);
46 | color: white;
47 |
48 | text-align: center;
49 | display: flex;
50 | align-items: center;
51 | justify-content: center;
52 | }
53 |
54 | .loopCounter {
55 | display: inline-block;
56 | width: 16px;
57 | height: 16px;
58 | font-size: 10px;
59 | line-height: 100%;
60 | top: -5px;
61 | left: 13px;
62 | text-align: center;
63 | position: relative;
64 | background-color: #127bc8;
65 | padding-top: 2px;
66 | padding-left: 0px;
67 |
68 | border-radius: 8px;
69 | color: white;
70 | }
71 |
--------------------------------------------------------------------------------
/src/app/js/Components/VidLineItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { select } from '../Model/KUtils';
3 |
4 | export default class VidLineItem extends React.PureComponent {
5 | static CHARS_PER_LINE = 38;
6 | static ROW_HEIGHT = 25;
7 | static ROW_PADDING = 16;
8 |
9 | constructor(props) {
10 | super(props);
11 | this.progressBar = null;
12 | }
13 |
14 | handleClickWord = e => {
15 | e.stopPropagation();
16 | var word = e.target.innerText;
17 | //e.target.style["user-select"] = "text";
18 | select(e.target);
19 | this.props.onClickWord(word, e.target);
20 | select(null);
21 | //e.target.style["user-select"] = "none";
22 | };
23 |
24 | updateProgress = percent => {
25 | this.progressBar.style.height = percent + '%';
26 | };
27 |
28 | buildClassName = () => {
29 | var cn = 'k-vid-line-item-words';
30 | if (this.props.currentIndex === this.props.line.index && this.props.highlighted) {
31 | cn += ' k-selected';
32 | }
33 | if (this.props.line.index % 2 == 0) {
34 | cn += ' k-even';
35 | } else {
36 | cn += ' k-odd';
37 | }
38 | return cn;
39 | };
40 |
41 | handleMouseDown = e => {
42 | this.dragStartPos = [e.screenX, e.screenY];
43 | };
44 |
45 | handleMouseMove = e => {
46 | this.dragEndPos = [e.screenX, e.screenY];
47 | };
48 |
49 | handleMouseUp = e => {
50 | if (!this.dragStartPos) return;
51 |
52 | if (this.dragStartPos[0] == this.dragEndPos[0] && this.dragStartPos[1] == this.dragEndPos[1]) {
53 | if (e.target.nodeName != 'SPAN') {
54 | this.props.onClickLine(this.props.line.index, true);
55 | }
56 | }
57 | };
58 |
59 | handleMouseLeave = e => {};
60 |
61 | render() {
62 | const line = this.props.line;
63 | return (
64 | (this.element = ref)}
67 | onMouseDown={this.handleMouseDown}
68 | onMouseMove={this.handleMouseMove}
69 | onMouseUp={this.handleMouseUp}
70 | onMouseLeave={this.handleMouseLeave}
71 | data-index={line.index}
72 | onContextMenu={this.props.onContextMenu}
73 | style={{ height: line.height }}>
74 |
(this.progressBar = ref)}
77 | style={{
78 | height: this.props.currentIndex > line.index ? '100%' : '0%',
79 | backgroundColor: this.props.highlighted ? 'rgb(9, 140, 228)' : 'rgb(40, 40, 40)'
80 | }}>
81 |
82 | {line.words.map((word, i) =>
83 | word == '\n' ? (
84 |
85 | ) : (
86 | = line.highlightStart && i <= line.highlightEnd ? ' k-highlighter' : ''}
88 | key={i}
89 | onClick={this.handleClickWord}>
90 | {' '}
91 | {word}{' '}
92 |
93 | )
94 | )}
95 |
96 |
97 | );
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/js/Components/WebSourceItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { remote } from 'electron';
3 | const i18n = remote.require('./i18n');
4 |
5 | export default class WebSourceItem extends React.Component {
6 | handleChangeHomeUrl = e => {
7 | this.props.onChangeHomeUrl(this.props.index, e.target.value);
8 | };
9 |
10 | handleChangeSearchUrl = e => {
11 | this.props.onChangeSearchUrl(this.props.index, e.target.value);
12 | };
13 |
14 | handleChangeName = e => {
15 | this.props.onChangeName(this.props.index, e.target.value);
16 | };
17 |
18 | handleChangeEnabled = e => {
19 | this.props.onChangeEnabled(this.props.index, e.target.checked);
20 | };
21 |
22 | handleChangeSeparator = e => {
23 | this.props.onChangeSeparator(this.props.index, e.target.value);
24 | };
25 |
26 | collectValue() {
27 | return {
28 | name: this.state.name,
29 | homeUrl: this.state.homeUrl,
30 | searchUrl: this.state.searchUrl,
31 | enabled: this.state.enabled
32 | };
33 | }
34 |
35 | render() {
36 | return (
37 |
38 |
39 | {i18n.t('web.source.name')}
40 |
41 |
42 |
43 |
44 |
45 | {i18n.t('web.source.search.url')}
46 |
47 |
48 |
49 |
50 |
51 | {i18n.t('web.source.home.url')}
52 |
53 |
54 |
55 |
56 |
57 | {i18n.t('web.source.separator')}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
70 |
71 |
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordDefintionEditorPane.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { faUndo, faTimes } from '@fortawesome/free-solid-svg-icons';
4 | import Dictionary from '../Model/Dictionary';
5 | import { remote } from 'electron';
6 | const i18n = remote.require('./i18n');
7 |
8 | export default class WordDefinitionEditorPane extends React.Component {
9 | constructor(props) {
10 | super(props);
11 | this.state = {
12 | definition: props.definition
13 | };
14 | }
15 |
16 | handleChange = e => {
17 | this.setState({
18 | definition: e.target.value
19 | });
20 | };
21 |
22 | handleOK = e => {
23 | this.props.onOK(this.props.word, this.state.definition);
24 | };
25 |
26 | handleCancel = e => {
27 | this.props.onCancel();
28 | };
29 |
30 | handleClickDelete = e => {
31 | if (confirm(i18n.t('confirm.delete.word.annotation'))) {
32 | this.props.onDeleteWordDefinition(this.props.word);
33 | }
34 | };
35 |
36 | handleClickReload = e => {
37 | Dictionary.lookup(this.props.word, str => {
38 | this.setState({
39 | definition: str
40 | });
41 | });
42 | };
43 |
44 | render() {
45 | return (
46 |
47 |
48 |
{this.props.word}
49 | {this.props.isEditing ? (
50 |
51 |
58 |
64 |
65 | ) : null}
66 |
67 |
71 |
72 |
76 |
80 |
81 |
82 | );
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import LineItem from './LineItem.jsx';
3 | import { select } from '../Model/KUtils';
4 | import WordBookInstance from '../Model/WordBook';
5 |
6 | export default class WordItem extends React.PureComponent {
7 | constructor(props) {
8 | super(props);
9 | this.element = null;
10 | }
11 |
12 | handleClickWord = e => {
13 | e.stopPropagation();
14 | var word = e.target.innerText;
15 | select(e.target);
16 | this.props.onClickWord(word, e.target);
17 | select(null);
18 | };
19 |
20 | handleClickLine = e => {
21 | this.props.onSelect(this.props.word);
22 | };
23 |
24 | handleContextMenu = e => {
25 | this.props.onContextMenuOnWord(e);
26 | this.props.onSelect(this.props.word);
27 | };
28 |
29 | handleClickLineOnLineItem = (index, target) => {
30 | this.props.onClickLineOnLineItem(index, this.props.word);
31 | };
32 |
33 | highlight(flag) {
34 | if (flag) {
35 | this.element.classList.add('selected');
36 | } else {
37 | this.element.classList.remove('selected');
38 | }
39 | }
40 |
41 | handleClickRemove = () => {
42 | this.props.onRemoveWord(this.props.word);
43 | };
44 |
45 | handleClickSearchInDictionary = () => {
46 | this.props.onSearchInDictionary(this.props.word);
47 | };
48 |
49 | handleClickSearchInWeb = () => {
50 | this.props.onSearchInWeb(this.props.word);
51 | };
52 |
53 | handleClickEditNote = () => {
54 | this.props.onEditNote(this.props.word);
55 | };
56 |
57 | trimDef(def) {
58 | const MAX = 200;
59 | if (def.length > MAX) {
60 | return def.substring(0, MAX - 3) + '...';
61 | }
62 | return def;
63 | }
64 |
65 | render() {
66 | const def = WordBookInstance.getWordDefinition(this.props.word);
67 | const textStyle = def ? 'underline' : 'inherit';
68 | return (
69 |
70 |
(this.element = ref)}
73 | onClick={this.handleClickLine}
74 | onContextMenu={this.handleContextMenu}>
75 |
76 | {this.props.word}{' '}
77 |
78 | {this.props.selected ? (
79 |
80 |
83 |
86 |
89 |
90 | ) : null}
91 | {this.props.selected ?
: null}
92 |
93 |
94 | {this.props.wordRelatedLines.map((line, i) => (
95 |
105 | ))}
106 |
107 |
108 | );
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordNotifier/WordNotifierItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import lerp from 'lerp';
3 | import ReactTimeout from 'react-timeout';
4 |
5 | class WordNotifierItem extends React.Component {
6 | static ANIM_TIME = 300;
7 | static REMOVE_TIME = 10000;
8 |
9 | constructor(props) {
10 | super(props);
11 | this.rootDOM = React.createRef();
12 | this.state = {
13 | height: 0,
14 | opacity: 1
15 | };
16 | }
17 |
18 | componentDidMount() {
19 | this.resetHeight();
20 | if (!this.props.pause) {
21 | this.countTimeRemove();
22 | }
23 | }
24 |
25 | countTimeRemove() {
26 | this.timer = this.props.setTimeout(() => {
27 | //this.hide();
28 | this.props.onSelfRemove(this.props.notification);
29 | }, WordNotifierItem.REMOVE_TIME);
30 | }
31 |
32 | componentDidUpdate(preProps) {
33 | if (preProps.pause != this.props.pause) {
34 | if (this.props.pause) {
35 | clearTimeout(this.timer);
36 | } else {
37 | this.countTimeRemove();
38 | }
39 | }
40 | }
41 |
42 | resetHeight() {
43 | this.setState({ height: this.rootDOM.current.scrollHeight });
44 | }
45 |
46 | truncate(content) {
47 | const MAX = 120;
48 | if (content.length > MAX) {
49 | content = content.substring(0, MAX - 3) + '...';
50 | }
51 | var lines = content.split('\n');
52 |
53 | return lines.map((line, index) => {line}
);
54 | }
55 |
56 | onClickClose = e => {
57 | e.stopPropagation();
58 | e.preventDefault();
59 | this.props.onClickClose(this.props.notification);
60 | };
61 |
62 | onClickEdit = e => {
63 | e.stopPropagation();
64 | e.preventDefault();
65 | this.props.onClickEdit(this.props.notification);
66 | };
67 |
68 | hide() {
69 | this.start = 0;
70 | this.playAnim = true;
71 | window.requestAnimationFrame(this.hideUpdate);
72 | }
73 |
74 | hideUpdate = timestamp => {
75 | if (!this.playAnim) return;
76 |
77 | if (!this.start) this.start = timestamp;
78 | let progress = timestamp - this.start;
79 |
80 | this.setState({
81 | opacity: lerp(1, 0, progress / WordNotifierItem.ANIM_TIME)
82 | });
83 |
84 | if (progress < WordNotifierItem.ANIM_TIME) {
85 | window.requestAnimationFrame(this.hideUpdate);
86 | } else {
87 | this.start = 0;
88 | this.props.onSelfRemove(this.props.notification);
89 | }
90 | };
91 |
92 | render() {
93 | const { notification } = this.props;
94 | let { childElementStyle } = this.state;
95 |
96 | let fontSize = 14;
97 |
98 | if (this.props.containerSize > 600) {
99 | fontSize = 20;
100 | }
101 |
102 | const toolbar = (
103 |
104 |
105 |
106 |
107 | );
108 |
109 | return (
110 |
119 |
120 |
121 | {toolbar}
122 |
{notification.word}
123 |
{this.truncate(notification.definition)}
124 |
125 |
126 |
127 | );
128 | }
129 | }
130 |
131 | export default ReactTimeout(WordNotifierItem);
132 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordNotifier/constants.js:
--------------------------------------------------------------------------------
1 | export const NOTIFICATION_BASE_CLASS = 'notification-item';
2 |
3 | export const CONTAINER = {
4 | BOTTOM_LEFT: 'bottom-left',
5 | BOTTOM_RIGHT: 'bottom-right',
6 | BOTTOM_CENTER: 'bottom-center',
7 | TOP_LEFT: 'top-left',
8 | TOP_RIGHT: 'top-right',
9 | TOP_CENTER: 'top-center'
10 | };
11 |
12 | export const INSERTION = {
13 | TOP: 'top',
14 | BOTTOM: 'bottom'
15 | };
16 |
17 | export const NOTIFICATION_TYPE = {
18 | SUCCESS: 'success',
19 | DANGER: 'danger',
20 | INFO: 'info',
21 | DEFAULT: 'default',
22 | WARNING: 'warning'
23 | };
24 |
25 | export const NOTIFICATION_STAGE = {
26 | // used for both sliding and animation at the same time
27 | SLIDING_ANIMATION_EXIT: 'SLIDING_ANIMATION_EXIT',
28 |
29 | // used by API call to remove notification
30 | MANUAL_REMOVAL: 'REMOVAL'
31 | };
32 |
33 | export const REMOVAL = {
34 | TIMEOUT: 1,
35 | CLICK: 2,
36 | TOUCH: 3,
37 | MANUAL: 4
38 | };
39 |
40 | export const ERROR = {
41 | // dismiss icon option
42 | DISMISS_ICON_CLASS: 'className property of dismissIcon option is required',
43 | DISMISS_ICON_CONTENT: 'content property of dismissIcon option is required',
44 | DISMISS_ICON_STRING: 'className property of dismissIcon option must be a String',
45 | DISMISS_ICON_INVALID: 'content property of dismissIcon option must be a valid React element',
46 |
47 | // animations
48 | ANIMATION_IN: 'animationIn option must be an array',
49 | ANIMATION_OUT: 'animationOut option must be an array',
50 |
51 | // dismiss
52 | DISMISS_REQUIRED: 'duration property of dismiss option is required',
53 | DISMISS_NUMBER: 'duration property of dismiss option must be a Number',
54 | DISMISS_POSITIVE: 'duration property of dismiss option must be a positive Number',
55 |
56 | // title
57 | TITLE_STRING: 'title option must be a String.',
58 |
59 | // message
60 | MESSAGE_REQUIRED: 'message option is required',
61 | MESSAGE_STRING: 'message option must be a String',
62 |
63 | // type
64 | TYPE_REQUIRED: 'type option is required',
65 | TYPE_STRING: 'type option must be a String',
66 | TYPE_NOT_EXISTENT: 'type option not found',
67 |
68 | // container
69 | CONTAINER_REQUIRED: 'container option is required',
70 | CONTAINER_STRING: 'container option must be a String',
71 |
72 | // dismissable
73 | DISMISSABLE_CLICK_BOOL: 'click property of dismissable option must be a Boolean',
74 | DISMISSABLE_TOUCH_BOOL: 'touch property of dismissable option must be a Boolean',
75 |
76 | // width
77 | WIDTH_NUMBER: 'width option must be a Number',
78 |
79 | // insert
80 | INSERT_STRING: 'insert option must be a String',
81 |
82 | // transition
83 | TRANSITION_DURATION_NUMBER: 'duration property of transition option must be a Number',
84 | TRANSITION_CUBICBEZIER_NUMBER: 'cubicBezier property of transition option must be a String',
85 | TRANSITION_DELAY_NUMBER: 'delay property of transition option must be a Number',
86 |
87 | // custom types
88 | TYPE_NOT_FOUND: 'custom type not found'
89 | };
90 |
91 | export const BREAKPOINT = 768;
92 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordNotifier/helpers.js:
--------------------------------------------------------------------------------
1 | export function getCubicBezierTransition(duration = 500, cubicBezier = 'linear', delay = 0, property = 'height') {
2 | return `${duration}ms ${property} ${cubicBezier} ${delay}ms`;
3 | }
4 |
5 | export function getRandomId() {
6 | return Math.random()
7 | .toString(36)
8 | .substr(2, 9);
9 | }
10 |
11 | export function getNotificationOptions(options) {
12 | const notification = options;
13 | const { id } = notification;
14 |
15 | notification.id = id || getRandomId();
16 |
17 | return notification;
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordNotifier/notification.css:
--------------------------------------------------------------------------------
1 | .notification-container-root {
2 | position: absolute;
3 | top: 55px;
4 | bottom: 18%;
5 | right: 5px;
6 | z-index: 8000;
7 | margin-bottom: -20px;
8 | width: 25%;
9 | max-width: 350px;
10 | overflow-y: hidden;
11 | }
12 |
13 | .notification-container {
14 | position: absolute;
15 | top: 0;
16 | /*
17 | display: flex;
18 | flex-direction: row;
19 | flex-wrap: wrap-reverse;
20 | align-content: flex-start;
21 | */
22 | width: 100%;
23 | pointer-events: auto;
24 | }
25 |
26 | .notification-default {
27 | background-color: rgba(255, 255, 255, 0.767);
28 | }
29 |
30 | .notification-title {
31 | color: black;
32 | font-weight: bold;
33 | font-size: 120%;
34 | }
35 | .notification-message {
36 | color: rgb(0, 0, 0);
37 | }
38 |
39 | .notification-close span {
40 | color: rgb(0, 0, 0);
41 | }
42 |
43 | .notification-item-root,
44 | .notification-title,
45 | .notification-message,
46 | .notification-item {
47 | font-family: Arial, Helvetica, sans-serif;
48 | }
49 |
50 | .notification-item {
51 | position: relative;
52 | }
53 |
54 | .notification-toolbar {
55 | font-size: 15px;
56 | position: absolute;
57 | right: 10px;
58 | top: 0;
59 | color: rgb(3, 3, 3);
60 | visibility: hidden;
61 | }
62 | .notification-toolbar span {
63 | padding-left: 5px;
64 | }
65 |
66 | .notification-item:hover .notification-toolbar {
67 | visibility: visible;
68 | }
69 |
70 | .nc-center {
71 | top: 50%;
72 | left: 50%;
73 | position: fixed;
74 | z-index: 8000;
75 | pointer-events: all;
76 | }
77 |
78 | .nc-box {
79 | left: -50%;
80 | position: relative;
81 | transform: translateY(-50%);
82 | }
83 |
84 | .notification-item {
85 | display: flex;
86 | position: relative;
87 | border-radius: 3px;
88 | cursor: pointer;
89 | /*box-shadow: 1px 3px 4px rgba(0, 0, 0, 0.2);*/
90 | }
91 |
92 | .notification-container-top-right .notification-item-root,
93 | .notification-container-bottom-right .notification-item-root {
94 | margin-left: auto;
95 | }
96 |
97 | .notification-container-top-left .notification-item-root,
98 | .notification-container-bottom-left .notification-item-root {
99 | margin-right: auto;
100 | }
101 |
102 | .notification-item-root {
103 | width: 100%;
104 | margin-bottom: 5px;
105 | }
106 |
107 | .notification-title {
108 | font-weight: bold;
109 | margin-top: 5px;
110 | margin-bottom: 5px;
111 | }
112 |
113 | .notification-message {
114 | max-width: calc(100% - 15px);
115 | line-height: 150%;
116 | word-wrap: break-word;
117 | margin-bottom: 0;
118 | margin-top: 0;
119 | }
120 |
121 | .notification-invisible {
122 | visibility: hidden;
123 | max-width: 375px;
124 | }
125 |
126 | .notification-visible {
127 | visibility: visible;
128 | }
129 |
130 | .notification-content {
131 | padding: 8px 15px;
132 | display: inline-block;
133 | width: 100%;
134 | }
135 |
--------------------------------------------------------------------------------
/src/app/js/Components/WordNotifier/utils.js:
--------------------------------------------------------------------------------
1 | export function cssWidth(width) {
2 | return width ? `${width}px` : undefined;
3 | }
4 |
5 | export function isNullOrUndefined(prop) {
6 | return prop === null || prop === undefined;
7 | }
8 |
9 | export function isString(object) {
10 | return typeof object === 'string';
11 | }
12 |
13 | export function isNumber(object) {
14 | return typeof object === 'number';
15 | }
16 |
17 | export function isBoolean(object) {
18 | return typeof object === 'boolean';
19 | }
20 |
21 | export function isArray(object) {
22 | return !isNullOrUndefined(object) && object.constructor === Array;
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/js/Containers/App.less:
--------------------------------------------------------------------------------
1 | .ant-form-item {
2 | margin-bottom: 8px !important;
3 | }
4 |
5 | /* antd beyond overrides*/
6 | .beyond-form {
7 | .ant-row {
8 | margin-bottom: 10px;
9 | }
10 |
11 | .ant-select {
12 | width: 100%;
13 | }
14 |
15 | .form-label {
16 | text-align: right;
17 | padding-right: 15px;
18 | }
19 | }
20 |
21 | .ant-modal-body,
22 | .ant-modal-header {
23 | padding: 14px;
24 | }
25 |
26 | .byp-center-container {
27 | position: absolute;
28 | height: 100%;
29 | width: 100%;
30 | z-index: 9;
31 | pointer-events: none;
32 | background-image: url('../Images/back.jpg');
33 | background-size: cover;
34 | }
35 |
36 | .byp-clear-background {
37 | background-image: none;
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/js/Images/back.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/js/Images/back.jpg
--------------------------------------------------------------------------------
/src/app/js/Images/book.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/equalizer.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/loop.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/pm_auto_pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/js/Images/pm_auto_repeat.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/js/Images/pm_repeat.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/js/Images/pm_sequence.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/js/Images/sub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/app/js/Images/sub.png
--------------------------------------------------------------------------------
/src/app/js/Images/switches.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Images/tutorial.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/js/Model/Caption/addic7ed.js:
--------------------------------------------------------------------------------
1 | const addic7ed = require('addic7ed-api');
2 |
3 | const download = async (item, path) => {
4 | return addic7ed.download(item.subInfo, path);
5 | };
6 |
7 | const transform = (query, items) =>
8 | items.map(item => ({
9 | name: `${query}.${item.distribution}.${item.team}`,
10 | subInfo: item,
11 | extention: '',
12 | source: 'addic7ed',
13 | size: '',
14 | score: 0,
15 | download
16 | }));
17 |
18 | const textSearch = async (query, language, limit) => {
19 | const splitQuery = query.match(/s([0-9]{1,2})\s*e([0-9]{1,2}.*)/i);
20 |
21 | if (!splitQuery) {
22 | console.log(`Addic7ed: Can't parse ${query}...`);
23 | return [];
24 | }
25 |
26 | let serie = query.replace(splitQuery[0], '');
27 | serie = serie.replace(/\./g, ' ');
28 | const season = parseInt(splitQuery[1], 10);
29 | const episode = parseInt(splitQuery[2], 10);
30 |
31 | let items = [];
32 |
33 | try {
34 | items = await addic7ed.search(serie, season, episode, language);
35 |
36 | if (!items) {
37 | console.log('Addic7ed: Nothing found...');
38 | return [];
39 | }
40 | return transform(query, items);
41 | } catch (e) {
42 | alert('Unable to retrieve data from addic7ed:' + e);
43 | }
44 |
45 | return [];
46 | };
47 |
48 | //TODO remove it
49 | const fileSearch = async () => {};
50 |
51 | export default {
52 | textSearch,
53 | fileSearch,
54 | download
55 | };
56 |
--------------------------------------------------------------------------------
/src/app/js/Model/Caption/index.js:
--------------------------------------------------------------------------------
1 | const Promise = require('bluebird');
2 | import addic7ed from './addic7ed';
3 | import opensubtitles from './opensubtitles';
4 | import path from 'path-extra';
5 | import fs from 'fs-extra';
6 |
7 | class Caption {
8 | sources;
9 | constructor() {
10 | this.sources = [opensubtitles, addic7ed];
11 | }
12 |
13 | getLocalSubtitles = movieFile => {
14 | let dirName = path.dirname(movieFile);
15 | let idxFilePath = path.replaceExt(movieFile, '.kidx');
16 | console.log(`idxFilePath: ${idxFilePath}`);
17 |
18 | if (!fs.existsSync(idxFilePath)) {
19 | fs.closeSync(fs.openSync(idxFilePath, 'w'));
20 | }
21 | let subFiles = fs
22 | .readFileSync(idxFilePath)
23 | .toString()
24 | .split('\n');
25 |
26 | subFiles = subFiles.filter(subFile => subFile);
27 |
28 | subFiles = subFiles.map(subFile => {
29 | if (!subFile.startsWith('/')) {
30 | return path.join(dirName, subFile);
31 | }
32 | return subFile;
33 | });
34 | return subFiles;
35 | };
36 |
37 | searchByQuery = async (query, language = 'eng', limit = 10) => {
38 | if (language == 'eng') {
39 | query = query.replace(/[^\x00-\x7F]/g, '');
40 | query = query.replace(/\.\.\./g, '.');
41 | query = query.replace(/\.\./g, '.');
42 | query = query.replace(/^\./g, '');
43 | }
44 |
45 | const checkSources = this.sources.map(source => {
46 | try {
47 | return source.textSearch(query, language, limit);
48 | } catch (e) {
49 | alert(e);
50 | return [];
51 | }
52 | });
53 |
54 | let all = await Promise.all(checkSources);
55 | let results = [];
56 | for (let r of all) {
57 | results = results.concat(r);
58 | }
59 | return results;
60 | };
61 |
62 | searchByFiles(files, language = 'eng', limit = 10) {
63 | const opensubtitlesRef = opensubtitles.fileSearch(files, language, limit);
64 |
65 | return {
66 | on(event, callback) {
67 | switch (event) {
68 | case 'completed':
69 | default:
70 | // First promise which is resolved should return its results
71 | Promise.race([opensubtitlesRef]).then(results => callback(results));
72 |
73 | return this;
74 | }
75 | }
76 | };
77 | }
78 |
79 | download = async (item, source, filename) => {
80 | switch (source) {
81 | case 'opensubtitles':
82 | return await opensubtitles.download(item, filename);
83 | case 'addic7ed':
84 | return await addic7ed.download(item, filename);
85 | }
86 | };
87 | }
88 |
89 | export default new Caption();
90 |
--------------------------------------------------------------------------------
/src/app/js/Model/Caption/opensubtitles.js:
--------------------------------------------------------------------------------
1 | import OS from 'opensubtitles-api';
2 | import { head } from 'lodash';
3 | import zlib from 'zlib';
4 | import fse from 'fs-extra';
5 | import bluebird from 'bluebird';
6 | import iconvlite from 'iconv-lite';
7 | import got from 'got';
8 |
9 | const zlibUnzip = bluebird.promisify(zlib.unzip);
10 |
11 | const OpenSubtitles = new OS({
12 | useragent: 'EMPlayer v1',
13 | ssl: true
14 | });
15 |
16 | const download = async (item, path) => {
17 | const response = await got(item.downloadUrl, { encoding: null });
18 | const unzipped = await zlibUnzip(response.body);
19 | const subtitleContent = iconvlite.decode(unzipped, item.encoding);
20 | await fse.writeFile(path, subtitleContent, 'utf8');
21 | };
22 |
23 | const transform = items =>
24 | items.map(({ filename, url, encoding, score }) => ({
25 | score,
26 | download,
27 | encoding,
28 | name: filename,
29 | downloadUrl: url,
30 | extention: '',
31 | source: 'opensubtitles',
32 | size: ''
33 | }));
34 |
35 | const textSearch = async (query, language, limit) => {
36 | const options = {
37 | sublanguageid: language,
38 | limit,
39 | query,
40 | gzip: true
41 | };
42 |
43 | let items = [];
44 |
45 | try {
46 | items = await OpenSubtitles.search(options);
47 |
48 | if (!items) {
49 | console.log(`Opensubtitles: Nothing found...`);
50 | return [];
51 | }
52 |
53 | const firstItem = head(Object.keys(items)); // firstItem is selected language: obj[language]
54 | const results = items[firstItem];
55 |
56 | if (!results) return [];
57 |
58 | return transform(results);
59 | } catch (e) {
60 | alert('Unable to retrieve data from OpenSubtitle: ' + e);
61 | }
62 | return items;
63 | };
64 |
65 | const fileSearch = async (files, language, limit) => {
66 | const subtitleReferences = files.map(async file => {
67 | const info = await OpenSubtitles.identify({
68 | path: file.path,
69 | extend: true
70 | });
71 |
72 | const options = {
73 | limit,
74 | sublanguageid: language,
75 | hash: info.moviehash,
76 | filesize: info.moviebytesize,
77 | path: file.path,
78 | filename: file.filename,
79 | imdbid: null,
80 | gzip: true
81 | };
82 |
83 | if (info && info.metadata && info.metadata.imdbid) {
84 | options['imdbid'] = info.metadata.imdbid;
85 | }
86 |
87 | const result = await OpenSubtitles.search(options);
88 | const firstItem = head(Object.keys(result));
89 | const subtitle = result[firstItem];
90 |
91 | return {
92 | file,
93 | subtitle
94 | };
95 | });
96 |
97 | const downloadedReferences = await Promise.all(subtitleReferences);
98 | const subtitleResults = downloadedReferences.filter(({ subtitle }) => subtitle !== undefined);
99 |
100 | return subtitleResults;
101 | };
102 |
103 | export default {
104 | textSearch,
105 | fileSearch,
106 | download
107 | };
108 |
--------------------------------------------------------------------------------
/src/app/js/Model/Dictionary/index.js:
--------------------------------------------------------------------------------
1 | import say from 'say';
2 | import path from 'path-extra';
3 | import { exec } from 'child_process';
4 | import open from 'open';
5 | import { remote } from 'electron';
6 | import Settings from '../Settings';
7 |
8 | class Dictionary {
9 | lookup = async (word, callback) => {
10 | var appPath = remote.app.getAppPath();
11 | var exePath = path.join(appPath, 'etc', 'lookup', 'osx-lookup').replace('app.asar', 'app.asar.unpacked');
12 | exec(`"${exePath}" "${word}"`, (err, stdout, stderr) => {
13 | if (err) {
14 | console.log('not able to execute osx-dictionary:' + err);
15 | callback(null);
16 | return;
17 | }
18 |
19 | console.log(`stdout: ${stdout}`);
20 | console.log(`stderr: ${stderr}`);
21 |
22 | callback(stdout, stderr);
23 |
24 | /*
25 | var items = JSON.parse(stdout);
26 | items = items.filter(item=> item.definition);
27 | items.forEach(item => {
28 | item.definition = this.parseDefinition(item.definition)
29 | });
30 | callback(items);
31 | */
32 | });
33 | };
34 |
35 | parseDefinition(item) {
36 | var lines = item.split(/\s+(\d+|•)\s+/);
37 | console.log(lines);
38 | return lines;
39 | }
40 |
41 | showDicWindow = word => {
42 | if (Settings.getSettings(Settings.SKEY_EXT_DIC) == Settings.EXD_APPLE_DIC) {
43 | open(`dict://${word}`);
44 | } else if (Settings.getSettings(Settings.SKEY_EXT_DIC) == Settings.EXD_EUDIC) {
45 | open(`eudic://dict/${word}`);
46 | }
47 | };
48 |
49 | pronounce = (word, voice) => {
50 | say.stop();
51 | if (voice) {
52 | say.speak(word, voice);
53 | } else {
54 | var voiceFromSetttings = Settings.getSettings(Settings.SKEY_VOICE);
55 | if (voiceFromSetttings) {
56 | say.speak(word, voiceFromSetttings);
57 | } else if (voiceFromSetttings != 'Off') {
58 | say.speak(word);
59 | }
60 | }
61 | };
62 |
63 | pronounceDefault = word => {
64 | say.stop();
65 | say.speak(word);
66 | };
67 | }
68 |
69 | export default new Dictionary();
70 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/destinations/anki/AnkiFileDestination.js:
--------------------------------------------------------------------------------
1 | import { promises as fs } from 'fs';
2 | import AnkiExport from 'anki-apkg-export';
3 |
4 | function interpolateTemplate(params) {
5 | const names = Object.keys(params);
6 | const values = Object.values(params);
7 |
8 | return new Function(...names, `return \`${this}\`;`)(...values);
9 | }
10 |
11 | class AnkiFileDestination {
12 | constructor({ file, deckName, frontTemplate, backTemplate }) {
13 | this.file = file;
14 | this.deckName = deckName;
15 | this.backTemplate = backTemplate.replace(/{{/g, '${').replace(/}}/g, '}');
16 | this.frontTemplate = frontTemplate.replace(/{{/g, '${').replace(/}}/g, '}');
17 | }
18 |
19 | export = async source => {
20 | const apkgDeck = new AnkiExport(this.deckName);
21 | const backTemplate = interpolateTemplate.bind(this.backTemplate);
22 | const frontTemplate = interpolateTemplate.bind(this.frontTemplate);
23 | const isIncludeVideo = this.backTemplate.includes('frontVideo') || this.frontTemplate.includes('frontVideo');
24 | const isIncludePreview = this.backTemplate.includes('frontPreview') || this.frontTemplate.includes('frontPreview');
25 |
26 | for (const item of source) {
27 | const { id, frontVideo, frontPreview } = item;
28 |
29 | if (isIncludeVideo) {
30 | const frontVideoContent = await fs.readFile(frontVideo);
31 | apkgDeck.addMedia(`${id}.mp4`, frontVideoContent);
32 |
33 | item.frontVideo = `${id}.mp4`;
34 | }
35 |
36 | if (isIncludePreview) {
37 | const frontVideoContent = await fs.readFile(frontPreview);
38 | apkgDeck.addMedia(`${id}.png`, frontVideoContent);
39 |
40 | item.frontPreview = `${id}.png`;
41 | }
42 |
43 | apkgDeck.addCard(frontTemplate(item), backTemplate(item));
44 | }
45 |
46 | const zipFile = await apkgDeck.save();
47 |
48 | await fs.writeFile(this.file, zipFile, 'binary');
49 | };
50 | }
51 |
52 | export { AnkiFileDestination };
53 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/destinations/anki/index.js:
--------------------------------------------------------------------------------
1 | export { AnkiFileDestination } from './AnkiFileDestination';
2 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/index.js:
--------------------------------------------------------------------------------
1 | export { VidLibExportSourceAdapter } from './sources/VidLibExportSourceAdapter';
2 | export { AnkiFileDestination } from './destinations/anki';
3 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/readme.md:
--------------------------------------------------------------------------------
1 | This folder contains two entities: sources and destinations;
2 |
3 | Source can be a vidlib or for example a word book.
4 | Destination can be anki or Apple Notes.
5 |
6 | Source should be a generator which returns source items
7 | Source item structure is:
8 |
9 | ```
10 | {
11 | id,
12 | frontVideo?, // absolute video file path
13 | frontPreview?, // absolute image file path
14 | frontText, // front card text
15 | backText // back card text,
16 | tags?: []
17 | }
18 | ```
19 |
20 | Destination is a class which has an export method with a source as a param.
21 | This approach allows us to combine different sources and different destinations.
22 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/sources/VidLibExportSourceAdapter.js:
--------------------------------------------------------------------------------
1 | const lines2Text = lines =>
2 | lines
3 | .reduce((result, line) => {
4 | return `${result}${line.text}\n`;
5 | }, '')
6 | .replace(/\n$/, '');
7 |
8 | const vid2SourceItem = viItem => {
9 | const { id, thumbnail: frontPreview, vid: frontVideo, lines = [], lines2 = [], tags } = viItem;
10 |
11 | const frontText = lines2Text(lines);
12 | const backText = lines2Text(lines2);
13 |
14 | return {
15 | id,
16 | frontVideo,
17 | frontPreview,
18 | frontText,
19 | backText,
20 | tags
21 | };
22 | };
23 |
24 | class VidLibExportSourceAdapter {
25 | constructor({ vidLib, filter = () => true }) {
26 | this.vidLib = vidLib;
27 | this.filter = filter;
28 | }
29 |
30 | *[Symbol.iterator]() {
31 | const ids = this.vidLib.retrieveAll();
32 |
33 | for (let index = 0; index < ids.length; index += 1) {
34 | const sourceItem = this.vidLib.genVidInfo(ids[index]);
35 | const resultItem = vid2SourceItem(sourceItem);
36 |
37 | if (this.filter(resultItem)) {
38 | yield resultItem;
39 | }
40 | }
41 | }
42 |
43 | static getOutputFields() {
44 | return ['id', 'frontVideo', 'frontPreview', 'frontText', 'tags'];
45 | }
46 | }
47 |
48 | export { VidLibExportSourceAdapter };
49 |
--------------------------------------------------------------------------------
/src/app/js/Model/Export/sources/__tests__/VidLibExportSourceAdapter.spec.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker';
2 | import { VidLibExportSourceAdapter } from '../VidLibExportSourceAdapter';
3 |
4 | describe('VidLibExportSourceAdapter specs', () => {
5 | const createVidLibItem = () => ({
6 | id: faker.random.alphaNumeric(),
7 | thumbnail: faker.system.filePath(),
8 | vid: faker.system.filePath(),
9 | tags: [],
10 | lines: [{ text: faker.lorem.sentence() }]
11 | });
12 |
13 | const createVidLib = items => ({
14 | retrieveAll: () => items.map(item => item.id),
15 | genVidInfo: id => {
16 | if (id === items[0].id) {
17 | return items[0];
18 | } else if (id === items[1].id) {
19 | return items[1];
20 | }
21 |
22 | throw new Error();
23 | }
24 | });
25 |
26 | const expectMatchObject = (resultItem, vidItem) => {
27 | expect(resultItem).toMatchObject({
28 | id: vidItem.id,
29 | frontVideo: vidItem.vid,
30 | frontPreview: vidItem.thumbnail,
31 | frontText: vidItem.lines[0].text
32 | });
33 | };
34 |
35 | it('should convert vidLib items to the source items', () => {
36 | const vidLibItems = [createVidLibItem(), createVidLibItem()];
37 | const vidLib = createVidLib(vidLibItems);
38 |
39 | const vidAdapter = new VidLibExportSourceAdapter({ vidLib });
40 | const result = [...vidAdapter];
41 |
42 | expect(result.length).toBe(2);
43 | expectMatchObject(result[0], vidLibItems[0]);
44 | expectMatchObject(result[1], vidLibItems[1]);
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/app/js/Model/FFmpegHelper/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import util from 'util';
3 | import log from 'electron-log';
4 | import FFmpeg from 'fluent-ffmpeg';
5 | import { remote } from 'electron';
6 |
7 | const isDev = require('electron-is-dev');
8 | const exec = util.promisify(require('child_process').exec);
9 |
10 | export const getFFmpegExePath = exeName => {
11 | let exePath;
12 | if (isDev) {
13 | const appPath = path.join(remote.app.getAppPath(), '..');
14 | exePath = path.join(appPath, 'scripts', 'dist', exeName);
15 | } else {
16 | const appPath = remote.app.getAppPath();
17 | exePath = path.join(appPath, 'node_modules', 'mpv.js', 'build', 'Release', exeName);
18 | }
19 | return exePath.replace('app.asar', 'app.asar.unpacked');
20 | };
21 |
22 | export const initFFmpegPath = () => {
23 | const ffmpegPath = getFFmpegExePath('ffmpeg');
24 | const ffprobePath = getFFmpegExePath('ffprobe');
25 |
26 | log.info(`Init ffmpeg path:${ffmpegPath}`);
27 | log.info(`Init ffprobe path:${ffprobePath}`);
28 |
29 | FFmpeg.setFfmpegPath(ffmpegPath);
30 | FFmpeg.setFfprobePath(ffprobePath);
31 | };
32 |
33 | export const convertToSrt = async subtitleFile => {
34 | const baseName = path.basename(subtitleFile);
35 | const tempFile = path.join(remote.app.getPath('temp'), `${baseName}.srt`);
36 | const command = `"${getFFmpegExePath('ffmpeg')}" -y -i "${subtitleFile}" "${tempFile}"`;
37 |
38 | log.info(`Execute Command: ${command}`);
39 |
40 | try {
41 | const r = await exec(command);
42 | if (r.error) throw r.error;
43 | } catch (e) {
44 | log.error(`error on converting ${subtitleFile} to srt file: ${e}`);
45 | return '';
46 | }
47 |
48 | return tempFile;
49 | };
50 |
--------------------------------------------------------------------------------
/src/app/js/Model/LRCHelper/index.js:
--------------------------------------------------------------------------------
1 | import parser from 'lrc-parser';
2 |
3 | class LRCHelper {
4 | parseLRC = str => {
5 | const lrc = parser(str);
6 | lrc.scripts.forEach(line => {
7 | line.start = line.start * 1000;
8 | line.end = line.end * 1000;
9 | });
10 | return lrc.scripts;
11 | };
12 | }
13 |
14 | export default new LRCHelper();
15 |
--------------------------------------------------------------------------------
/src/app/js/Model/MRUFiles/index.js:
--------------------------------------------------------------------------------
1 | import storage from 'electron-json-storage';
2 | import _ from 'lodash';
3 |
4 | class MRUFiles {
5 | KEY = 'mru';
6 | MAX_COUNT = 20;
7 | files = [];
8 |
9 | load = callback => {
10 | storage.get(this.KEY, (error, data) => {
11 | if (error) throw error;
12 |
13 | if (_.isEmpty(data)) {
14 | this.files = [];
15 | } else {
16 | this.files = data;
17 | }
18 | callback(this.files);
19 | });
20 | };
21 |
22 | add = file => {
23 | if (!this.files || _.isEmpty(this.files)) {
24 | this.files = [];
25 | }
26 |
27 | let index = this.files.findIndex(item => {
28 | if (_.isString(item)) {
29 | return item == file;
30 | } else {
31 | return item.url == file.url;
32 | }
33 | });
34 |
35 | if (index != -1) {
36 | this.files.splice(index, 1);
37 | this.files.unshift(file);
38 | } else {
39 | this.files.unshift(file);
40 | if (this.files.length > this.MAX_COUNT) {
41 | this.files.pop();
42 | }
43 | }
44 |
45 | console.log(this.files);
46 |
47 | storage.set(this.KEY, this.files, function(error) {
48 | if (error) throw error;
49 | });
50 | };
51 | }
52 |
53 | export default new MRUFiles();
54 |
--------------------------------------------------------------------------------
/src/app/js/Model/MansonryLayout/index.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events';
2 |
3 | export default class MansonryLayout extends EventEmitter {
4 | options;
5 | container;
6 |
7 | columnCount;
8 | columnHeights = void 0;
9 |
10 | persist = void 0; // packing new elements, or all elements?
11 |
12 | nodes = void 0;
13 | nodesWidths = void 0;
14 | nodesHeights = void 0;
15 |
16 | packed;
17 |
18 | constructor(options) {
19 | super();
20 | this.options = options;
21 | this.container = this.options.container.nodeType ? this.options.container : document.querySelector(this.options.container);
22 | this.packed = this.options.packed.indexOf('data-') === 0 ? this.options.packed : 'data-' + this.options.packed;
23 | this.size = this.options.size;
24 | this.position = this.options.position !== false;
25 | }
26 |
27 | selectors = {
28 | all: () => {
29 | return this.toArray(this.container.children);
30 | },
31 | new: () => {
32 | return this.toArray(this.container.children).filter(node => {
33 | return !node.hasAttribute('' + this.packed);
34 | });
35 | }
36 | };
37 |
38 | runSeries = functions => {
39 | functions.forEach(function(func) {
40 | return func();
41 | });
42 | };
43 |
44 | toArray = input => {
45 | return Array.prototype.slice.call(input);
46 | };
47 |
48 | fillArray = length => {
49 | return Array.apply(null, Array(length)).map(function() {
50 | return 0;
51 | });
52 | };
53 |
54 | setupColumns = () => {
55 | this.columnCount = Math.floor(this.container.clientWidth / (this.size.columnWidth + this.size.gap));
56 | this.columnHeights = this.fillArray(this.columnCount);
57 |
58 | this.margin = (this.container.clientWidth - this.columnCount * this.size.columnWidth - (this.columnCount - 1) * this.size.gap) / 2.0;
59 | };
60 |
61 | setupNodes = () => {
62 | this.nodes = this.selectors[this.persist ? 'new' : 'all']();
63 | };
64 |
65 | setupNodesDimensions = () => {
66 | if (this.nodes.length === 0) return;
67 |
68 | this.nodesWidths = this.nodes.map(function(element) {
69 | return element.clientWidth;
70 | });
71 | this.nodesHeights = this.nodes.map(function(element) {
72 | return element.clientHeight;
73 | });
74 | };
75 |
76 | setupNodesStyles = () => {
77 | this.nodes.forEach((element, index) => {
78 | //alert(Math.min.apply(Math, this.columnHeights));
79 | //const columnIndex = this.columnHeights.indexOf(Math.min.apply(Math, this.columnHeights));
80 | const columnIndex = index % this.columnHeights.length;
81 |
82 | element.style.position = 'absolute';
83 |
84 | const nodeTop = this.columnHeights[columnIndex] + 'px';
85 |
86 | //alert(columnIndex)
87 | const nodeLeft = this.margin + columnIndex * this.size.columnWidth + columnIndex * this.size.gap + 'px';
88 |
89 | //alert(nodeLeft);
90 |
91 | element.style.top = nodeTop;
92 | element.style.left = nodeLeft;
93 | element.setAttribute(this.packed, '');
94 |
95 | // ignore nodes with no width and/or height
96 | const nodeWidth = this.nodesWidths[index];
97 | const nodeHeight = this.nodesHeights[index];
98 |
99 | if (nodeWidth && nodeHeight) {
100 | this.columnHeights[columnIndex] += nodeHeight + this.size.vgap;
101 | }
102 | });
103 | };
104 |
105 | // API
106 | pack = () => {
107 | this.persist = false;
108 | this.runSeries(this.setup.concat(this.run));
109 | return this.emit('pack');
110 | };
111 |
112 | update = () => {
113 | this.persist = true;
114 | this.runSeries(this.run);
115 | return this.emit('update');
116 | };
117 |
118 | setup = [this.setupColumns];
119 | run = [this.setupNodes, this.setupNodesDimensions, this.setupNodesStyles];
120 | }
121 |
--------------------------------------------------------------------------------
/src/app/js/Model/PlayMode.js:
--------------------------------------------------------------------------------
1 | export default Object.freeze({
2 | NORMAL: 0,
3 | AUTO_REPEAT: 1,
4 | AUTO_PAUSE: 2
5 | });
6 |
--------------------------------------------------------------------------------
/src/app/js/Model/SubExtractor/index.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import Ffmpeg from 'fluent-ffmpeg';
4 |
5 | const fileExists = require('file-exists').sync;
6 |
7 | const streamToString = (stream, enc) => {
8 | let str = '';
9 | return new Promise((resolve, reject) => {
10 | stream.on('data', data => {
11 | str += typeof enc === 'string' ? data.toString(enc) : data.toString();
12 | });
13 |
14 | stream.on('end', () => resolve(str));
15 | stream.on('error', reject);
16 | });
17 | };
18 |
19 | export const subtitleExtractor = (filePath, outputDir, progressCallback) => {
20 | const dir = outputDir || path.dirname(filePath);
21 | const name = path.basename(filePath, path.extname(filePath));
22 | const srtPath = language => {
23 | const languageSuffix = language ? `.${language}` : '';
24 | return path.join(dir, `${name + languageSuffix}.srt`);
25 | };
26 | return new Promise((resolve, reject) =>
27 | Ffmpeg({ source: filePath }).ffprobe(async (err, { streams }) => {
28 | if (err) {
29 | return reject(err);
30 | }
31 |
32 | const subtitles = streams.filter(
33 | ({ codec_type, codec_name }) => codec_type === 'subtitle' && !codec_name.match(/.*_pgs_*.|dvd_subtitle/i)
34 | );
35 | const result = [];
36 |
37 | try {
38 | for (const subtitleItem of subtitles) {
39 | const { index, tags = {} } = subtitleItem;
40 | const language = tags.language || tags.LANGUAGE || index;
41 | // const title = tags.title || '';
42 | let text = await new Promise((subtitleResolve, subtitleReject) => {
43 | let subtitleText;
44 |
45 | const stream = Ffmpeg({ source: filePath })
46 | .outputOptions(`-map 0:${index}`)
47 | .format('srt')
48 | .on('error', subtitleReject)
49 | .on('end', () => subtitleResolve(subtitleText))
50 | .pipe(undefined, { end: true });
51 |
52 | subtitleText = streamToString(stream);
53 | });
54 |
55 | text = text.replace(/\<[^>]*\>/g, '');
56 | let subtitlePath = srtPath(language);
57 | for (let i = 2; fileExists(subtitlePath); i += 1) {
58 | subtitlePath = language ? srtPath(language + i) : srtPath(i);
59 | }
60 | fs.writeFileSync(subtitlePath, text, { encoding: 'utf-8' });
61 | const item = {
62 | number: index,
63 | path: subtitlePath,
64 | language
65 | };
66 | result.push(item);
67 | setTimeout(() => {
68 | progressCallback(item, result.length - 1);
69 | }, 0);
70 | }
71 | } catch (error) {
72 | return reject(error);
73 | }
74 |
75 | resolve(result);
76 | })
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/app/js/Model/SubStore/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path-extra';
2 | import { remote, shell } from 'electron';
3 | import md5 from 'md5';
4 | import fs from 'fs-extra';
5 | const i18n = remote.require('./i18n');
6 | import { deleteFolderRecursiveSync, parseLinesFromFile } from '../KUtils';
7 |
8 | class SubStore {
9 | basePath;
10 |
11 | constructor() {
12 | this.basePath = path.join(remote.app.getPath('userData'), 'subtitles');
13 | fs.ensureDirSync(this.basePath);
14 | }
15 |
16 | getStorePath(filePath) {
17 | var hash = md5(filePath);
18 | console.log(`hash: ${hash}`);
19 | return path.join(this.basePath, hash);
20 | }
21 |
22 | createStoreFolder(filePath) {
23 | var folderPath = this.getStorePath(filePath);
24 | fs.ensureDirSync(folderPath);
25 | }
26 |
27 | saveTimePos = (movieUrl, timePos, timeOffset, secondTimeOffset, subtitle, secondSubtitle) => {
28 | const storePath = this.getStorePath(movieUrl);
29 | fs.ensureDirSync(storePath);
30 |
31 | const timePosFilePath = this.getStoreTimePosFile(movieUrl);
32 |
33 | const obj = {
34 | timePos,
35 | subtitle,
36 | secondSubtitle,
37 | timeOffset,
38 | secondTimeOffset
39 | };
40 | fs.writeFileSync(timePosFilePath, JSON.stringify(obj));
41 | };
42 |
43 | getStoreTimePosFile = movieUrl => {
44 | let basename = path.basename(movieUrl);
45 | let storePath = this.getStorePath(movieUrl);
46 | console.log(`storePath: ${storePath}`);
47 | let timePosFilePath = path.join(storePath, basename + '.tps');
48 | console.log(`timePosFilePath: ${timePosFilePath}`);
49 | return timePosFilePath;
50 | };
51 |
52 | getLocalTimePos = movieUrl => {
53 | if (movieUrl.startsWith('http')) return { timePos: 0, subtitle: '', timeOffset: 0 };
54 |
55 | let timePosFilePath = this.getStoreTimePosFile(movieUrl);
56 |
57 | if (!fs.existsSync(timePosFilePath)) {
58 | fs.closeSync(fs.openSync(timePosFilePath, 'w'));
59 | }
60 | let obj = {};
61 | try {
62 | obj = JSON.parse(fs.readFileSync(timePosFilePath).toString());
63 | } catch (e) {}
64 |
65 | if (!obj.timePos) {
66 | obj.timePos = 0;
67 | }
68 | if (!obj.subtitle) {
69 | obj.subtitle = '';
70 | }
71 | if (!obj.secondSubtitle) {
72 | obj.secondSubtitle = '';
73 | }
74 | if (!obj.timeOffset) {
75 | obj.timeOffset = 0;
76 | }
77 | if (!obj.secondTimeOffset) {
78 | obj.secondTimeOffset = 0;
79 | }
80 | return obj;
81 | };
82 |
83 | clearSubtitles = (movieUrl, callback) => {
84 | if (!movieUrl || movieUrl.startsWith('http')) return;
85 |
86 | let storePath = this.getStorePath(movieUrl);
87 | if (fs.existsSync(storePath)) {
88 | var result = confirm(i18n.t('confirm.clear.subtitles'));
89 | if (result) {
90 | deleteFolderRecursiveSync(storePath);
91 | if (callback) callback();
92 | }
93 | }
94 | };
95 |
96 | getLocalSubtitles = movieUrl => {
97 | if (movieUrl.startsWith('http')) return [];
98 |
99 | let storePath = this.getStorePath(movieUrl);
100 | if (!fs.existsSync(storePath)) {
101 | fs.mkdirpSync(storePath);
102 | }
103 |
104 | let subFiles = fs
105 | .readdirSync(storePath)
106 | .filter(file => {
107 | var lower = file.toLocaleLowerCase();
108 | return lower.endsWith('srt') || lower.endsWith('lrc');
109 | })
110 | .map(file => path.join(storePath, file));
111 |
112 | subFiles.unshift('None');
113 | return subFiles;
114 | };
115 |
116 | addExternalSubtitle = (movieUrl, subtitleFileName) => {
117 | this.createStoreFolder(movieUrl);
118 | let storePath = path.join(this.getStorePath(movieUrl), path.basename(subtitleFileName));
119 | fs.copySync(subtitleFileName, storePath);
120 | };
121 |
122 | revealSubtitleFolder = movieUrl => {
123 | if (!movieUrl || movieUrl.startsWith('http')) return;
124 |
125 | let storePath = this.getStorePath(movieUrl);
126 | if (fs.existsSync(storePath)) {
127 | shell.openItem(storePath);
128 | }
129 | };
130 | }
131 |
132 | export default new SubStore();
133 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/index.js:
--------------------------------------------------------------------------------
1 | export { default as toMS } from './toMS';
2 | export { default as toSrtTime } from './toSrtTime';
3 | export { default as toVttTime } from './toVttTime';
4 | export { default as parse } from './parse';
5 | export { default as stringify } from './stringify';
6 | export { default as stringifyVtt } from './stringifyVtt';
7 | export { default as resync } from './resync';
8 | export { default as parseTimestamps } from './parseTimestamps';
9 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/parse.js:
--------------------------------------------------------------------------------
1 | import parseTimestamps from './parseTimestamps';
2 | /**
3 | * Parse a SRT or WebVTT string.
4 | * @param {String} srtOrVtt
5 | * @return {Array} subtitles
6 | */
7 |
8 | export default function parse(srtOrVtt) {
9 | if (!srtOrVtt) return [];
10 |
11 | const source = srtOrVtt
12 | .trim()
13 | .concat('\n')
14 | .replace(/\r\n/g, '\n')
15 | .replace(/\\N/g, '\n')
16 | .replace(/\n{3,}/g, '\n\n')
17 | .replace(/^WEBVTT.*\n(?:.*: .*\n)*\n/, '')
18 | .replace(//g, '')
19 | .replace(/<\/?b>/g, '')
20 | .replace(/<\/font>/g, '')
21 | .split('\n');
22 |
23 | let lines = source.reduce(
24 | (captions, row, index) => {
25 | const caption = captions[captions.length - 1];
26 |
27 | if (!caption.index) {
28 | if (/^-?\d+$/.test(row)) {
29 | caption.index = parseInt(row, 10);
30 | return captions;
31 | }
32 | }
33 |
34 | if (!caption.hasOwnProperty('start')) {
35 | let timeObj;
36 | try {
37 | timeObj = parseTimestamps(row);
38 | } catch (ex) {
39 | console.log('Unable to parse row: ' + row);
40 | console.log('ex:' + ex);
41 | timeObj = { start: 0, end: 0 };
42 | }
43 | Object.assign(caption, timeObj);
44 | return captions;
45 | }
46 |
47 | if (row === '') {
48 | delete caption.index;
49 | if (index !== source.length - 1) {
50 | captions.push({});
51 | }
52 | } else {
53 | row = row.replace(/\{\\.+?\}/g, '');
54 | caption.text = caption.text ? caption.text + '\n' + row : row;
55 | }
56 |
57 | return captions;
58 | },
59 | [{}]
60 | );
61 |
62 | let prevStart, prevEnd;
63 | lines = lines.reduce((result, line, index) => {
64 | if (index != 0) {
65 | if (line.start == prevStart && line.end == prevEnd) {
66 | result[result.length - 1].text += '\n' + line.text;
67 | } else {
68 | result.push(line);
69 | }
70 | } else {
71 | result.push(line);
72 | }
73 | prevStart = line.start;
74 | prevEnd = line.end;
75 | return result;
76 | }, []);
77 |
78 | return lines;
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/parseTimestamps.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import toMS from './toMS';
6 |
7 | /**
8 | * Timestamp regex
9 | * @type {RegExp}
10 | */
11 |
12 | const RE = /^((?:\d{2,}:)?\d{2}:\d{2}[,.]\d{2}\d?) --> ((?:\d{2,}:)?\d{2}:\d{2}[,.]\d{2}\d?)(?: (.*))?$/;
13 |
14 | /**
15 | * parseTimestamps
16 | * @param value
17 | * @returns {{start: Number, end: Number}}
18 | */
19 |
20 | export default function parseTimestamps(value) {
21 | const match = RE.exec(value);
22 | const cue = {
23 | start: toMS(match[1]),
24 | end: toMS(match[2])
25 | };
26 | if (match[3]) {
27 | cue.settings = match[3];
28 | }
29 | return cue;
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/resync.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import toMS from './toMS';
6 |
7 | /**
8 | * Resync the given subtitles.
9 | * @param captions
10 | * @param time
11 | * @returns {Array|*}
12 | */
13 |
14 | export default function resync(captions, time) {
15 | return captions.map(caption => {
16 | const start = toMS(caption.start) + time;
17 | const end = toMS(caption.end) + time;
18 |
19 | return Object.assign({}, caption, {
20 | start,
21 | end
22 | });
23 | });
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/stringify.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import toSrtTime from './toSrtTime';
6 |
7 | /**
8 | * Stringify the given array of captions.
9 | * @param {Array} captions
10 | * @return {String} srt
11 | */
12 |
13 | export default function stringify(captions) {
14 | return (
15 | captions
16 | .map((caption, index) => {
17 | return (index > 0 ? '\n' : '') + [index + 1, `${toSrtTime(caption.start)} --> ${toSrtTime(caption.end)}`, caption.text].join('\n');
18 | })
19 | .join('\n') + '\n'
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/stringifyVtt.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import toVttTime from './toVttTime';
6 |
7 | /**
8 | * Stringify the given array of captions to WebVTT format.
9 | * @param {Array} captions
10 | * @return {String} webVtt
11 | */
12 |
13 | export default function stringifyVtt(captions) {
14 | return (
15 | 'WEBVTT\n\n' +
16 | captions
17 | .map((caption, index) => {
18 | return (
19 | (index > 0 ? '\n' : '') +
20 | [
21 | index + 1,
22 | `${toVttTime(caption.start)} --> ${toVttTime(caption.end)}${caption.settings ? ' ' + caption.settings : ''}`,
23 | caption.text
24 | ].join('\n')
25 | );
26 | })
27 | .join('\n') +
28 | '\n'
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/toMS.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Return the given SRT timestamp as milleseconds.
3 | * @param {string|number} timestamp
4 | * @returns {number} milliseconds
5 | */
6 |
7 | export default function toMS(timestamp) {
8 | if (!isNaN(timestamp)) {
9 | return timestamp;
10 | }
11 |
12 | const match = timestamp.match(/^(?:(\d{2,}):)?(\d{2}):(\d{2})[,.](\d{2}\d?)$/);
13 |
14 | if (!match) {
15 | throw new Error('Invalid SRT or VTT time format: "' + timestamp + '"');
16 | }
17 |
18 | const hours = match[1] ? parseInt(match[1], 10) * 3600000 : 0;
19 | const minutes = parseInt(match[2], 10) * 60000;
20 | const seconds = parseInt(match[3], 10) * 1000;
21 | const milliseconds = parseInt(match[4], 10);
22 |
23 | return hours + minutes + seconds + milliseconds;
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/toSrtTime.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import zeroFill from 'zero-fill';
6 |
7 | /**
8 | * Return the given milliseconds as SRT timestamp.
9 | * @param timestamp
10 | * @returns {string}
11 | */
12 |
13 | export default function toSrtTime(timestamp) {
14 | if (isNaN(timestamp)) {
15 | return timestamp;
16 | }
17 |
18 | const date = new Date(0, 0, 0, 0, 0, 0, timestamp);
19 |
20 | const hours = zeroFill(2, date.getHours());
21 | const minutes = zeroFill(2, date.getMinutes());
22 | const seconds = zeroFill(2, date.getSeconds());
23 | const ms = timestamp - (hours * 3600000 + minutes * 60000 + seconds * 1000);
24 |
25 | return `${hours}:${minutes}:${seconds},${zeroFill(3, ms)}`;
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/js/Model/Subtitle/toVttTime.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Module dependencies.
3 | */
4 |
5 | import zeroFill from 'zero-fill';
6 |
7 | /**
8 | * Return the given milliseconds as WebVTT timestamp.
9 | * @param timestamp
10 | * @returns {string}
11 | */
12 |
13 | export default function toVttTime(timestamp) {
14 | if (isNaN(timestamp)) {
15 | return timestamp;
16 | }
17 |
18 | const date = new Date(0, 0, 0, 0, 0, 0, timestamp);
19 |
20 | const hours = zeroFill(2, date.getHours());
21 | const minutes = zeroFill(2, date.getMinutes());
22 | const seconds = zeroFill(2, date.getSeconds());
23 | const ms = timestamp - (hours * 3600000 + minutes * 60000 + seconds * 1000);
24 |
25 | return `${hours}:${minutes}:${seconds}.${zeroFill(3, ms)}`;
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/js/Model/TagSearch/index.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 |
3 | export default class TagSearch {
4 | filePath;
5 | index;
6 |
7 | constructor(filePath) {
8 | this.filePath = filePath;
9 | }
10 |
11 | load = () => {
12 | if (fs.existsSync(this.filePath)) {
13 | this.index = fs.readJSONSync(this.filePath);
14 | } else {
15 | this.index = {};
16 | }
17 | };
18 |
19 | getAllTags = () => {
20 | return Object.keys(this.index);
21 | };
22 |
23 | getAllTagCounts = () => {
24 | let tags = Object.keys(this.index);
25 | let counts = [];
26 | tags.forEach(value => {
27 | let count = this.index[value].length;
28 | if (count > 0) {
29 | counts.push({ value, count });
30 | }
31 | });
32 | return counts;
33 | };
34 |
35 | add = (tag, id, save = false) => {
36 | let idList = [];
37 | if (this.index.hasOwnProperty(tag)) {
38 | idList = this.index[tag];
39 | } else {
40 | this.index[tag] = idList;
41 | }
42 | if (idList.indexOf(id) === -1) {
43 | idList.push(id);
44 | }
45 | if (save) {
46 | this.save();
47 | }
48 | };
49 |
50 | addAll = (tags, id, save = true) => {
51 | tags.forEach(tag => {
52 | this.add(tag, id);
53 | });
54 | if (save) {
55 | this.save();
56 | }
57 | };
58 |
59 | remove = (tag, id, save = false) => {
60 | if (this.index.hasOwnProperty(tag)) {
61 | let idList = this.index[tag];
62 | let index = idList.indexOf(id);
63 | if (index !== -1) {
64 | idList.splice(index, 1);
65 | }
66 | if (idList.length == 0) {
67 | delete this.index[tag];
68 | }
69 | if (save) {
70 | this.save();
71 | }
72 | }
73 | };
74 |
75 | removeAll = (tags, id) => {
76 | tags.forEach(tag => {
77 | this.remove(tag, id);
78 | });
79 | this.save();
80 | };
81 |
82 | findIdsWithTag = tag => {
83 | if (this.index.hasOwnProperty(tag)) {
84 | return this.index[tag];
85 | }
86 | return [];
87 | };
88 |
89 | findIdsWithTags = tags => {
90 | let prevSet;
91 | tags.forEach(tag => {
92 | let ids = this.findIdsWithTag(tag);
93 | let set = new Set(ids);
94 | if (prevSet) {
95 | let intersect = new Set();
96 | prevSet.forEach(x => {
97 | if (set.has(x)) {
98 | intersect.add(x);
99 | }
100 | });
101 | prevSet = intersect;
102 | } else {
103 | prevSet = set;
104 | }
105 | });
106 | if (prevSet) {
107 | return Array.from(prevSet);
108 | }
109 | return [];
110 | };
111 |
112 | save = () => {
113 | fs.writeJSONSync(this.filePath, this.index);
114 | };
115 | }
116 |
--------------------------------------------------------------------------------
/src/app/js/Model/VidLib/VidConverter.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | import util from 'util';
3 | import { remote } from 'electron';
4 | import fs from 'fs-extra';
5 | import path from 'path-extra';
6 | import { getFFmpegExePath } from '../FFmpegHelper';
7 |
8 | const log = require('electron-log');
9 |
10 | const exec = util.promisify(require('child_process').exec);
11 |
12 | class VidConverter {
13 | convert = async (vidPath, startTime, endTime, aid, outPath) => {
14 | // ffmpeg -fflags +genpts -i Girl\,\ Interrupted\ 1999\ DvDrip\[Eng\]-greenbud1969.avi -ss 00:01:30.0 -acodec copy -vcodec copy -t 00:00:30.0 out.mp4
15 | const tempFile = path.join(remote.app.getPath('temp'), 'tempvid.mp4');
16 | if (fs.existsSync(tempFile)) {
17 | fs.removeSync(tempFile);
18 | }
19 | try {
20 | // const commandExtract = `ffmpeg -ss ${startTime} -to ${endTime} -fflags +genpts -i "${vidPath}" -acodec copy -vcodec copy -strict -2 "${tempFile}"`
21 | const commandExtract = `"${getFFmpegExePath(
22 | 'ffmpeg'
23 | )}" -ss ${startTime} -to ${endTime} -fflags +genpts -i "${vidPath}" -acodec aac -vcodec copy -map 0:v:0 -map 0:a:${aid -
24 | 1} -strict -2 -b:a 128k "${tempFile}"`;
25 | const commandScale = `"${getFFmpegExePath(
26 | 'ffmpeg'
27 | )}" -i "${tempFile}" -vf scale=370:-2 -acodec aac -af "aresample=async=1000" -max_muxing_queue_size 1999 "${outPath}"`;
28 |
29 | let r = await exec(commandExtract);
30 | if (r.error) {
31 | log.error(`error on extract: ${r.error}`);
32 | return false;
33 | }
34 | r = await exec(commandScale);
35 |
36 | if (r.error) {
37 | log.error(`error on scale: ${r.error}`);
38 | return false;
39 | }
40 | } catch (e) {
41 | log.error(`error on generating movie clip: ${e}`);
42 | return false;
43 | }
44 |
45 | return true;
46 | };
47 |
48 | genThumbnail = async (vidPath, time, outPath) => {
49 | const commandThumbnail = `"${getFFmpegExePath('ffmpeg')}" -i "${vidPath}" -ss ${time} -vframes 1 "${outPath}"`;
50 | log.log(commandThumbnail);
51 | const { error } = await exec(commandThumbnail);
52 | if (error) {
53 | log.error(`error on gen thumbnail: ${error}`);
54 | return false;
55 | }
56 | return true;
57 | };
58 | }
59 |
60 | export default new VidConverter();
61 |
--------------------------------------------------------------------------------
/src/app/js/Model/YoutubeSubtitle/index.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import log from 'electron-log';
3 | import { remote } from 'electron';
4 | import { parseAutoGen, parseCC } from '../youtube-subtitle-converter';
5 | import { formatMs } from '../KUtils';
6 |
7 | class YoutubeSubtitle {
8 | getSubtitleFromUrl = async (url, videoId, lang) => {
9 | let lines = [];
10 | if (lang.startsWith('cc_')) {
11 | lines = await this.getCCFromId(videoId, lang.replace('cc_', ''));
12 | } else {
13 | lines = await this.getAutoSubFromUrl(url, lang);
14 | }
15 | return lines;
16 | };
17 |
18 | downloadSubtitleFromUrl = async (url, videoId, lang) => {
19 | const lines = await this.getSubtitleFromUrl(url, videoId, lang);
20 | const text = this.convertLinesToSrt(lines);
21 | const filePath = remote.dialog.showSaveDialog(remote.getCurrentWindow(), { defaultPath: `YouTubeSubtitle.${lang}.srt` });
22 | await fs.writeFile(filePath, text);
23 | return filePath;
24 | };
25 |
26 | convertLinesToSrt = lines => {
27 | const result = lines.reduce((text, line, index) => {
28 | let temp = `${index + 1}\n`;
29 | temp += `${formatMs(line.start)} --> ${formatMs(line.end)}\n`;
30 | temp += `${line.text}\n\n`;
31 | return text + temp;
32 | }, '');
33 | return result;
34 | };
35 |
36 | getAutoSubFromUrl = async (url, lang) => {
37 | let lines = [];
38 | const text = await this.sendAutoGenerateRequest(url, lang);
39 | if (text) {
40 | lines = parseAutoGen(text);
41 | }
42 | return lines;
43 | };
44 |
45 | getCCFromId = async (videoId, lang) => {
46 | const text = await this.sendCCRequest(videoId, lang);
47 | let lines = [];
48 | if (text) {
49 | lines = parseCC(text);
50 | }
51 | return lines;
52 | };
53 |
54 | sendAutoGenerateRequest = async (url, lang) => {
55 | let requestUrl = url;
56 | if (!requestUrl.includes(`lang=${lang}`)) {
57 | requestUrl += `&tlang=${lang}`;
58 | }
59 | const text = await this.requestText(requestUrl);
60 | return text;
61 | };
62 |
63 | sendCCRequest = async (videoId, lang) => {
64 | const url = `http://video.google.com/timedtext?lang=${lang}&v=${videoId}`;
65 | log.info(`getCCFromId:${url}`);
66 |
67 | const text = await this.requestText(url);
68 | return text;
69 | };
70 |
71 | ccListParser = text => {
72 | const trackList = new DOMParser().parseFromString(text, 'text/xml').getElementsByTagName('track');
73 | return Array.from(trackList).map(trackElement => ({
74 | key: `cc_${trackElement.getAttribute('lang_code')}`,
75 | name: `${trackElement.getAttribute('lang_translated')} [cc]`
76 | }));
77 | };
78 |
79 | getCCListFromId = async videoId => {
80 | const listUrl = `https://video.google.com/timedtext?hl=en&v=${videoId}&type=list`;
81 | log.info(`listUrl:${listUrl}`);
82 | const text = this.requestText(listUrl);
83 | if (text) {
84 | return this.ccListParser(text);
85 | }
86 | return [];
87 | };
88 |
89 | requestText = async url => {
90 | return new Promise(resolve => {
91 | const xhr = new XMLHttpRequest();
92 | xhr.addEventListener('load', () => {
93 | if (xhr.status === 200) {
94 | resolve(xhr.responseText);
95 | } else {
96 | resolve('');
97 | }
98 | });
99 | xhr.addEventListener('error', () => {
100 | log.error(xhr.statusText);
101 | resolve('');
102 | });
103 | xhr.open('GET', url);
104 | xhr.send();
105 | });
106 | };
107 | }
108 |
109 | export default new YoutubeSubtitle();
110 |
--------------------------------------------------------------------------------
/src/app/js/Model/youtube-subtitle-converter/gen.js:
--------------------------------------------------------------------------------
1 | import he from 'he';
2 | import { breakLine } from '../KUtils';
3 |
4 | const genCCSub = body => {
5 | const lineObjs = body.slice(2).filter(lineObj => lineObj.length > 2);
6 | const subtitles = lineObjs.map(([tagName, { t, d }, text], i) => {
7 | const startTime = parseInt(t);
8 | const endTime = parseInt(d) + startTime;
9 |
10 | return {
11 | text,
12 | start: startTime,
13 | end: endTime,
14 | sentences: breakLine(text),
15 | index: i
16 | };
17 | }, '');
18 |
19 | return subtitles;
20 | };
21 |
22 | const genTranscript = body => {
23 | const lineObjs = body.slice(2).filter(lineObj => lineObj.length > 2);
24 | const subtitles = lineObjs.map(([tagName, { start, dur }, text], i) => {
25 | const startTime = parseFloat(start) * 1000;
26 | const endTime = parseFloat(dur) * 1000 + startTime;
27 | var sub = {
28 | start: startTime,
29 | end: endTime,
30 | text: text,
31 | sentences: breakLine(he.decode(text)),
32 | index: i
33 | };
34 | return sub;
35 | }, '');
36 |
37 | return subtitles;
38 | };
39 |
40 | const genSub = body => {
41 | const lineObjs = body.slice(2).filter(lineObj => lineObj.length > 2);
42 | //console.log("len:" + lineObjs.length);
43 |
44 | let subtitles = lineObjs.map(([tagName, { t, d }, ...texts], index) => {
45 | const startTime = parseInt(t);
46 | let words = texts
47 | .map(text => {
48 | if (text[0] === 's') {
49 | return text[2].trim();
50 | } else {
51 | return text;
52 | }
53 | })
54 | .filter(w => w);
55 | const content = words.join(' ');
56 | var sentences = breakLine(content);
57 |
58 | var sub = {
59 | start: startTime,
60 | sentences: sentences,
61 | text: content,
62 | index: index
63 | };
64 |
65 | return sub;
66 | }, '');
67 |
68 | let pEnd = 0;
69 | for (let i = subtitles.length - 1; i >= 0; i--) {
70 | var sub = subtitles[i];
71 | if (pEnd != 0) {
72 | sub.end = pEnd - 1;
73 | }
74 | pEnd = sub.start;
75 | }
76 |
77 | return subtitles;
78 | };
79 |
80 | const gen = json => {
81 | if (json[0] === 'timedtext' && json[1].format === '3') {
82 | if (json[2][0] === 'body') {
83 | // CC srt sub
84 | return genCCSub(json[2]);
85 | }
86 | // auto srt sub without youtube-like styling
87 | return genSub(json[3]);
88 | } else if (json[0] === 'transcript') {
89 | return genTranscript(json);
90 | } else {
91 | throw Error('only timedtext with format 3 or transcript expected.');
92 | }
93 | };
94 |
95 | export default gen;
96 |
--------------------------------------------------------------------------------
/src/app/js/Model/youtube-subtitle-converter/index.js:
--------------------------------------------------------------------------------
1 | import { breakLine } from '../KUtils';
2 | import gen from './gen';
3 | import parse from './parser';
4 |
5 | export function parseCC(xmlStr) {
6 | if (!xmlStr) {
7 | return [];
8 | } else {
9 | return gen(parse(xmlStr));
10 | }
11 | }
12 |
13 | export function parseAutoGen(jsonStr) {
14 | if (!jsonStr) {
15 | return [];
16 | }
17 | return parseJson(jsonStr);
18 | }
19 |
20 | const reducer = (content, seg) => {
21 | return content + seg.utf8;
22 | };
23 |
24 | const parseJson = jsonStr => {
25 | jsonStr = jsonStr.replace(/\\n/g, ' ');
26 | const json = JSON.parse(jsonStr);
27 | const events = json.events;
28 |
29 | const lines = events
30 | .map(event => {
31 | const start = event.tStartMs;
32 | // const durationMs = event.dDurationMs;
33 | // const end = start + durationMs;
34 | // duration from YouTube is not correct, calculate it by ourself
35 | if (event.segs) {
36 | if (event.segs.length == 1 && !event.segs[0].utf8.trim()) return null;
37 |
38 | const text = event.segs.reduce(reducer, '');
39 | const sentences = breakLine(text);
40 | return { start, sentences, text };
41 | }
42 | return null;
43 | })
44 | .filter(line => line != null);
45 |
46 | let lastStart = 0;
47 | for (let index = lines.length - 1; index >= 0; index -= 1) {
48 | const line = lines[index];
49 | line.index = index;
50 | if (!lastStart) {
51 | line.end = line.start + 99999;
52 | } else {
53 | line.end = lastStart - 1;
54 | }
55 | lastStart = line.start;
56 | }
57 |
58 | return lines;
59 | };
60 |
--------------------------------------------------------------------------------
/src/app/js/Model/youtube-subtitle-converter/parser.js:
--------------------------------------------------------------------------------
1 | const isDocumentNode = nodeType => nodeType === 9;
2 |
3 | const isTextNode = nodeType => nodeType === 3;
4 |
5 | const reduceAttr = attributes => {
6 | const attrs = Array.from(attributes);
7 |
8 | const props = attrs.reduce((acc, { nodeName, nodeValue }) => {
9 | acc[nodeName] = nodeValue;
10 | return acc;
11 | }, {});
12 |
13 | return props;
14 | };
15 |
16 | const xmlToJson = ({ nodeType, childNodes, nodeName, nodeValue, attributes }) => {
17 | if (isDocumentNode(nodeType)) {
18 | return xmlToJson(childNodes[0]);
19 | }
20 |
21 | if (isTextNode(nodeType)) {
22 | return nodeValue;
23 | }
24 |
25 | if (nodeName === undefined) {
26 | return;
27 | }
28 |
29 | const props = reduceAttr(attributes);
30 |
31 | const children = Array.from(childNodes).map(node => xmlToJson(node));
32 |
33 | const root = [nodeName, props, ...children];
34 |
35 | return root;
36 | };
37 |
38 | const strToXML = str => {
39 | return new DOMParser().parseFromString(
40 | //str,
41 | str.replace(/>\n/g, '>').replace(/\n/g, ' '),
42 | 'text/xml'
43 | );
44 | };
45 |
46 | const parse = str => xmlToJson(strToXML(str));
47 |
48 | export default parse;
49 |
--------------------------------------------------------------------------------
/src/app/js/Model/youtube-subtitle-converter/util.js:
--------------------------------------------------------------------------------
1 | const msToObj = n => ({
2 | d: Math.floor(n / 86400000),
3 | hr: Math.floor(n / 3600000) % 24,
4 | min: Math.floor(n / 60000) % 60,
5 | s: Math.floor(n / 1000) % 60,
6 | ms: Math.floor(n) % 1000
7 | });
8 |
9 | const leftPad = (str, len = 2, n = 0) => String(str).padStart(len, n);
10 |
11 | function formatMs(inputMs) {
12 | const { hr, min, s, ms } = msToObj(inputMs);
13 | const timeStr = `${leftPad(hr)}:${leftPad(min)}:${leftPad(s)},${leftPad(ms, 3)}`;
14 | return timeStr;
15 | }
16 |
17 | const formatTime = (t, d, nextT) => {
18 | t = parseInt(t, 10);
19 | d = parseInt(d, 10);
20 | let end = t + d;
21 |
22 | if (nextT !== undefined) {
23 | nextT = parseInt(nextT, 10);
24 | if (end > nextT) {
25 | end = nextT;
26 | }
27 | }
28 |
29 | return {
30 | startTime: formatMs(t),
31 | endTime: formatMs(end)
32 | };
33 | };
34 |
35 | // setColor by text accuracy
36 | const setColor = ([tagName, { ac }, text]) => {
37 | ac = parseInt(ac, 10);
38 |
39 | if (ac === 252) {
40 | return text;
41 | }
42 |
43 | if (ac < 200) {
44 | return `${text}`;
45 | }
46 |
47 | return `${text}`;
48 | };
49 |
50 | export { setColor, formatTime };
51 |
--------------------------------------------------------------------------------
/src/app/js/renderer.jsx:
--------------------------------------------------------------------------------
1 | import 'photonkit/dist/css/photon.css';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import log from 'electron-log';
5 | import unhandled from 'electron-unhandled';
6 | import App from './Containers/App.jsx';
7 | import Settings from './Model/Settings';
8 | import { initFFmpegPath } from './Model/FFmpegHelper';
9 |
10 | initFFmpegPath();
11 |
12 | window.addEventListener('error', event => {
13 | event.preventDefault();
14 | if (event.message?.includes('ResizeObserver')) {
15 | event.stopImmediatePropagation();
16 | return false;
17 | }
18 | });
19 |
20 | unhandled({
21 | logger: error => {
22 | log.error(error);
23 | }
24 | });
25 |
26 | Settings.load(() => {
27 | ReactDOM.render(, document.querySelector('app'));
28 | });
29 |
30 | document.ondragover = ev => {
31 | ev.preventDefault();
32 | ev.stopPropagation();
33 | };
34 |
35 | document.ondrop = ev => {
36 | ev.preventDefault();
37 | };
38 |
39 | document.body.ondrop = ev => {
40 | ev.preventDefault();
41 | };
42 |
--------------------------------------------------------------------------------
/src/art/closed-caption-logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/closed-caption-logo-small.png
--------------------------------------------------------------------------------
/src/art/closed-caption-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/closed-caption-logo.png
--------------------------------------------------------------------------------
/src/art/manage_subtitle.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/manage_subtitle.psd
--------------------------------------------------------------------------------
/src/art/noun_1650297_cc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/noun_1650297_cc.png
--------------------------------------------------------------------------------
/src/art/settings-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/settings-512.png
--------------------------------------------------------------------------------
/src/art/settings_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/settings_1.png
--------------------------------------------------------------------------------
/src/art/sub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/sub.png
--------------------------------------------------------------------------------
/src/art/subitle_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/subitle_1.png
--------------------------------------------------------------------------------
/src/art/subitle_1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
43 |
--------------------------------------------------------------------------------
/src/art/subtitle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/art/subtitle.png
--------------------------------------------------------------------------------
/src/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | targets: {
7 | node: 'current'
8 | }
9 | }
10 | ]
11 | ],
12 | env: {
13 | test: {
14 | presets: ['@babel/preset-react']
15 | }
16 | },
17 | plugins: [
18 | [
19 | 'import',
20 | {
21 | libraryName: 'antd',
22 | style: true
23 | }
24 | ],
25 | 'dynamic-import-node'
26 | ]
27 | };
28 |
--------------------------------------------------------------------------------
/src/etc/lookup/osx-lookup:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circleapps/beyondplayer/07df76d56baf72b23fd1fed7ff6deb768d656f81/src/etc/lookup/osx-lookup
--------------------------------------------------------------------------------