├── docs ├── icon.png ├── zh-ja.jpg ├── 2021_zh-en.jpg ├── js-console.png ├── popup-menu.jpg ├── 2021_settings.jpg ├── 2021_popup-menu.jpg ├── chrome-webstore-badge58.png ├── firefox-addons-badge58.png └── chrome-ext-update-manually.png ├── src ├── icon.png ├── icon128.png ├── icon16.png ├── icon32.png ├── icon-gray.png ├── icon128-gray.png ├── icon16-gray.png ├── icon32-gray.png ├── console.js ├── default-settings.js ├── manifest-v2-firefox.json ├── manifest-v3.json ├── manifest-v2-safari.json ├── content.js ├── playback-rate-controller.js ├── settings.html ├── service_worker.js ├── settings.css ├── settings.js └── nflxmultisubs.js ├── .gitignore ├── utils ├── build.js └── pack.js ├── package.json ├── LICENSE ├── .github └── workflows │ └── publish.yml ├── webpack.config.js ├── INSTALL.md ├── README_cn.md └── README.md /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/icon.png -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon.png -------------------------------------------------------------------------------- /docs/zh-ja.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/zh-ja.jpg -------------------------------------------------------------------------------- /src/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon128.png -------------------------------------------------------------------------------- /src/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon16.png -------------------------------------------------------------------------------- /src/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | 4 | node_modules/ 5 | build/ 6 | dist/ 7 | .idea/ 8 | -------------------------------------------------------------------------------- /src/icon-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon-gray.png -------------------------------------------------------------------------------- /docs/2021_zh-en.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/2021_zh-en.jpg -------------------------------------------------------------------------------- /docs/js-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/js-console.png -------------------------------------------------------------------------------- /docs/popup-menu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/popup-menu.jpg -------------------------------------------------------------------------------- /src/icon128-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon128-gray.png -------------------------------------------------------------------------------- /src/icon16-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon16-gray.png -------------------------------------------------------------------------------- /src/icon32-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/src/icon32-gray.png -------------------------------------------------------------------------------- /docs/2021_settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/2021_settings.jpg -------------------------------------------------------------------------------- /docs/2021_popup-menu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/2021_popup-menu.jpg -------------------------------------------------------------------------------- /docs/chrome-webstore-badge58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/chrome-webstore-badge58.png -------------------------------------------------------------------------------- /docs/firefox-addons-badge58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/firefox-addons-badge58.png -------------------------------------------------------------------------------- /docs/chrome-ext-update-manually.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmertes/NflxMultiSubs/HEAD/docs/chrome-ext-update-manually.png -------------------------------------------------------------------------------- /utils/build.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const config = require('../webpack.config'); 3 | 4 | webpack(config, (err, stats) => { 5 | if (err) { 6 | console.error(err.stack || err); 7 | err.details && console.error(err.details); 8 | return; 9 | } 10 | 11 | console.log(stats.toString({ colors: true })); 12 | }); 13 | -------------------------------------------------------------------------------- /src/console.js: -------------------------------------------------------------------------------- 1 | // wraper console.xxx() to add prefix 2 | const prefix = 'NflxMultiSubs>'; 3 | const console = { 4 | log: (...args) => window.console.log(prefix, ...args), 5 | warn: (...args) => window.console.warn(prefix, ...args), 6 | error: (...args) => window.console.error(prefix, ...args), 7 | debug: (...args) => window.console.debug(prefix, ...args), 8 | }; 9 | 10 | module.exports = console; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NflxMultiSubs", 3 | "description": "Bilingual Subtitles for Netflix (fixed)", 4 | "author": "Dan Chen, Gert Mertes", 5 | "version": "3.0.2", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build": "node utils/build.js", 10 | "build-debug": "cross-env DISABLE_MINI=1 node utils/build.js", 11 | "pack": "node utils/pack.js" 12 | }, 13 | "devDependencies": { 14 | "clean-webpack-plugin": "^4.0.0", 15 | "copy-webpack-plugin": "^9.0.1", 16 | "cross-env": "^7.0.3", 17 | "fs-extra": "^10.0.0", 18 | "jszip": "^3.7.1", 19 | "webpack": "^5.61.0", 20 | "vanilla-picker": "^2.12.1" 21 | } 22 | } -------------------------------------------------------------------------------- /src/default-settings.js: -------------------------------------------------------------------------------- 1 | const kDefaultSettings = { 2 | upperBaselinePos: 0.15, 3 | lowerBaselinePos: 0.85, 4 | primaryImageScale: 0.75, 5 | primaryImageOpacity: 1, 6 | primaryTextScale: 0.95, 7 | primaryTextOpacity: 1, 8 | primaryTextColor: "#ffffff", 9 | secondaryImageScale: 0.5, 10 | secondaryImageOpacity: 1, 11 | secondaryTextScale: 1.0, 12 | secondaryTextStroke: 2.0, 13 | secondaryTextOpacity: 1, 14 | secondaryTextColor: "#ffffff", 15 | // secondaryLanguageMode valid values are: 16 | // 'disabled', 17 | // 'audio' (use audio language), 18 | // 'last' (use last used language) 19 | secondaryLanguageMode: 'audio', 20 | // bcp47 code of the last used language 21 | secondaryLanguageLastUsed: '', 22 | secondaryLanguageLastUsedIsCaption: false, 23 | }; 24 | 25 | module.exports = kDefaultSettings; 26 | -------------------------------------------------------------------------------- /src/manifest-v2-firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NflxMultiSubs 2021 (Netflix Multi. Subtitles)", 3 | "manifest_version": 2, 4 | "author": "Dan Chen, Gert Mertes", 5 | "permissions": [ 6 | "storage", 7 | "https://www.netflix.com/watch/*", 8 | "https://assets.nflxext.com/*" 9 | ], 10 | "background": { 11 | "scripts": [ 12 | "service_worker.min.js" 13 | ] 14 | }, 15 | "browser_action": { 16 | "default_icon": "icon-gray.png", 17 | "default_popup": "settings.html" 18 | }, 19 | "icons": { 20 | "16": "icon16.png", 21 | "32": "icon32.png", 22 | "128": "icon128.png", 23 | "512": "icon.png" 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": [ 28 | "https://www.netflix.com/*" 29 | ], 30 | "js": [ 31 | "content.min.js" 32 | ], 33 | "run_at": "document_start" 34 | } 35 | ], 36 | "web_accessible_resources": [ 37 | "nflxmultisubs.min.js" 38 | ] 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dan Chen, Gert Mertes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/manifest-v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NflxMultiSubs 2021 (Netflix Multi. Subtitles)", 3 | "manifest_version": 3, 4 | "author": "Dan Chen, Gert Mertes", 5 | "permissions": [ 6 | "storage" 7 | ], 8 | "background": { 9 | "service_worker": "service_worker.min.js" 10 | }, 11 | "icons": { 12 | "16": "icon16.png", 13 | "32": "icon32.png", 14 | "128": "icon128.png", 15 | "512": "icon.png" 16 | }, 17 | "content_scripts": [ 18 | { 19 | "matches": [ 20 | "https://www.netflix.com/*" 21 | ], 22 | "js": [ 23 | "content.min.js" 24 | ], 25 | "run_at": "document_start" 26 | } 27 | ], 28 | "externally_connectable": { 29 | "matches": [ 30 | "https://www.netflix.com/*" 31 | ] 32 | }, 33 | "web_accessible_resources": [ 34 | { 35 | "resources": [ 36 | "nflxmultisubs.min.js" 37 | ], 38 | "matches": [ 39 | "" 40 | ] 41 | } 42 | ], 43 | "action": { 44 | "default_icon": "icon-gray.png", 45 | "default_popup": "settings.html" 46 | }, 47 | "host_permissions": [ 48 | "https://www.netflix.com/watch/*", 49 | "https://assets.nflxext.com/*" 50 | ] 51 | } -------------------------------------------------------------------------------- /src/manifest-v2-safari.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NflxMultiSubs 2021 (Netflix Multi. Subtitles)", 3 | "manifest_version": 2, 4 | "author": "Dan Chen, Gert Mertes", 5 | "permissions": [ 6 | "storage", 7 | "https://www.netflix.com/watch/*", 8 | "https://assets.nflxext.com/*" 9 | ], 10 | "background": { 11 | "scripts": [ 12 | "service_worker.min.js" 13 | ] 14 | }, 15 | "browser_action": { 16 | "default_icon": "icon-gray.png", 17 | "default_popup": "settings.html" 18 | }, 19 | "icons": { 20 | "16": "icon16.png", 21 | "32": "icon32.png", 22 | "128": "icon128.png", 23 | "512": "icon.png" 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": [ 28 | "https://www.netflix.com/*" 29 | ], 30 | "js": [ 31 | "content.min.js" 32 | ], 33 | "run_at": "document_start" 34 | } 35 | ], 36 | "externally_connectable": { 37 | "matches": [ 38 | "https://www.netflix.com/*" 39 | ] 40 | }, 41 | "web_accessible_resources": [ 42 | "nflxmultisubs.min.js" 43 | ] 44 | } -------------------------------------------------------------------------------- /utils/pack.js: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const PACKAGE = require('../package.json'); 5 | const fse = require('fs-extra'); 6 | 7 | // ============================================================================= 8 | const kScriptDir = path.dirname(require.main.filename); 9 | const kProjectDir = path.join(kScriptDir, '..'); 10 | 11 | const kBuildDir = path.join(kProjectDir, 'build'); 12 | const kDistDir = path.join(kProjectDir, 'dist'); 13 | const kModulesDir = path.join(kProjectDir, 'node_modules'); 14 | 15 | const kPackageName = PACKAGE.name; 16 | const kPackageVersion = PACKAGE.version; 17 | // ----------------------------------------------------------------------------- 18 | 19 | const browsers = ['chrome', 'firefox']; 20 | browsers.forEach(browser => { 21 | const buildDir = path.join(kBuildDir, browser); 22 | const outName = `${kPackageName}_v${kPackageVersion}_${browser}.zip`; 23 | const srcPath = path.join(buildDir); 24 | const outPath = path.join(kDistDir, outName); 25 | 26 | if (!fs.existsSync(buildDir)) { 27 | console.error(`Error: build folder "${buildDir}" unavailable`); 28 | process.exit(1); 29 | } 30 | 31 | console.log('Preparing dist directory ...'); 32 | fse.ensureDirSync(kDistDir); 33 | 34 | console.log(`Archiving for "${browser}" ...`); 35 | 36 | fse.removeSync(outPath); 37 | child_process.spawnSync('zip', ['-j', '-r', '-9', outPath, srcPath], 38 | { stdio: 'inherit' }); 39 | 40 | console.log(`Packed for "${browser}" !!`); 41 | }); 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20.x 16 | 17 | - run: git archive --format=zip --output=source.zip HEAD 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm run pack 21 | 22 | - uses: actions/upload-artifact@v4 23 | with: 24 | name: archives 25 | path: dist/*.zip 26 | 27 | - name: Get zip path 28 | run: | 29 | echo "ZIP_CHROME=dist/$(ls dist | grep chrome)" >> $GITHUB_ENV 30 | echo "ZIP_FF=dist/$(ls dist | grep firefox)" >> $GITHUB_ENV 31 | 32 | - name: Upload to Chrome Web Store 33 | uses: mobilefirstllc/cws-publish@latest 34 | with: 35 | action: 'upload' 36 | client_id: ${{ secrets.CHROME_ID }} 37 | client_secret: ${{ secrets.CHROME_SECRET }} 38 | refresh_token: ${{ secrets.CHROME_REFRESH_TOKEN }} 39 | extension_id: 'jepfhfjlkgobooomdgpcjikalfpcldmm' 40 | zip_file: ${{ env.ZIP_CHROME }} 41 | 42 | - name: Upload to Firefox Add-ons 43 | uses: yayuyokitano/firefox-addon@v0.0.6-alpha 44 | with: 45 | api_key: ${{ secrets.FF_ISSUER }} 46 | api_secret: ${{ secrets.FF_SECRET }} 47 | guid: '{7c45e7c9-80ff-4f7f-93b3-da64baf0c6dd}' 48 | xpi_path: ${{ env.ZIP_FF }} 49 | src_path: source.zip 50 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | const console = require('./console'); 2 | 3 | 4 | window.addEventListener('load', () => { 5 | const scriptsToInject = ['nflxmultisubs.min.js']; 6 | scriptsToInject.forEach(scriptName => { 7 | const scriptElem = document.createElement('script'); 8 | scriptElem.setAttribute('type', 'text/javascript'); 9 | scriptElem.setAttribute('src', chrome.runtime.getURL(scriptName)); 10 | scriptElem.setAttribute('id', chrome.runtime.id) 11 | document.head.appendChild(scriptElem); 12 | console.log(`Injected: ${scriptName}`); 13 | }); 14 | }); 15 | 16 | 17 | // Firefox: the target website (our injected agent) cannot connect to extensions 18 | // directly, thus we need to relay the connection in this content script. 19 | let gMsgPort; 20 | window.addEventListener('message', evt => { 21 | if (!evt.data || evt.data.namespace !== 'nflxmultisubs') return; 22 | 23 | if (evt.data.action === 'connect') { 24 | if (!gMsgPort) { 25 | gMsgPort = browser.runtime.connect(browser.runtime.id); 26 | gMsgPort.onMessage.addListener(msg => { 27 | if (msg.settings) { 28 | window.postMessage({ 29 | namespace: 'nflxmultisubs', 30 | action: 'apply-settings', 31 | settings: msg.settings, 32 | }, '*'); 33 | } 34 | }); 35 | } 36 | } 37 | else if (evt.data.action === 'disconnect') { 38 | if (gMsgPort) { 39 | gMsgPort.disconnect(); 40 | gMsgPort = null; 41 | gMsgPort.disconnect(); 42 | } 43 | } 44 | else if (evt.data.action === 'update-settings') { 45 | if (gMsgPort) { 46 | if (evt.data.settings) { 47 | gMsgPort.postMessage({ settings: evt.data.settings }); 48 | } 49 | } 50 | } 51 | else if (evt.data.action === 'startPlayback') { 52 | if (gMsgPort) { 53 | gMsgPort.postMessage({ startPlayback: 1 }); 54 | } 55 | } 56 | else if (evt.data.action === 'stopPlayback') { 57 | if (gMsgPort) { 58 | gMsgPort.postMessage({ stopPlayback: 1 }); 59 | } 60 | } 61 | }, false); 62 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const PACKAGE = require('./package.json'); 3 | const webpack = require('webpack'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 6 | 7 | 8 | // ============================================================================= 9 | 10 | const mode = (process.env.NODE_ENV || 'production'); 11 | const mini = !(process.env.DISABLE_MINI); // disable minification if env DISABLE_MINI is set 12 | 13 | const kProjectDir = __dirname; 14 | const kSourceDir = path.join(kProjectDir, 'src'); 15 | const kBuildDir = path.join(kProjectDir, 'build'); 16 | const kModulesDir = path.join(kProjectDir, 'node_modules'); 17 | 18 | 19 | // ----------------------------------------------------------------------------- 20 | 21 | const browsers = ['chrome', 'firefox', 'safari']; 22 | const manifests = { 23 | chrome: 'v3', 24 | firefox: 'v2-firefox', 25 | safari: 'v2-safari', 26 | }; 27 | const configs = browsers.map(browser => { 28 | const mver = manifests[browser]; 29 | const buildDir = path.join(kBuildDir, browser); 30 | return { 31 | mode: mode, 32 | 33 | optimization: { 34 | minimize: mini 35 | }, 36 | 37 | entry: { 38 | service_worker: path.join(kSourceDir, 'service_worker.js'), 39 | content: path.join(kSourceDir, 'content.js'), 40 | settings: path.join(kSourceDir, 'settings.js'), 41 | nflxmultisubs: path.join(kSourceDir, 'nflxmultisubs.js'), 42 | }, 43 | output: { 44 | path: buildDir, 45 | filename: '[name].min.js', 46 | }, 47 | 48 | plugins: [ 49 | new CleanWebpackPlugin(), 50 | new CopyWebpackPlugin({ 51 | patterns: [ 52 | { 53 | from: path.join(kSourceDir, `manifest-${mver}.json`), 54 | to: "manifest.json", 55 | transform: (content, path) => Buffer.from(JSON.stringify({ 56 | short_name: PACKAGE.name, 57 | description: PACKAGE.description, 58 | version: PACKAGE.version, 59 | ...JSON.parse(content.toString('utf-8')) 60 | }, null, '\t')), 61 | }, 62 | { 63 | from: path.join(kSourceDir, '*.+(html|png|css)').replace(/\\/g, "/"), 64 | to: "[name][ext]", 65 | }, 66 | ] 67 | }), 68 | new webpack.DefinePlugin({ 69 | VERSION: JSON.stringify(PACKAGE.version), 70 | BROWSER: JSON.stringify(browser), 71 | }), 72 | ], 73 | }; 74 | }); 75 | 76 | 77 | module.exports = configs; 78 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation instructions 2 | ### UPDATE - 2021/10/26 3 | The extension is now available on the Chrome and Firefox stores under the name NflxMultiSubs 2021. 4 | 5 | **If you have previously installed the extension** from this repository, please remove it and reinstall from the store. 6 | 7 | **Regular users should only install the extension directly from the store**! Click on the button below to install: 8 | 9 | [](https://chrome.google.com/webstore/detail/jepfhfjlkgobooomdgpcjikalfpcldmm) 10 | [](https://addons.mozilla.org/en-GB/firefox/addon/nflxmultisubs-2021) 11 | 12 |
13 | Click here for manual installation instructions (obsolete - only for developers) 14 | 15 | Chrome 16 | ---- 17 | 1) Download the zip for Chrome from 18 | the [Release](https://github.com/gmertes/NflxMultiSubs/releases/latest) page 19 | 2) Create a folder somewhere called `NflxMultiSubs` . 20 | You can put this folder anywhere, for example in your My Documents folder 21 | 3) Extract the contents of the zip file into this folder you just created 22 | 4) In a new Chrome tab, type `chrome://extensions` in the address bar and press enter 23 | 5) In the top right corner, click the box next to **Developer mode** to turn it on 24 | 6) Now click on the **Load Unpacked** button, a file browser will pop up 25 | 7) Browse to the location of the `NflxMultiSubs` folder that you just created, 26 | select it and press **Select Folder** 27 | 28 | That's it, the extension should now be installed! 29 | 30 | To update the extension with a new release, 31 | simply download the new zip file and extract the contents to your `NflxMultiSubs` folder (overwrite all files). 32 | If you have open Netflix tabs, close them. The extension is now updated. 33 | 34 | Firefox 35 | ---- 36 | 37 | **NOTE:** Due to the way Firefox handles unpacked extensions, this is only a temporary installation. 38 | The extension will be removed the next time you restart the browser. 39 | 40 | 1) Download the zip for Firefox from 41 | the [Release](https://github.com/gmertes/NflxMultiSubs/releases/latest) page 42 | 2) In a new Firefox tab, type `about:debugging` in the address bar and press enter 43 | 3) Click on **This Firefox** on the left side of the page 44 | 4) In the middle under Temporary Extensions, click on **Load Temporary Addon-on** 45 | 5) Browse to the location of the zip file you just downloaded (e.g.: your Downloads folder) 46 | 6) Select the zip file and click **Open** 47 | 48 | That's it, the extension should now be installed! 49 |
50 | -------------------------------------------------------------------------------- /src/playback-rate-controller.js: -------------------------------------------------------------------------------- 1 | class PlaybackRateController { 2 | constructor() { 3 | this.keyUpHandler = undefined; 4 | this.timer = undefined; 5 | } 6 | 7 | 8 | activate() { 9 | if (this.keyUpHandler) return; 10 | 11 | this.keyUpHandler = window.addEventListener('keydown', 12 | this._keyUpHandler.bind(this)); 13 | } 14 | 15 | 16 | deactivate() { 17 | if (!this.keyUpHandler) return; 18 | 19 | window.removeEventListener('keydown', this.keyUpHandler); 20 | this.keyUpHandler = null; 21 | } 22 | 23 | 24 | _keyUpHandler(evt) { 25 | if (evt.ctrlKey || evt.altKey || evt.metaKey || evt.shiftKey) return; 26 | const allowedEventKeys = { 27 | "playbackRateDecreaserKeys": ["[", "ğ"], 28 | "playbackRateIncreaserKeys": ["]", "ü"] 29 | }; 30 | const allowedEventCodes = { 31 | "playbackRateDecreaserKeys": ["BracketLeft"], 32 | "playbackRateIncreaserKeys": ["BracketRight"] 33 | }; 34 | 35 | if (!(Object.values(allowedEventCodes).flat().includes(evt.code) || Object.values(allowedEventKeys).flat().includes(evt.key))) return; 36 | 37 | const playerContainer = document.querySelector('.watch-video'); 38 | if (!playerContainer) return; 39 | 40 | const video = playerContainer.querySelector('video'); 41 | if (!video) return; 42 | 43 | let playbackRate = video.playbackRate; 44 | if (allowedEventCodes.playbackRateDecreaserKeys.includes(evt.code) || allowedEventKeys.playbackRateDecreaserKeys.includes(evt.key)) playbackRate -= 0.1; 45 | else if (allowedEventCodes.playbackRateIncreaserKeys.includes(evt.code) || allowedEventKeys.playbackRateIncreaserKeys.includes(evt.key)) playbackRate += 0.1; 46 | 47 | playbackRate = Math.max(Math.min(playbackRate, 3.0), 0.1); 48 | video.playbackRate = playbackRate; 49 | 50 | let osd = document.querySelector('.nflxmultisubs-playback-rate'); 51 | if (!osd) { 52 | osd = document.createElement('div'); 53 | osd.classList.add('nflxmultisubs-playback-rate'); 54 | osd.style.position = 'absolute'; osd.style.top = '10%'; osd.style.right = '5%'; 55 | osd.style.fontSize = '36px'; osd.style.fontFamily = 'sans-serif'; 56 | osd.style.fontWeight = '800'; osd.style.color = 'white'; 57 | osd.style.display = 'flex'; osd.style.alignItems = 'center'; 58 | osd.style.zIndex = 2; 59 | playerContainer.appendChild(osd); 60 | } 61 | if (!osd) return; 62 | 63 | const icon = ` 64 | 65 | `; 66 | 67 | osd.innerHTML = `${icon}${playbackRate.toFixed(1)}x`; 68 | 69 | if (this.timer) clearTimeout(this.timer); 70 | osd.style.transition = 'none'; 71 | osd.style.opacity = '1'; 72 | this.timer = setTimeout(() => { 73 | osd.style.transition = 'opacity 2.3s'; 74 | osd.style.opacity = '0'; 75 | this.timer = null; 76 | }, 200); 77 | } 78 | } 79 | 80 | module.exports = PlaybackRateController; 81 | -------------------------------------------------------------------------------- /README_cn.md: -------------------------------------------------------------------------------- 1 |

English, 中文

2 | 3 | 4 | NflxMultiSubs 5 | ============================================================ 6 | ![Chrome users](https://img.shields.io/chrome-web-store/users/jepfhfjlkgobooomdgpcjikalfpcldmm?label=Chrome%20users) 7 | ![Firefox users](https://img.shields.io/amo/users/nflxmultisubs-2021?label=Firefox%20users) 8 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=5GY9A82PFY38W&no_recurring=1¤cy_code=EUR) 9 | 10 | Best ever Chrome/Firefox extension to unleash bilingual subtitles on Netflix! 11 | 全球首款支援 Netflix 全語言雙字幕的 Chrome/Firefox 擴充套件,提供您更佳的觀影體驗! 12 | 13 | [](https://chrome.google.com/webstore/detail/jepfhfjlkgobooomdgpcjikalfpcldmm) 14 | [](https://addons.mozilla.org/firefox/addon/nflxmultisubs-2021) 15 | [](https://apps.apple.com/app/nflxmultisubs/id1594059167) 16 | 17 | 18 | 19 | 20 | 特色 21 | ---- 22 | - 坊間首款全自動支援日語、俄文等語言 (image-based) 第二字幕的擴充套件 23 | - 智慧選擇雙語字幕:看日本動畫顯示日語,看美劇顯示英文 24 | - 整合原生 Netflix 選單,不需離開播放介面即可切換語言 25 | - Netflix 有提供的字幕通通可以選,不需要另外找字幕組 26 | - 也順便做了調整播放速度的功能(按 `[` 與 `]` 鍵) 27 | - 開放原始碼! 28 | 29 | 30 | 31 | 有圖有真相 32 | ---------- 33 | ![Bilingual Subtitles with zh-cn/en](docs/2021_zh-en.jpg?raw=true) 34 | ![Intergrated in original menu](docs/2021_popup-menu.jpg?raw=true) 35 | ![Settings menu](docs/2021_settings.jpg?raw=true) 36 | 37 | 构建 38 | ---- 39 | ``` 40 | git clone https://github.com/gmertes/NflxMultiSubs.git 41 | cd NflxMultiSubs 42 | npm install 43 | npm run build 44 | ``` 45 | 46 | 使用須知 & 已知問題 47 | ------------------- 48 | - 使用過程中發生的問題,本套件與開發者概不負責哦,請謹慎使用 49 | - 本套件與 Netflix, Inc. 原廠沒有關係,各資源版權均屬原創作者所有 50 | - 本套件可能與其他 Netflix 相關套件相衝,很遺憾請擇一使用 51 | - 目前 text-based 第二字幕沒有處理 right-to-left (RTL) 語系 52 | 53 | 54 | 55 | 遇到問題了嗎? 56 | -------------- 57 | ### 字幕列表是空的 58 | - 重新整理 (F5) 后字幕会出现 59 | 60 | ### 主字幕跟第二字幕分很開 61 | - 通常只有在進度條顯示的時候才會發生,等到進度條隱藏就好了 62 | 63 | ### 主字幕跳到畫面中間 64 | - 通常只有在進度條顯示的時候才會發生,等到進度條隱藏就好了 65 | 66 | ### 只能在 Chrome 桌面版用嗎? 67 | - 沒錯,手機、平版電腦、智慧電視、Apple TV、Chromecast、………通通不支援 68 | - 因為技術限制,未來也不會支援這些平台,只能跪求 Netflix 官方釋出囉 69 | 70 | ### 可以跨區載入字幕嗎? 71 | - 本套件尊重 Netflix 資源,目前只支援該地區官方有提供的字幕(主字幕有什麼語言,第二字幕就有相同選擇) 72 | - 未來也不會加入「自行掛載字幕檔」的功能 73 | 74 | ### 可不可以加入____功能? 75 | - 這個套件只想專注做好一件事:提供「雙語字幕」的良好觀影體驗 76 | - 如果有请求,可以打开 issue(请写英文与在标题写入[Feature Request]) 77 | 78 | 捐赠 79 | ---- 80 | 这个项目总是免费的。如果你喜欢并想支持我的工作,欢迎捐款。 81 | 82 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=5GY9A82PFY38W&no_recurring=1¤cy_code=EUR) 83 | 84 | BTC: `bc1qx8duq3526zhc2md724ym70qgd4wgadj5dqfuvr` 85 | 86 | ETH: `0x02635a2ef80887B0AEBa5a8282AeFAEA401DFCf9` 87 | 88 | XLM: `GB5Y7TVH7OBI7MFAT26RZ4TCZRDMVNWXLQH3LPTI2RRB22PRHSDR25BH` 89 | 90 | License 91 | -------- 92 | MIT. Original by [Dan Chen](https://github.com/dannvix), forked and maintained by [Gert Mertes](https://github.com/gmertes). 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

English, 中文

2 | 3 | NflxMultiSubs 4 | ============================================================ 5 | ![Chrome users](https://img.shields.io/chrome-web-store/users/jepfhfjlkgobooomdgpcjikalfpcldmm?label=Chrome%20users) 6 | ![Firefox users](https://img.shields.io/amo/users/nflxmultisubs-2021?label=Firefox%20users) 7 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=5GY9A82PFY38W&no_recurring=1¤cy_code=EUR) 8 | 9 | The best ever Chrome/Firefox extension to unleash bilingual subtitles on Netflix! 10 | 11 | This repository is updated for 2021 with a fix for the Netflix redesign and other bug fixes and improvements. 12 | 13 | [](https://chrome.google.com/webstore/detail/jepfhfjlkgobooomdgpcjikalfpcldmm) 14 | [](https://addons.mozilla.org/firefox/addon/nflxmultisubs-2021) 15 | [](https://apps.apple.com/app/nflxmultisubs/id1594059167) 16 | 17 | #### We are now on the Chrome and Firefox stores under the name NflxMultiSubs 2021 🥳🥳🥳
An Apple Mac version for Safari with basic functionality is also available. Click on the badge to install. 18 | 19 | **If you installed the extension from the zip before, please remove it and reinstall from the store.** 20 | 21 | Features 22 | -------- 23 | - Enable secondary subtitles in all languages (incl. image-based subtitles like Japanese, Chinese, Russian, …) 24 | - Smart selection on secondary subtitles. Choose between 3 subtitle activation modes: disabled; automatically match subtitle language to audio language; or remember the last selected language. 25 | - Seamless integration with native Netflix player UI -- switch languages in place 26 | - Adjust playback speed (pressing key `[` and `]`) 27 | - Open source!! 28 | 29 | Installation 30 | ----- 31 | Chrome: https://chrome.google.com/webstore/detail/jepfhfjlkgobooomdgpcjikalfpcldmm
32 | Firefox: https://addons.mozilla.org/firefox/addon/nflxmultisubs-2021
33 | 34 | Safari Mac port maintained by [WingCH](https://github.com/WingCH) (without customisable settings for now):
https://apps.apple.com/app/nflxmultisubs/id1594059167 35 | 36 | See it in Action 37 | ---------------- 38 | ![Bilingual Subtitles with zh-cn/en](docs/2021_zh-en.jpg?raw=true) 39 | ![Intergrated in original menu](docs/2021_popup-menu.jpg?raw=true) 40 | ![Settings menu](docs/2021_settings.jpg?raw=true) 41 | 42 | Build 43 | ----- 44 | Requires Node.js. Build directories are `build/chrome` and `build/firefox`. 45 | ``` 46 | git clone https://github.com/gmertes/NflxMultiSubs.git 47 | cd NflxMultiSubs 48 | npm install 49 | npm run build 50 | ``` 51 | 52 | Known Issues 53 | ------------------------- 54 | - Wait for the Netflix home page to finish loading completely before starting a show/movie. 55 | - Refresh the page if the secondary sub list is empty. 56 | - This extension could conflict with other Netflix-related extensions (but not [NflxIntroSkip](https://github.com/gmertes/NflxIntroSkip)! :D). If you encounter any problem, try to disable some of them 57 | - RTL (right-to-left) text-based subtitles are not ready yet 58 | - This extension and the developers are not affiliated with Netflix, Inc; All rights belong to their owners 59 | 60 | 61 | Problems? 62 | --------- 63 | ### The secondary subtitles list is empty or subs aren't showing up 64 | - Subs will show up after a Refresh (F5). 65 | 66 | ### Large gap between main subtitle and secondary subtitle 67 | - This happens only when the controls bar is active -- just wait until the controls hide 68 | 69 | ### Only available in Chrome/Firefox for desktop? 70 | - Yup -- mobile devices, smart TVs, Apple TV, Chromecast, … are not supported 71 | 72 | ### Could I load subtitles from other country? 73 | - This extension respects Netflix rules, hence we only support all official subtitles available in your country 74 | 75 | ### Feature request: __________ ? 76 | - This extension does one thing and does it well -- great experience with bilingual subtitles support 77 | - If you have a request you can open an issue for consideration (please put [Feature Request] in the title) 78 | 79 | Donate 80 | ---- 81 | The extension is and will remain free. If you like and want to support my work, donations are welcome. 82 | 83 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=5GY9A82PFY38W&no_recurring=1¤cy_code=EUR) 84 | 85 | BTC: `bc1qx8duq3526zhc2md724ym70qgd4wgadj5dqfuvr` 86 | 87 | ETH: `0x02635a2ef80887B0AEBa5a8282AeFAEA401DFCf9` 88 | 89 | XLM: `GB5Y7TVH7OBI7MFAT26RZ4TCZRDMVNWXLQH3LPTI2RRB22PRHSDR25BH` 90 | 91 | License 92 | -------- 93 | MIT. Original by [Dan Chen](https://github.com/dannvix), forked and maintained by [Gert Mertes](https://github.com/gmertes). 94 | -------------------------------------------------------------------------------- /src/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 |

Layout

12 |
13 | 14 | 15 | 16 | 17 | 18 |

Compact

19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 |

Moderate

27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 |

Tight

35 |
36 |
37 | 38 |
39 |

Primary Subtitles

40 |

Size

41 |
42 |
43 |
A
44 |
45 |
46 |

Text color

47 |
48 |
49 | 50 | Currently only for text subtitles. Image-subs will be white. 51 |
52 |
53 |
54 | 55 |
56 |

Secondary Subtitles

57 |

Size

58 |
59 |
60 |
A
61 |
62 |
63 |

Text color

64 |
65 |
66 | 67 | Currently only for text subtitles. Image-subs will be white. 68 |
69 |
70 |
71 | 72 |
73 |

Secondary Language

74 |
75 | 76 | 77 | 78 | 79 |

Disabled

80 | Subs are off by default 81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 |

Audio

89 | Try to match subs to audio language 90 |
91 |
92 |

93 | 94 | 95 | 96 | 97 | 98 |

Last Used

99 | Remember the last used language 100 |
101 |
102 | 103 | 104 |
105 |
106 | Reset to Default 107 | 111 |
112 |
113 | 114 |
115 | v 116 |  |  117 | Give us a ★ on GitHub 118 |
119 |
120 | 121 | 122 | -------------------------------------------------------------------------------- /src/service_worker.js: -------------------------------------------------------------------------------- 1 | const kDefaultSettings = require('./default-settings'); 2 | 3 | // return true if valid; otherwise return false 4 | function validateSettings(settings) { 5 | const keys = Object.keys(kDefaultSettings); 6 | return keys.every(key => (key in settings)); 7 | } 8 | 9 | const loadSettings = async () => { 10 | return new Promise((resolve, reject) => { 11 | chrome.storage.local.get(['settings'], function (result) { 12 | console.log('Loaded: settings=', result.settings); 13 | if (result.settings && validateSettings(result.settings)) { 14 | resolve(result.settings) 15 | } 16 | else { 17 | saveSettings(kDefaultSettings); 18 | resolve(kDefaultSettings); 19 | } 20 | }); 21 | }); 22 | }; 23 | 24 | function saveSettings(settings) { 25 | // hack to update opacity for existing users 26 | settings.primaryImageOpacity = 1 27 | settings.primaryTextOpacity = 1 28 | settings.secondaryImageOpacity = 1 29 | settings.secondaryTextOpacity = 1 30 | chrome.storage.local.set({ settings: settings }, () => { 31 | console.log('Settings: saved into local storage', settings); 32 | }); 33 | } 34 | 35 | // TODO: revisit this logic. 36 | // The port is ephemeral in manifest v3, so keeping a map of ports is probably not useful. 37 | let gExtPorts = {}; // tabId -> msgPort; for config dispatching 38 | function dispatchSettings(settings) { 39 | try { 40 | const keys = Object.keys(gExtPorts); 41 | keys.map(k => gExtPorts[k]).forEach(port => { 42 | try { 43 | port.postMessage({ settings: settings }); 44 | } 45 | catch (err) { 46 | console.error('Error: cannot dispatch settings,', err); 47 | } 48 | }); 49 | } catch (err) { } 50 | } 51 | 52 | function saturateActionIconForTab(tabId) { 53 | try { 54 | // v2 55 | chrome.browserAction.setIcon({ 56 | tabId: tabId, 57 | path: { 58 | '16': 'icon16.png', 59 | '32': 'icon32.png', 60 | }, 61 | }); 62 | } catch (err) { 63 | // v3 64 | chrome.action.setIcon({ 65 | path: { 66 | '16': 'icon16.png', 67 | '32': 'icon32.png', 68 | }, 69 | }); 70 | } 71 | } 72 | 73 | function desaturateActionIconForTab(tabId) { 74 | try { 75 | // v2 76 | chrome.browserAction.setIcon({ 77 | tabId: tabId, 78 | path: { 79 | '16': 'icon16-gray.png', 80 | '32': 'icon32-gray.png', 81 | }, 82 | }); 83 | } catch (err) { 84 | // v3 85 | chrome.action.setIcon({ 86 | path: { 87 | '16': 'icon16-gray.png', 88 | '32': 'icon32-gray.png', 89 | }, 90 | }); 91 | } 92 | } 93 | 94 | // connected from target website (our injected agent) 95 | async function handleExternalConnection(port) { 96 | const tabId = port.sender && port.sender.tab && port.sender.tab.id; 97 | if (!tabId) return; 98 | 99 | gExtPorts[tabId] = port; 100 | console.log(`Connected: ${tabId} (tab)`); 101 | 102 | var gSettings = await loadSettings(); 103 | port.postMessage({ settings: gSettings }); 104 | 105 | port.onMessage.addListener(msg => { 106 | if (msg.settings) { 107 | console.log('Received from injected agent: settings=', msg.settings); 108 | let settings = Object.assign({}, gSettings); 109 | settings = Object.assign(settings, msg.settings); 110 | if (!validateSettings(settings)) { 111 | gSettings = Object.assign({}, kDefaultSettings); 112 | port.postMessage({ settings: gSettings }); 113 | } 114 | else { 115 | gSettings = settings; 116 | } 117 | saveSettings(gSettings); 118 | dispatchSettings(gSettings); 119 | } 120 | else if (msg.startPlayback) { 121 | console.log('Saturate icon') 122 | saturateActionIconForTab(tabId); 123 | } 124 | else if (msg.stopPlayback) { 125 | console.log('Desaturate icon') 126 | desaturateActionIconForTab(tabId); 127 | } 128 | else { 129 | 130 | } 131 | }); 132 | 133 | port.onDisconnect.addListener(() => { 134 | delete gExtPorts[tabId]; 135 | console.log(`Disconnected: ${tabId} (tab)`); 136 | }); 137 | } 138 | 139 | // connected from our pop-up page 140 | async function handleInternalConnection(port) { 141 | const portName = port.name; 142 | console.log(`Connected: ${portName} (internal)`); 143 | 144 | port.onDisconnect.addListener(() => { 145 | console.log(`Disconnected: ${portName} (internal)`); 146 | }); 147 | 148 | if (portName !== 'settings') return; 149 | 150 | var gSettings = await loadSettings(); 151 | console.log('Dispatching settings to pop-up', gSettings); 152 | port.postMessage({ settings: gSettings }); 153 | 154 | port.onMessage.addListener(msg => { 155 | // this logic is a mess, a leftover from when gSettings was a global variable 156 | // TODO: could use a refactor 157 | if (!msg.settings) { 158 | gSettings = Object.assign({}, kDefaultSettings); 159 | port.postMessage({ settings: gSettings }); 160 | } 161 | else { 162 | console.log('Received: settings=', msg.settings); 163 | let settings = Object.assign({}, gSettings); 164 | settings = Object.assign(settings, msg.settings); 165 | if (!validateSettings(settings)) { 166 | gSettings = Object.assign({}, kDefaultSettings); 167 | port.postMessage({ settings: gSettings }); 168 | } 169 | else { 170 | gSettings = settings; 171 | } 172 | } 173 | saveSettings(gSettings); 174 | dispatchSettings(gSettings); 175 | }); 176 | } 177 | 178 | // handle connections from target website and our pop-up 179 | if (BROWSER !== 'firefox') { 180 | chrome.runtime.onConnectExternal.addListener( 181 | port => handleExternalConnection(port)); 182 | 183 | chrome.runtime.onConnect.addListener( 184 | port => handleInternalConnection(port)); 185 | } 186 | else { 187 | // Firefox: either from website (injected agent) or pop-up are all "internal" 188 | chrome.runtime.onConnect.addListener(port => { 189 | if (port.sender && port.sender.tab) { 190 | handleExternalConnection(port); 191 | } 192 | else { 193 | handleInternalConnection(port); 194 | } 195 | }); 196 | } 197 | -------------------------------------------------------------------------------- /src/settings.css: -------------------------------------------------------------------------------- 1 | /* FIXME: refactor this stylesheet with Sass */ 2 | 3 | html, body { 4 | background: hsl(0, 0%, 97%); 5 | font-size: 12px; 6 | font-family: "Helvetica Neue", Arial, "Microsoft Jhenghei", sans-serif; 7 | margin: 0; 8 | min-width: 300px; 9 | min-height: 460px; 10 | padding: 0; 11 | outline: 0; 12 | user-select: none; 13 | overflow: hidden; 14 | } 15 | 16 | .wrapper { 17 | align-items: stretch; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: center; 21 | min-height: 200px; 22 | text-align: center; 23 | } 24 | 25 | p { 26 | line-height: 1; 27 | margin: 0; 28 | } 29 | 30 | hr { 31 | display: block; 32 | height: 0; 33 | border: 0; 34 | border-top: 1px solid hsl(0, 0%, 90%); 35 | border-bottom: 1px solid hsl(0, 0%, 100%); 36 | margin: 10px 0; 37 | padding: 0; 38 | } 39 | 40 | h1 { 41 | color: #db3b26; 42 | font-weight: bolder; 43 | font-size: 20px; 44 | margin: 16px 0 0 0; 45 | padding: 0; 46 | } 47 | 48 | section { 49 | margin: 5px 0; 50 | } 51 | 52 | section p{ 53 | margin: 3px 0px; 54 | } 55 | section h2 { 56 | color: hsl(0, 0%, 40%); 57 | font-size: 16px; 58 | margin: 0 0 5px 0; 59 | padding: 8px 0; 60 | background: hsl(0, 0%, 95%); 61 | } 62 | 63 | a { 64 | color: hsl(10, 80%, 38%); 65 | font-weight: bold; 66 | outline: 0; 67 | transition: color .33s; 68 | text-decoration: none; 69 | } 70 | 71 | a:hover{ 72 | color: hsl(10, 80%, 50%); 73 | cursor: pointer; 74 | text-decoration: underline; 75 | } 76 | 77 | .pro-link { 78 | color: hsl(0, 0%, 60%); 79 | text-decoration: none; 80 | transition: all 1s; 81 | filter: blur(6px) grayscale(100%); 82 | } 83 | 84 | .pro-link:hover { 85 | cursor: not-allowed; 86 | filter: blur(0); 87 | } 88 | 89 | footer { 90 | color: hsl(0, 0%, 50%); 91 | margin-bottom: 10px; 92 | } 93 | 94 | .settings-secondary-lang { 95 | transition: all 1s; 96 | } 97 | 98 | .settings-layout div, 99 | .settings-secondary-lang div { 100 | color: hsl(0, 0%, 30%); 101 | display: inline-block; 102 | text-align: center; 103 | position: relative; 104 | } 105 | 106 | .settings-layout div+div, 107 | .settings-secondary-lang div+div { 108 | margin-left: 20px; 109 | } 110 | 111 | .settings-layout div:hover, 112 | .settings-layout div.active, 113 | .settings-secondary-lang div:hover, 114 | .settings-secondary-lang div.active 115 | 116 | { 117 | color: hsl(0, 70%, 50%); 118 | cursor: pointer; 119 | } 120 | 121 | .settings-layout svg, 122 | .settings-secondary-lang svg { 123 | width: 60px; 124 | } 125 | 126 | .settings-layout svg:hover rect, 127 | .settings-layout div.active svg rect, 128 | .settings-secondary-lang svg:hover rect, 129 | .settings-secondary-lang div.active svg rect 130 | { 131 | stroke: hsl(0, 70%, 50%); 132 | } 133 | 134 | .settings-layout svg:hover line, 135 | .settings-layout div.active svg line, 136 | .settings-secondary-lang svg:hover line, 137 | .settings-secondary-lang div.active svg line 138 | { 139 | stroke: hsl(0, 80%, 50%); 140 | } 141 | 142 | .font-size { 143 | position: relative; 144 | height: 25px; 145 | width: 25px; 146 | border: 2px solid hsl(0, 0%, 70%); 147 | color: hsl(0, 0%, 30%); 148 | background-color: hsl(0, 0%, 98%); 149 | border-radius: 25px; 150 | font-size: 25px; 151 | font-weight: normal; 152 | align-items: stretch; 153 | display: inline-flex; 154 | flex-direction: column; 155 | justify-content: center; 156 | text-align: center; 157 | } 158 | .font-size-indicator { 159 | height: 35px; 160 | width: 35px; 161 | border: 1px solid hsl(0, 0%, 70%); 162 | background-color: hsl(0, 0%, 100%); 163 | border-radius: 10px; 164 | } 165 | .font-size + .font-size { 166 | margin-left: 7px; 167 | } 168 | 169 | .action:hover{ 170 | border-color: hsl(0, 70%, 50%); 171 | color: hsl(0, 70%, 50%); 172 | cursor: pointer; 173 | } 174 | .action:active { 175 | border-color: hsl(0, 84%, 80%); 176 | color: hsl(0, 91%, 86%); 177 | } 178 | 179 | .settings-secondary-lang div span { 180 | visibility: hidden; 181 | width: 120px; 182 | background-color: #555; 183 | color: #fff; 184 | text-align: center; 185 | border-radius: 6px; 186 | padding: 4px 2px; 187 | position: absolute; 188 | z-index: 1; 189 | top: 120%; 190 | left: 50%; 191 | margin-left: -60px; 192 | opacity: 0; 193 | transition: opacity 0.5s; 194 | } 195 | 196 | .settings-secondary-lang div span::after { 197 | content: ""; 198 | position: absolute; 199 | bottom: 100%; 200 | left: 50%; 201 | margin-left: -5px; 202 | border-width: 5px; 203 | border-style: solid; 204 | border-color: transparent transparent #555 transparent; 205 | } 206 | 207 | .settings-secondary-lang div:hover span{ 208 | visibility: visible; 209 | opacity: 1; 210 | } 211 | 212 | .settings-secondary-lang #langcode{ 213 | position: absolute; 214 | width: 100%; 215 | top: 20%; 216 | pointer-events: none; 217 | } 218 | 219 | 220 | .tooltip { 221 | position: relative; 222 | display: inline-block; 223 | } 224 | 225 | .tooltip span { 226 | visibility: hidden; 227 | width: 170px; 228 | background-color: #555; 229 | color: #fff; 230 | text-align: center; 231 | padding: 5px; 232 | border-radius: 6px; 233 | opacity: 0; 234 | transition: opacity 0.3s; 235 | position: absolute; 236 | z-index: 1; 237 | bottom: 165%; 238 | left: 50%; 239 | margin-left: -88px; 240 | } 241 | 242 | .tooltip:hover span { 243 | visibility: visible; 244 | opacity: 1; 245 | } 246 | 247 | .tooltip span::after { 248 | content: " "; 249 | position: absolute; 250 | top: 100%; 251 | left: 50%; 252 | margin-left: -5px; 253 | border-width: 5px; 254 | border-style: solid; 255 | border-color: #5e5e5e transparent transparent transparent; 256 | } 257 | 258 | #primary-color-ff .picker_wrapper { 259 | width: 230px; 260 | left: -100px; 261 | margin-top: 5px; 262 | } 263 | 264 | #secondary-color-ff .picker_wrapper { 265 | width: 230px; 266 | left: -100px; 267 | margin-bottom: 60px; 268 | } 269 | 270 | #primary-color-ff .picker_arrow, 271 | #secondary-color-ff .picker_arrow { 272 | visibility: hidden; 273 | } 274 | 275 | #primary-color-ff .picker_wrapper.popup, 276 | #secondary-color-ff .picker_wrapper.popup { 277 | box-shadow: 0 0 10px 1px rgb(0 0 0 / 80%); 278 | } 279 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | let settings = {}; 2 | let primaryPicker, secondaryPicker; 3 | 4 | if (BROWSER === 'firefox') { 5 | const Picker = require('vanilla-picker'); 6 | 7 | primaryPicker = new Picker({ 8 | popup: 'bottom', 9 | color: '#ffffff', 10 | alpha: false, 11 | editor: false 12 | }); 13 | 14 | secondaryPicker = new Picker({ 15 | popup: 'top', 16 | color: '#ffffff', 17 | alpha: false, 18 | editor: false 19 | }); 20 | } 21 | 22 | const port = chrome.runtime.connect({ name: 'settings' }); 23 | port.onMessage.addListener((msg) => { 24 | settings = msg.settings || settings; 25 | renderActiveSettings(); 26 | 27 | primaryPicker?.setColor(settings.primaryTextColor || "#ffffff", true); 28 | secondaryPicker?.setColor(settings.secondaryTextColor || "#ffffff", true); 29 | console.log('Settings received:', settings); 30 | }); 31 | 32 | // ----------------------------------------------------------------------------- 33 | 34 | const minimumFontScale = 0.3; 35 | const maximumFontScale = 2.5; 36 | 37 | const layoutPresets = [ 38 | { // compact 39 | upperBaselinePos: 0.20, 40 | lowerBaselinePos: 0.80, 41 | }, 42 | { // moderate (default) 43 | upperBaselinePos: 0.15, 44 | lowerBaselinePos: 0.85, 45 | }, 46 | { // ease 47 | upperBaselinePos: 0.10, 48 | lowerBaselinePos: 0.90, 49 | }, 50 | ]; 51 | 52 | const secondaryLanguagePresets = [ 53 | { 54 | secondaryLanguageMode: 'disabled', 55 | }, 56 | { 57 | secondaryLanguageMode: 'audio', 58 | }, 59 | { 60 | secondaryLanguageMode: 'last', 61 | } 62 | ]; 63 | 64 | 65 | function uploadSettings() { 66 | port.postMessage({ settings: settings }); 67 | } 68 | 69 | function resetSettings() { 70 | port.postMessage({ settings: null }); 71 | } 72 | 73 | function renderActiveSettings() { 74 | if (document.readyState !== 'complete') return; 75 | 76 | // clear all 77 | [].forEach.call(document.querySelectorAll('.active'), elem => { 78 | elem.classList.remove('active'); 79 | }); 80 | 81 | let elem; 82 | 83 | // layout 84 | const layoutId = layoutPresets.findIndex(k => (k.lowerBaselinePos === settings.lowerBaselinePos)); 85 | if (layoutId !== -1) { 86 | elem = document.querySelector(`.settings-layout > div[data-id="${layoutId}"]`); 87 | elem && elem.classList.add('active'); 88 | } 89 | // primary font size 90 | document.getElementById('primary-font-indicator').style.scale = settings.primaryTextScale * 0.8; 91 | 92 | // primary font color 93 | document.getElementById('primary-color').value = settings.primaryTextColor || "#ffffff"; 94 | 95 | // secondary font size 96 | document.getElementById('secondary-font-indicator').style.scale = settings.secondaryTextScale * 0.8; 97 | 98 | // secondary font color 99 | document.getElementById('secondary-color').value = settings.secondaryTextColor || "#ffffff"; 100 | 101 | // secondary language 102 | const secondaryLanguageId = secondaryLanguagePresets.findIndex(k => (k.secondaryLanguageMode === settings.secondaryLanguageMode)); 103 | if (secondaryLanguageId !== -1) { 104 | elem = document.querySelector(`.settings-secondary-lang > div[data-id="${secondaryLanguageId}"]`); 105 | elem && elem.classList.add('active'); 106 | 107 | if(settings.secondaryLanguageLastUsed) 108 | document.getElementById('langcode').innerHTML = settings.secondaryLanguageLastUsed.split('-')[0] // only display language code, not script tag (eg: zh not zh-Hans) 109 | } 110 | } 111 | 112 | function updateLayout(layoutId) { 113 | if (layoutId < 0 || layoutId >= layoutPresets.length) return; 114 | 115 | settings = Object.assign(settings, layoutPresets[layoutId]); 116 | uploadSettings(); 117 | renderActiveSettings(); 118 | } 119 | 120 | function updatePrimaryFontSize(action) { 121 | if (action === "+") { 122 | settings.primaryTextScale = Math.min(maximumFontScale, settings.primaryTextScale + 0.1); 123 | } else if (action === "-"){ 124 | settings.primaryTextScale = Math.max(minimumFontScale, settings.primaryTextScale - 0.1); 125 | } else return; 126 | 127 | settings.primaryImageScale = 0.6 * settings.primaryTextScale; 128 | uploadSettings(); 129 | renderActiveSettings(); 130 | } 131 | 132 | function updateSecondaryFontSize(action) { 133 | if (action === "+") { 134 | settings.secondaryTextScale = Math.min(maximumFontScale, settings.secondaryTextScale + 0.1); 135 | } else if (action === "-"){ 136 | settings.secondaryTextScale = Math.max(minimumFontScale, settings.secondaryTextScale - 0.1); 137 | } else return; 138 | 139 | settings.secondaryImageScale = 0.6 * settings.secondaryTextScale; 140 | 141 | uploadSettings(); 142 | renderActiveSettings(); 143 | } 144 | 145 | function updatePrimaryColor(color) { 146 | settings = Object.assign(settings, {primaryTextColor: color}); 147 | uploadSettings(); 148 | renderActiveSettings(); 149 | } 150 | 151 | function updateSecondaryColor(color) { 152 | settings = Object.assign(settings, {secondaryTextColor: color}); 153 | uploadSettings(); 154 | renderActiveSettings(); 155 | } 156 | 157 | function updateSecondaryLanguage(secondaryLanguage){ 158 | if (secondaryLanguage < 0 || secondaryLanguage >= secondaryLanguagePresets.length) return; 159 | 160 | settings = Object.assign(settings, secondaryLanguagePresets[secondaryLanguage]); 161 | uploadSettings(); 162 | renderActiveSettings(); 163 | } 164 | 165 | 166 | function renderVersion() { 167 | let elem = document.querySelector('#version'); 168 | if (elem) { 169 | elem.textContent = VERSION; 170 | } 171 | } 172 | 173 | 174 | window.addEventListener('load', evt => { 175 | renderVersion(); 176 | renderActiveSettings(); 177 | console.log('Settings page loaded'); 178 | 179 | // handle click events 180 | // --------------------------------------------------------------------------- 181 | const layouts = document.querySelectorAll('.settings-layout > div'); 182 | [].forEach.call(layouts, div => { 183 | const layoutId = parseInt(div.getAttribute('data-id')); 184 | div.addEventListener('click', evt => updateLayout(layoutId), false); 185 | }); 186 | 187 | document.getElementById("primary-plus").addEventListener('click', () => updatePrimaryFontSize("+")); 188 | document.getElementById("primary-minus").addEventListener('click', () => updatePrimaryFontSize("-")); 189 | 190 | document.getElementById("secondary-plus").addEventListener('click', () => updateSecondaryFontSize("+")); 191 | document.getElementById("secondary-minus").addEventListener('click', () => updateSecondaryFontSize("-")); 192 | 193 | const primaryColorField = document.getElementById('primary-color'); 194 | primaryColorField.onchange = evt => { 195 | updatePrimaryColor(evt.target.value); 196 | } 197 | 198 | primaryPicker?.setOptions({ 199 | parent: document.getElementById('primary-color-ff'), 200 | onChange: color => { 201 | updatePrimaryColor(color.hex.slice(0, 7)); 202 | } 203 | }); 204 | 205 | const secondaryColorField = document.getElementById('secondary-color'); 206 | secondaryColorField.onchange = evt => { 207 | updateSecondaryColor(evt.target.value); 208 | } 209 | 210 | secondaryPicker?.setOptions({ 211 | parent: document.getElementById('secondary-color-ff'), 212 | onChange: color => { 213 | updateSecondaryColor(color.hex.slice(0, 7)); 214 | } 215 | }); 216 | 217 | const secondaryLanguage = document.querySelectorAll('.settings-secondary-lang > div'); 218 | [].forEach.call(secondaryLanguage, div => { 219 | const languageId = parseInt(div.getAttribute('data-id')); 220 | div.addEventListener('click', evt => updateSecondaryLanguage(languageId), false); 221 | }); 222 | 223 | const btnReset = document.getElementById('btnReset'); 224 | btnReset.addEventListener('click', evt => { 225 | resetSettings(); 226 | }, false); 227 | }); 228 | -------------------------------------------------------------------------------- /src/nflxmultisubs.js: -------------------------------------------------------------------------------- 1 | const console = require('./console'); 2 | const JSZip = require('jszip'); 3 | const kDefaultSettings = require('./default-settings'); 4 | const PlaybackRateController = require('./playback-rate-controller'); 5 | 6 | //////////////////////////////////////////////////////////////////////////////// 7 | 8 | // Hook JSON.parse() and attempt to intercept the manifest 9 | // For cadmium-playercore-6.0022.710.042.js and later 10 | const hookJsonParseAndAddCallback = function (_window) { 11 | const _parse = JSON.parse; 12 | _window.JSON.parse = (...args) => { 13 | const result = _parse.call(JSON, ...args); 14 | if (result && result.result && result.result.movieId) { 15 | const movieId = result.result.movieId; 16 | window.__NflxMultiSubs.updateManifest(result.result); 17 | } 18 | return result; 19 | }; 20 | }; 21 | hookJsonParseAndAddCallback(window); 22 | 23 | 24 | // hook `history.pushState()` as there is not "pushstate" event in DOM API 25 | // Because Netflix preload manifests when the user hovers mouse over movies on index page, 26 | // our .updateManifest() won't be trigger after user clicks a movie to start watching (they must reload the player page) 27 | (() => { 28 | function processStateChange() { 29 | const movieIdInUrl = extractMovieIdFromUrl(); 30 | if (!movieIdInUrl) return; 31 | console.log(`Movie changed, movieId: ${movieIdInUrl}`); 32 | nflxMultiSubsManager.activateManifest(movieIdInUrl); 33 | } 34 | 35 | history.pushState = (f => function pushState(state, ...args) { 36 | f.call(history, state, ...args); 37 | 38 | processStateChange() 39 | })(history.pushState); 40 | 41 | // Sometimes the URL captured by pushState does not contain the correct movieId, causing the manifest activation to fail. 42 | // This happens when there is a server-side redirect after starting playback, which doesn't trigger the pushState hook. 43 | // For example, a redirect happens after you click on a show thumbnail to start it instead of the play icon. 44 | // So we also hook history.replaceState to capture this redirect. 45 | history.replaceState = (f => function replaceState(state, ...args) { 46 | f.call(history, state, ...args); 47 | 48 | processStateChange() 49 | })(history.replaceState); 50 | })(); 51 | 52 | //////////////////////////////////////////////////////////////////////////////// 53 | 54 | // global states 55 | let gSubtitles = [], 56 | gSubtitleMenu; 57 | let gMsgPort, gRendererLoop; 58 | let gVideoRatio = 1080 / 1920; 59 | let gRenderOptions = Object.assign({}, kDefaultSettings); 60 | let gSecondaryOffset = 0; // used to move secondary subs if primary subs overflow the screen edge 61 | const extensionId = document.currentScript.id; 62 | 63 | function getMsgPort() { 64 | if (gMsgPort) return gMsgPort; 65 | 66 | if (BROWSER !== 'safari') { 67 | gMsgPort = chrome.runtime.connect(extensionId); 68 | } 69 | else { 70 | gMsgPort = browser.runtime.connect(extensionId); 71 | } 72 | console.log(`Linked: ${extensionId}`); 73 | 74 | gMsgPort.onMessage.addListener(msg => { 75 | if (!msg.settings) return; 76 | gRenderOptions = Object.assign({}, msg.settings); 77 | gRendererLoop && gRendererLoop.setRenderDirty(); 78 | console.log("Updated settings: ", gRenderOptions); 79 | }); 80 | 81 | // This is a workaround for manifest v3. 82 | // When the service worker is killed and disconnects, we force it to reopen so we can keep receiving setting updates from settings popup. 83 | gMsgPort.onDisconnect.addListener(() => { 84 | gMsgPort = null; 85 | console.debug(`Reconnecting port...`); 86 | getMsgPort(); 87 | }); 88 | 89 | return gMsgPort; 90 | } 91 | 92 | // connect with background script immediately to capture settings 93 | if (BROWSER !== 'firefox') { 94 | try { 95 | getMsgPort(); 96 | } catch (err) { 97 | console.warn('Error: cannot talk to background,', err); 98 | } 99 | } 100 | 101 | // Firefox: this injected agent cannot talk to extension directly, thus the 102 | // connection (for applying settings) is relayed by our content script through 103 | // window.postMessage(). 104 | 105 | if (BROWSER === 'firefox') { 106 | window.addEventListener( 107 | 'message', 108 | evt => { 109 | if (!evt.data || evt.data.namespace !== 'nflxmultisubs') return; 110 | 111 | if (evt.data.action === 'apply-settings' && evt.data.settings) { 112 | gRenderOptions = Object.assign({}, evt.data.settings); 113 | gRendererLoop && gRendererLoop.setRenderDirty(); 114 | } 115 | }, 116 | false 117 | ); 118 | 119 | try { 120 | window.postMessage({ 121 | namespace: 'nflxmultisubs', 122 | action: 'connect' 123 | }, '*'); 124 | } catch (err) { 125 | console.warn('Error: cannot talk to background,', err); 126 | } 127 | } 128 | 129 | //////////////////////////////////////////////////////////////////////////////// 130 | 131 | class SubtitleBase { 132 | constructor(lang, bcp47, urls, isCaption) { 133 | this.state = 'GENESIS'; 134 | this.active = false; 135 | this.lang = lang; 136 | this.bcp47 = bcp47; 137 | this.isCaption = isCaption; 138 | this.urls = urls; 139 | this.extentWidth = undefined; 140 | this.extentHeight = undefined; 141 | this.lines = undefined; 142 | this.lastRenderedIds = undefined; 143 | } 144 | 145 | activate(options) { 146 | return new Promise((resolve, reject) => { 147 | this.active = true; 148 | if (this.state === 'GENESIS') { 149 | this.state = 'LOADING'; 150 | console.log(`Subtitle "${this.lang}" downloading`); 151 | this._download().then(() => { 152 | this.state = 'READY'; 153 | console.log(`Subtitle "${this.lang}" loaded`); 154 | resolve(this); 155 | }); 156 | } 157 | }); 158 | } 159 | 160 | deactivate() { 161 | this.active = false; 162 | } 163 | 164 | render(seconds, options, forced) { 165 | if (!this.active || this.state !== 'READY' || !this.lines) return []; 166 | const lines = this.lines.filter( 167 | line => line.begin <= seconds && seconds <= line.end 168 | ); 169 | const ids = lines 170 | .map(line => line.id) 171 | .sort() 172 | .toString(); 173 | 174 | if (this.lastRenderedIds === ids && !forced) return null; 175 | this.lastRenderedIds = ids; 176 | return this._render(lines, options); 177 | } 178 | 179 | getExtent() { 180 | return [this.extentWidth, this.extentHeight]; 181 | } 182 | 183 | setExtent(width, height) { 184 | [this.extentWidth, this.extentHeight] = [width, height]; 185 | } 186 | 187 | _download() { 188 | if (!this.urls) return Promise.resolve(); 189 | 190 | console.debug('Selecting fastest server, candidates: ', 191 | this.urls.map(u => u.substr(0, 24))); 192 | 193 | return Promise.any( 194 | this.urls.map(url => fetch(url, { method: 'HEAD' })) 195 | ).then(r => { 196 | const url = r.url; 197 | console.debug(`Fastest: ${url.substr(0, 24)}`); 198 | return this._extract(fetch(url)); 199 | }); 200 | } 201 | 202 | _render(lines, options) { 203 | // implemented in derived class 204 | } 205 | 206 | _extract(fetchPromise) { 207 | // extract contents downloaded from fetch() 208 | // implemented in derived class 209 | } 210 | } 211 | 212 | class DummySubtitle extends SubtitleBase { 213 | constructor() { 214 | super('Off'); 215 | } 216 | 217 | activate() { 218 | this.active = true; 219 | return Promise.resolve(); 220 | } 221 | } 222 | 223 | // subtitle with no download urls 224 | class DehydratedSubtitle extends SubtitleBase { 225 | constructor(...args) { 226 | super(...args); 227 | } 228 | 229 | activate() { 230 | this.active = true; 231 | return Promise.resolve(); 232 | } 233 | } 234 | 235 | class TextSubtitle extends SubtitleBase { 236 | constructor(...args) { 237 | super(...args); 238 | } 239 | 240 | _extract(fetchPromise) { 241 | return new Promise((resolve, reject) => { 242 | fetchPromise 243 | .then(r => r.text()) 244 | .then(xmlText => { 245 | const xml = new DOMParser().parseFromString(xmlText, 'text/xml'); 246 | 247 | const LINE_SELECTOR = 'tt > body > div > p'; 248 | const lines = [].map.call( 249 | xml.querySelectorAll(LINE_SELECTOR), 250 | (line, id) => { 251 | let text = ''; 252 | let extractTextRecur = parentNode => { 253 | [].forEach.call(parentNode.childNodes, node => { 254 | if (node.nodeType === Node.ELEMENT_NODE) 255 | if (node.nodeName.toLowerCase() === 'br') text += '\n'; 256 | else extractTextRecur(node); 257 | else if (node.nodeType === Node.TEXT_NODE) 258 | text += node.nodeValue + ' '; 259 | }); 260 | }; 261 | extractTextRecur(line); 262 | 263 | // convert microseconds to seconds 264 | const begin = parseInt(line.getAttribute('begin')) / 10000000; 265 | const end = parseInt(line.getAttribute('end')) / 10000000; 266 | return { id, begin, end, text }; 267 | } 268 | ); 269 | 270 | this.lines = lines; 271 | resolve(); 272 | }); 273 | }); 274 | } 275 | 276 | _render(lines, options) { 277 | // these magic numbers looks good on my screen XD 278 | const fontSize = Math.ceil(this.extentHeight / 30); 279 | 280 | // .join('\n').split('\n') seems redundant but it's done because speaker-based captions will not contain a \n to 281 | // indicate line breaks, instead they will come as individual elements in the lines array. Regular captions will 282 | // come as a single element with a \n. So this is to make sure all caption formats are split into lines correctly. 283 | const textContent = lines.map(line => line.text).join('\n').split('\n'); 284 | const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 285 | text.setAttributeNS(null, 'text-anchor', 'middle'); 286 | text.setAttributeNS(null, 'alignment-baseline', 'hanging'); 287 | text.setAttributeNS(null, 'dominant-baseline', 'hanging'); // firefox 288 | text.setAttributeNS(null, 'paint-order', 'stroke'); 289 | text.setAttributeNS(null, 'stroke', 'black'); 290 | text.setAttributeNS( 291 | null, 292 | 'stroke-width', 293 | `${1.0 * options.secondaryTextStroke}px` 294 | ); 295 | text.setAttributeNS(null, 'x', this.extentWidth * 0.5); 296 | text.setAttributeNS( 297 | null, 298 | 'y', 299 | this.extentHeight * (options.lowerBaselinePos + 0.01) 300 | ); 301 | text.setAttributeNS(null, 'opacity', options.secondaryTextOpacity); 302 | text.style.fontSize = `${fontSize * options.secondaryTextScale}px`; 303 | text.style.fontFamily = 'Arial, Helvetica'; 304 | text.style.fill = options.secondaryTextColor; 305 | text.style.stroke = 'black'; 306 | 307 | // tspan for line breaks 308 | textContent.forEach((line, i) => { 309 | const tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); 310 | tspan.setAttributeNS(null, 'x', this.extentWidth * 0.5); 311 | if (i > 0) tspan.setAttributeNS(null, 'dy', text.style.fontSize); 312 | tspan.textContent = line; 313 | text.appendChild(tspan); 314 | }); 315 | 316 | return [text]; 317 | } 318 | } 319 | 320 | class ImageSubtitle extends SubtitleBase { 321 | constructor(...args) { 322 | super(...args); 323 | this.zip = undefined; 324 | } 325 | 326 | _extract(fetchPromise) { 327 | return new Promise((resolve, reject) => { 328 | const unzipP = fetchPromise.then(r => r.blob()).then(zipBlob => new JSZip().loadAsync(zipBlob)); 329 | unzipP.then(zip => { 330 | zip 331 | .file('manifest_ttml2.xml') 332 | .async('string') 333 | .then(xmlText => { 334 | const xml = new DOMParser().parseFromString(xmlText, 'text/xml'); 335 | 336 | // dealing with `ns2:extent`, `ns3:extent`, ... 337 | const _getAttributeAnyNS = (domNode, attrName) => { 338 | const name = domNode.getAttributeNames().find( 339 | n => 340 | n 341 | .split(':') 342 | .pop() 343 | .toLowerCase() === attrName 344 | ); 345 | return domNode.getAttribute(name); 346 | }; 347 | 348 | const extent = _getAttributeAnyNS( 349 | xml.querySelector('tt'), 350 | 'extent' 351 | ); 352 | [this.extentWidth, this.extentHeight] = extent 353 | .split(' ') 354 | .map(n => parseInt(n)); 355 | 356 | const _ttmlTimeToSeconds = timestamp => { 357 | // e.g., _ttmlTimeToSeconds('00:00:06.005') -> 6.005 358 | const regex = /(\d+):(\d+):(\d+(?:\.\d+)?)/; 359 | const [hh, mm, sssss] = regex 360 | .exec(timestamp) 361 | .slice(1) 362 | .map(parseFloat); 363 | return hh * 3600 + mm * 60 + sssss; 364 | }; 365 | 366 | const LINE_SELECTOR = 'tt > body > div'; 367 | const lines = [].map.call( 368 | xml.querySelectorAll(LINE_SELECTOR), 369 | (line, id) => { 370 | const extentAttrName = line.getAttributeNames().find( 371 | n => 372 | n 373 | .split(':') 374 | .pop() 375 | .toLowerCase() === 'extent' 376 | ); 377 | 378 | const [width, height] = _getAttributeAnyNS(line, 'extent') 379 | .split(' ') 380 | .map(n => parseInt(n)); 381 | const [left, top] = _getAttributeAnyNS(line, 'origin') 382 | .split(' ') 383 | .map(n => parseInt(n)); 384 | const imageName = line 385 | .querySelector('image') 386 | .getAttribute('src'); 387 | const begin = _ttmlTimeToSeconds(line.getAttribute('begin')); 388 | const end = _ttmlTimeToSeconds(line.getAttribute('end')); 389 | return { id, width, height, top, left, imageName, begin, end }; 390 | } 391 | ); 392 | 393 | this.lines = lines; 394 | this.zip = zip; 395 | resolve(); 396 | }); 397 | }); 398 | }); 399 | } 400 | 401 | _render(lines, options) { 402 | const scale = options.secondaryImageScale; 403 | const centerLine = this.extentHeight * 0.5; 404 | const upperBaseline = this.extentHeight * options.upperBaselinePos; 405 | const lowerBaseline = this.extentHeight * options.lowerBaselinePos; 406 | return lines.map(line => { 407 | const img = document.createElementNS( 408 | 'http://www.w3.org/2000/svg', 409 | 'image' 410 | ); 411 | this.zip 412 | .file(line.imageName) 413 | .async('blob') 414 | .then(blob => { 415 | const { left, top, width, height } = line; 416 | const [newWidth, newHeight] = [width * scale, height * scale]; 417 | const newLeft = left + 0.5 * (width - newWidth); 418 | const newTop = top <= centerLine ? upperBaseline + gSecondaryOffset : lowerBaseline; 419 | 420 | const src = URL.createObjectURL(blob); 421 | img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', src); 422 | img.setAttributeNS(null, 'width', newWidth); 423 | img.setAttributeNS(null, 'height', newHeight); 424 | img.setAttributeNS(null, 'x', newLeft); 425 | img.setAttributeNS(null, 'y', newTop); 426 | img.setAttributeNS(null, 'opacity', options.secondaryImageOpacity); 427 | img.addEventListener('load', () => { 428 | URL.revokeObjectURL(src); 429 | }); 430 | }); 431 | return img; 432 | }); 433 | } 434 | } 435 | 436 | // ----------------------------------------------------------------------------- 437 | 438 | class SubtitleFactory { 439 | // track: manifest.textTracks[...] 440 | static build(track) { 441 | const isImageBased = Object.values(track.ttDownloadables).some(d => d.isImage); 442 | const isCaption = track.rawTrackType === 'closedcaptions'; 443 | const lang = track.languageDescription + (isCaption ? ' [CC]' : ''); 444 | const bcp47 = track.language; 445 | 446 | if (!track.hydrated) { 447 | return new DehydratedSubtitle(lang, bcp47); 448 | } 449 | if (isImageBased) { 450 | return this._buildImageBased(track, lang, bcp47, isCaption); 451 | } 452 | return this._buildTextBased(track, lang, bcp47, isCaption); 453 | } 454 | 455 | static isNoneTrack(track) { 456 | // Sometimes Netflix places "fake" text tracks into manifests. 457 | // Such tracks have "isNoneTrack: false" and even have downloadable URLs, 458 | // while their display name is "Off" (localized in UI language, e.g., "關閉"). 459 | // Here we use a huristic rule concluded by observation to filter those "fake" tracks out. 460 | if (track.isNoneTrack) { 461 | return true; 462 | } 463 | 464 | // "new_track_id" example "T:1:0;1;zh-Hant;1;1;" 465 | // the last bit is 1 for NoneTrack text tracks 466 | try { 467 | const isNoneTrackBit = track.new_track_id.split(';')[4]; 468 | if (isNoneTrackBit === '1') { 469 | return true; 470 | } 471 | } 472 | catch (err) { 473 | } 474 | 475 | // "rank" === -1 476 | if (track.rank !== undefined && track.rank < 0) { 477 | return true; 478 | } 479 | return false; 480 | } 481 | 482 | static _buildImageBased(track, lang, bcp47, isCaption) { 483 | const maxHeight = Math.max(...Object.values(track.ttDownloadables).map(d => { 484 | if (d.height) 485 | return d.height; 486 | else 487 | return -1; 488 | })); 489 | const d = Object.values(track.ttDownloadables).find(d => d.height === maxHeight); 490 | let urls; 491 | if (d.downloadUrls) { 492 | urls = Object.values(d.downloadUrls); 493 | } else { 494 | urls = d.urls.map(t => t.url); 495 | } 496 | return new ImageSubtitle(lang, bcp47, urls, isCaption); 497 | } 498 | 499 | static _buildTextBased(track, lang, bcp47, isCaption) { 500 | const targetProfile = 'dfxp-ls-sdh'; 501 | const d = track.ttDownloadables[targetProfile]; 502 | if (!d) { 503 | console.debug(`Cannot find "${targetProfile}" for ${lang}`); 504 | return null; 505 | } 506 | let urls; 507 | if (d.downloadUrls) { 508 | urls = Object.values(d.downloadUrls); 509 | } else { 510 | urls = d.urls.map(t => t.url); 511 | } 512 | return new TextSubtitle(lang, bcp47, urls, isCaption); 513 | } 514 | } 515 | 516 | // textTracks: manifest.textTracks 517 | const buildSubtitleList = textTracks => { 518 | const dummy = new DummySubtitle(); 519 | dummy.activate(); 520 | 521 | // sorted by language in alphabetical order (to align with official UI) 522 | const subs = textTracks 523 | .filter(t => !SubtitleFactory.isNoneTrack(t)) 524 | .map(t => SubtitleFactory.build(t)) 525 | .filter(t => t !== null); 526 | return subs.concat(dummy); 527 | }; 528 | 529 | // textTracks: manifest.textTracks 530 | const updateSubtitleList = (textTracks, textTrackId) => { 531 | const track = textTracks.find(t => t.new_track_id == textTrackId), 532 | sub = SubtitleFactory.build(track), 533 | index = gSubtitles.findIndex(s => s.lang == sub.lang); 534 | if (gSubtitles[index] instanceof DehydratedSubtitle && sub !== null) { 535 | gSubtitles[index] = sub; 536 | gSubtitleMenu && gSubtitleMenu.render(); 537 | } 538 | }; 539 | 540 | //////////////////////////////////////////////////////////////////////////////// 541 | 542 | const SUBTITLE_LIST_CLASSNAME = 'nflxmultisubs-subtitle-list'; 543 | const SUB_MENU_SELECTOR = 'selector-audio-subtitle'; 544 | class SubtitleMenu { 545 | constructor(node) { 546 | this.style = this.extractStyle(node) 547 | this.elem = document.createElement('div'); 548 | this.elem.classList.add(this.style.maindiv, 'structural', 'track-list-subtitles'); 549 | this.elem.classList.add(SUBTITLE_LIST_CLASSNAME); 550 | } 551 | 552 | extractStyle(node) { 553 | // get class names of all the sub menu elements 554 | // so we can apply them to our menu and copy their style 555 | const style = { maindiv: null, subdiv: null, h3: null, ul: null, li: null, selected: null } 556 | const mainNode = node.querySelector(`div[data-uia=${SUB_MENU_SELECTOR}]`) 557 | 558 | if (!mainNode) return style; 559 | 560 | style.maindiv = mainNode.firstChild?.className; 561 | style.subdiv = mainNode.querySelector('li div div')?.className; 562 | style.h3 = mainNode.querySelector('h3')?.className; 563 | style.ul = mainNode.querySelector('ul')?.className; 564 | style.li = mainNode.querySelector('li')?.className; 565 | style.selected = mainNode.querySelector('li[data-uia*="selected"] svg')?.className?.baseVal; // Netflix fuckery 566 | 567 | return style 568 | } 569 | 570 | render() { 571 | const checkIcon = ``; 572 | 573 | const loadingIcon = ` 574 | 575 | 576 | 577 | `; 578 | 579 | this.elem.innerHTML = `

Secondary Subtitles

`; 580 | 581 | const listElem = document.createElement('ul'); 582 | gSubtitles.forEach((sub, id) => { 583 | if (sub instanceof DehydratedSubtitle) return; 584 | let item = document.createElement('li'); 585 | item.classList.add(this.style.li); 586 | if (sub.active) { 587 | const icon = sub.state === 'LOADING' ? loadingIcon : checkIcon; 588 | item.classList.add('selected'); 589 | item.innerHTML = `
${icon}
${sub.lang}
`; 590 | } else { 591 | item.innerHTML = `
${sub.lang}
`; 592 | item.addEventListener('click', () => { 593 | activateSubtitle(id); 594 | }); 595 | } 596 | listElem.classList.add(this.style.ul); 597 | listElem.appendChild(item); 598 | }); 599 | const listWrapper = document.createElement('div'); 600 | listWrapper.style.overflowY = 'auto'; 601 | listWrapper.style.overflowX = 'hidden'; 602 | listWrapper.appendChild(listElem); 603 | this.elem.appendChild(listWrapper); 604 | } 605 | } 606 | 607 | // ----------------------------------------------------------------------------- 608 | 609 | const isPopupMenuElement = node => { 610 | return ( 611 | node.nodeName.toLowerCase() === 'div' && 612 | node.querySelector(`div[data-uia=${SUB_MENU_SELECTOR}]`) 613 | ); 614 | }; 615 | 616 | // FIXME: can we disconnect this observer once our menu is injected ? 617 | // we still don't know whether Netflix would re-build the pop-up menu after 618 | // switching to next episodes 619 | const bodyObserver = new MutationObserver(mutations => { 620 | mutations.forEach(mutation => { 621 | mutation.addedNodes.forEach(node => { 622 | if (isPopupMenuElement(node)) { 623 | // popup menu attached 624 | if (!node.getElementsByClassName(SUBTITLE_LIST_CLASSNAME).length) { 625 | if (!gSubtitleMenu) { 626 | gSubtitleMenu = new SubtitleMenu(node); 627 | gSubtitleMenu.render(); 628 | } 629 | node.style.left = "auto"; 630 | node.style.right = "10px"; 631 | node.querySelector(`div[data-uia=${SUB_MENU_SELECTOR}]`).appendChild(gSubtitleMenu.elem); 632 | } 633 | } 634 | }); 635 | mutation.removedNodes.forEach(node => { 636 | if (isPopupMenuElement(node)) { 637 | // popup menu detached 638 | } 639 | }); 640 | }); 641 | }); 642 | const observerOptions = { 643 | attributes: true, 644 | subtree: true, 645 | childList: true, 646 | characterData: true 647 | }; 648 | bodyObserver.observe(document.body, observerOptions); 649 | 650 | //////////////////////////////////////////////////////////////////////////////// 651 | 652 | activateSubtitle = id => { 653 | const sub = gSubtitles[id]; 654 | if (sub) { 655 | gSubtitles.forEach(sub => sub.deactivate()); 656 | sub.activate().then(() => { gSubtitleMenu && gSubtitleMenu.render(); }); 657 | 658 | gRenderOptions.secondaryLanguageLastUsed = sub.bcp47; 659 | gRenderOptions.secondaryLanguageLastUsedIsCaption = sub.isCaption; 660 | 661 | if (BROWSER !== 'firefox') { 662 | try { 663 | getMsgPort().postMessage({ settings: gRenderOptions }); 664 | } catch (err) { 665 | console.warn('Cannot dispatch settings,', err); 666 | } 667 | } else { 668 | // Firefox 669 | try { 670 | window.postMessage({ 671 | namespace: 'nflxmultisubs', 672 | action: 'update-settings', 673 | settings: gRenderOptions 674 | }, '*'); 675 | } catch (err) { 676 | console.warn('Error: cannot talk to background,', err); 677 | } 678 | } 679 | } 680 | gSubtitleMenu && gSubtitleMenu.render(); 681 | }; 682 | 683 | const buildSecondarySubtitleElement = options => { 684 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 685 | svg.classList.add('nflxmultisubs-subtitle-svg'); 686 | svg.style = 687 | 'position:absolute; width:100%; top:0; bottom:0; left:0; right:0;'; 688 | svg.setAttributeNS(null, 'width', '100%'); 689 | svg.setAttributeNS(null, 'height', '100%'); 690 | 691 | const padding = document.createElement('div'); 692 | padding.classList.add('nflxmultisubs-subtitle-padding'); 693 | padding.style = `display:block; content:' '; width:100%; padding-top:${gVideoRatio * 694 | 100}%;`; 695 | 696 | const container = document.createElement('div'); 697 | container.classList.add('nflxmultisubs-subtitle-container'); 698 | container.style = 'position:relative; width:100%; max-height:100%;'; 699 | container.appendChild(svg); 700 | container.appendChild(padding); 701 | 702 | const wrapper = document.createElement('div'); 703 | wrapper.classList.add('nflxmultisubs-subtitle-wrapper'); 704 | wrapper.style = 705 | 'position:absolute; top:0; left:0; width:100%; height:100%; z-index:2; display:flex; align-items:center;'; 706 | wrapper.appendChild(container); 707 | return wrapper; 708 | }; 709 | 710 | // ----------------------------------------------------------------------------- 711 | 712 | class PrimaryImageTransformer { 713 | constructor() { } 714 | 715 | transform(svgElem, controlsActive, forced) { 716 | const selector = forced ? 'image' : 'image:not(.nflxmultisubs-scaled)'; 717 | const images = svgElem.querySelectorAll(selector); 718 | if (images.length > 0) { 719 | const viewBox = svgElem.getAttributeNS(null, 'viewBox'); 720 | const [extentWidth, extentHeight] = viewBox 721 | .split(' ') 722 | .slice(-2) 723 | .map(n => parseInt(n)); 724 | 725 | // TODO: if there's no secondary subtitle, center the primary on baseline 726 | const options = gRenderOptions; 727 | const centerLine = extentHeight * 0.5; 728 | const upperBaseline = extentHeight * options.upperBaselinePos; 729 | const lowerBaseline = extentHeight * options.lowerBaselinePos; 730 | const scale = options.primaryImageScale; 731 | const opacity = options.primaryImageOpacity; 732 | const color = options.primaryTextColor; 733 | 734 | [].forEach.call(images, img => { 735 | img.classList.add('nflxmultisubs-scaled'); 736 | const left = parseInt( 737 | img.getAttributeNS(null, 'data-orig-x') || 738 | img.getAttributeNS(null, 'x') 739 | ); 740 | const top = parseInt( 741 | img.getAttributeNS(null, 'data-orig-y') || 742 | img.getAttributeNS(null, 'y') 743 | ); 744 | const width = parseInt( 745 | img.getAttributeNS(null, 'data-orig-width') || 746 | img.getAttributeNS(null, 'width') 747 | ); 748 | const height = parseInt( 749 | img.getAttributeNS(null, 'data-orig-height') || 750 | img.getAttributeNS(null, 'height') 751 | ); 752 | 753 | const attribs = [ 754 | ['x', left], 755 | ['y', top], 756 | ['width', width], 757 | ['height', height] 758 | ]; 759 | attribs.forEach(p => { 760 | const attrName = `data-orig-${p[0]}`, 761 | attrValue = p[1]; 762 | if (!img.getAttributeNS(null, attrName)) { 763 | img.setAttributeNS(null, attrName, attrValue); 764 | } 765 | }); 766 | 767 | const [newWidth, newHeight] = [width * scale, height * scale]; 768 | const newLeft = left + 0.5 * (width - newWidth); 769 | 770 | // large scale multi-line subs sometimes fall outside of the screen when they are placed at the top, 771 | // caused by newTop becoming negative (because newHeight is based on the subs scale) 772 | // subtracting newHeight/2 prevents this and makes it so that multiline subs are displayed at roughly 773 | // the same location as the single line subs when this happens. 774 | // gSecondaryOffset moves the secondary subtitles with it 775 | let newTop; 776 | 777 | if (top <= centerLine) { 778 | if (upperBaseline - newHeight <= 0) { 779 | newTop = upperBaseline - newHeight / 2 780 | gSecondaryOffset = newHeight / 2 781 | } else { 782 | newTop = upperBaseline - newHeight 783 | gSecondaryOffset = 0 784 | } 785 | } else { 786 | newTop = lowerBaseline - newHeight 787 | gSecondaryOffset = 0 788 | } 789 | 790 | // if it somehow still ends up negative just hard-constrain it 791 | // (we arbitrarily choose 10 to give it some space from the screen edge) 792 | newTop = (newTop <= 0) ? 10 : newTop; 793 | 794 | img.setAttributeNS(null, 'width', newWidth); 795 | img.setAttributeNS(null, 'height', newHeight); 796 | img.setAttributeNS(null, 'x', newLeft); 797 | img.setAttributeNS(null, 'y', newTop); 798 | img.setAttributeNS(null, 'opacity', opacity); 799 | img.setAttributeNS(null, 'color', color); 800 | }); 801 | } 802 | } 803 | } 804 | 805 | class PrimaryTextTransformer { 806 | constructor() { 807 | this.lastScaledPrimaryTextContent = undefined; 808 | } 809 | 810 | transform(divElem, controlsActive, forced) { 811 | let parentNode = divElem.parentNode; 812 | if (!parentNode.classList.contains('nflxmultisubs-primary-wrapper')) { 813 | // let's use `