diff options
57 files changed, 5456 insertions, 568 deletions
diff --git a/config.json.example b/config.json.example index 1912aaee..c5e86444 100644 --- a/config.json.example +++ b/config.json.example @@ -2,7 +2,7 @@ "serverAddress": "https://sponsor.ajay.app", "testingServerAddress": "https://sponsor.ajay.app/test", "serverAddressComment": "This specifies the default SponsorBlock server to connect to", - "categoryList": ["sponsor", "selfpromo", "exclusive_access", "interaction", "poi_highlight", "intro", "outro", "preview", "filler", "music_offtopic"], + "categoryList": ["sponsor", "selfpromo", "exclusive_access", "interaction", "poi_highlight", "intro", "outro", "preview", "filler", "chapter", "music_offtopic"], "categorySupport": { "sponsor": ["skip", "mute", "full"], "selfpromo": ["skip", "mute", "full"], @@ -13,7 +13,8 @@ "preview": ["skip", "mute"], "filler": ["skip", "mute"], "music_offtopic": ["skip"], - "poi_highlight": ["poi"] + "poi_highlight": ["poi"], + "chapter": ["chapter"] }, "wikiLinks": { "sponsor": "https://wiki.sponsor.ajay.app/w/Sponsor", @@ -27,6 +28,7 @@ "music_offtopic": "https://wiki.sponsor.ajay.app/w/Music:_Non-Music_Section", "poi_highlight": "https://wiki.sponsor.ajay.app/w/Highlight", "guidelines": "https://wiki.sponsor.ajay.app/w/Guidelines", - "mute": "https://wiki.sponsor.ajay.app/w/Mute_Segment" + "mute": "https://wiki.sponsor.ajay.app/w/Mute_Segment", + "chapter": "https://wiki.sponsor.ajay.app/w/Chapter" } } diff --git a/manifest/manifest.json b/manifest/manifest.json index f5a603e2..4e6f97d5 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -18,6 +18,7 @@ ], "css": [ "content.css", + "shared.css", "./libs/Source+Sans+Pro.css", "popup.css" ] @@ -48,9 +49,11 @@ "icons/beep.ogg", "icons/pause.svg", "icons/stop.svg", + "icons/skip.svg", "icons/heart.svg", "icons/visible.svg", "icons/not_visible.svg", + "icons/sort.svg", "icons/money.svg", "icons/segway.png", "icons/close-smaller.svg", @@ -61,6 +64,8 @@ "icons/bolt.svg", "icons/stopwatch.svg", "icons/music-note.svg", + "icons/import.svg", + "icons/export.svg", "icons/PlayerInfoIconSponsorBlocker.svg", "icons/PlayerDeleteIconSponsorBlocker.svg", "popup.html", diff --git a/package-lock.json b/package-lock.json index db45f657..e6a4ec01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "eslint-plugin-react": "^7.30.1", "fork-ts-checker-webpack-plugin": "^7.2.13", "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.0", "rimraf": "^3.0.2", "schema-utils": "^4.0.0", "selenium-webdriver": "^4.3.1", @@ -1621,6 +1622,15 @@ "integrity": "sha512-1c4ZOETSRpI0iBfIFUqU4KqwBAB2lHUAlBjZz/YqOHqwM9dTTzjV6Km0ZkiEiSCx/tLr1BtESIKyWWMww+RUqw==", "dev": true }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.8", "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", @@ -1803,6 +1813,17 @@ "pretty-format": "^28.0.0" } }, + "node_modules/@types/jsdom": { + "version": "16.2.14", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.14.tgz", + "integrity": "sha512-6BAy1xXEmMuHeAJ4Fv4yXKwBDTGTOseExKE3OaHiNycdHdZw59KfYzrt0DkDluvwmik1HRt6QS7bImxUmpSy+w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/parse5": "*", + "@types/tough-cookie": "*" + } + }, "node_modules/@types/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz", @@ -1840,6 +1861,12 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/parse5": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", + "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", + "dev": true + }, "node_modules/@types/prettier": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz", @@ -1900,6 +1927,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/tough-cookie": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", + "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", + "dev": true + }, "node_modules/@types/wicg-mediasession": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/wicg-mediasession/-/wicg-mediasession-1.1.3.tgz", @@ -2415,6 +2448,12 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2439,6 +2478,28 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2448,6 +2509,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/addons-linter": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-5.10.0.tgz", @@ -2796,6 +2866,45 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/addons-linter/node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/addons-linter/node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/addons-linter/node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/addons-linter/node_modules/semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -2823,6 +2932,18 @@ "node": ">=8" } }, + "node_modules/addons-linter/node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/addons-linter/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3531,6 +3652,12 @@ "node": ">=8" } }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, "node_modules/browserslist": { "version": "4.20.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", @@ -4407,6 +4534,30 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, "node_modules/csstype": { "version": "3.0.10", "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", @@ -4424,6 +4575,33 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "2.28.0", "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", @@ -4470,6 +4648,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", + "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", + "dev": true + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4680,6 +4864,18 @@ } ] }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -4951,6 +5147,37 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/eslint": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.20.0.tgz", @@ -5262,6 +5489,45 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/eslint/node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -5273,6 +5539,18 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -6296,6 +6574,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6327,6 +6617,20 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -6795,6 +7099,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.1.4", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", @@ -7621,6 +7931,25 @@ "node": ">=8" } }, + "node_modules/jest-environment-jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-28.1.0.tgz", + "integrity": "sha512-8n6P4xiDjNVqTWv6W6vJPuQdLx+ZiA3dbYg7YJ+DPzR+9B61K6pMVJrSs2IxfGRG4J7pyAUA5shQ9G0KEun78w==", + "dev": true, + "dependencies": { + "@jest/environment": "^28.1.0", + "@jest/fake-timers": "^28.1.0", + "@jest/types": "^28.1.0", + "@types/jsdom": "^16.2.4", + "@types/node": "*", + "jest-mock": "^28.1.0", + "jest-util": "^28.1.0", + "jsdom": "^19.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, "node_modules/jest-environment-node": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-28.1.3.tgz", @@ -8752,6 +9081,72 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "node_modules/jsdom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", + "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.5.0", + "acorn-globals": "^6.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.1", + "decimal.js": "^10.3.1", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^3.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^10.0.0", + "ws": "^8.2.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -8986,13 +9381,13 @@ } }, "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "dev": true, "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" }, "engines": { "node": ">= 0.8.0" @@ -9557,6 +9952,12 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -9710,17 +10111,17 @@ } }, "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "dev": true, "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" }, "engines": { "node": ">= 0.8.0" @@ -10111,9 +10512,9 @@ } }, "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "dev": true, "engines": { "node": ">= 0.8.0" @@ -10508,6 +10909,19 @@ "node": ">= 6" } }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/request/node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -10691,6 +11105,18 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.20.2", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", @@ -11346,6 +11772,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -11553,16 +11985,38 @@ } }, "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "dev": true, "dependencies": { - "psl": "^1.1.28", "punycode": "^2.1.1" }, "engines": { - "node": ">=0.8" + "node": ">=12" } }, "node_modules/tree-kill": { @@ -11821,12 +12275,12 @@ "dev": true }, "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", "dev": true, "dependencies": { - "prelude-ls": "^1.2.1" + "prelude-ls": "~1.1.2" }, "engines": { "node": ">= 0.8.0" @@ -12045,6 +12499,27 @@ "extsprintf": "^1.2.0" } }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", + "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -12195,6 +12670,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.74.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", @@ -12347,6 +12831,52 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", + "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/when": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", @@ -12565,6 +13095,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -12587,6 +13126,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13870,6 +14415,12 @@ "integrity": "sha512-1c4ZOETSRpI0iBfIFUqU4KqwBAB2lHUAlBjZz/YqOHqwM9dTTzjV6Km0ZkiEiSCx/tLr1BtESIKyWWMww+RUqw==", "dev": true }, + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true + }, "@tsconfig/node10": { "version": "1.0.8", "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", @@ -14052,6 +14603,17 @@ "pretty-format": "^28.0.0" } }, + "@types/jsdom": { + "version": "16.2.14", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.14.tgz", + "integrity": "sha512-6BAy1xXEmMuHeAJ4Fv4yXKwBDTGTOseExKE3OaHiNycdHdZw59KfYzrt0DkDluvwmik1HRt6QS7bImxUmpSy+w==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/parse5": "*", + "@types/tough-cookie": "*" + } + }, "@types/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz", @@ -14089,6 +14651,12 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/parse5": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", + "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", + "dev": true + }, "@types/prettier": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz", @@ -14149,6 +14717,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/tough-cookie": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", + "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", + "dev": true + }, "@types/wicg-mediasession": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/wicg-mediasession/-/wicg-mediasession-1.1.3.tgz", @@ -14526,6 +15100,12 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, "abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -14541,6 +15121,24 @@ "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==", "dev": true }, + "acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + }, + "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + } + } + }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -14548,6 +15146,12 @@ "dev": true, "requires": {} }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, "addons-linter": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-5.10.0.tgz", @@ -14815,6 +15419,36 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, "semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -14833,6 +15467,15 @@ "has-flag": "^4.0.0" } }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -15344,6 +15987,12 @@ "fill-range": "^7.0.1" } }, + "browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, "browserslist": { "version": "4.20.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", @@ -15988,6 +16637,29 @@ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", "dev": true }, + "cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + } + } + }, "csstype": { "version": "3.0.10", "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", @@ -16002,6 +16674,29 @@ "assert-plus": "^1.0.0" } }, + "data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "dependencies": { + "whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "requires": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + } + } + } + }, "date-fns": { "version": "2.28.0", "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", @@ -16027,6 +16722,12 @@ "integrity": "sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==", "dev": true }, + "decimal.js": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", + "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", + "dev": true + }, "decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -16176,6 +16877,15 @@ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "dev": true }, + "domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "requires": { + "webidl-conversions": "^7.0.0" + } + }, "domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -16380,6 +17090,27 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, + "escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, "eslint": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.20.0.tgz", @@ -16517,6 +17248,36 @@ "argparse": "^2.0.1" } }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, "supports-color": { "version": "7.2.0", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -16525,6 +17286,15 @@ "has-flag": "^4.0.0" } }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -17355,6 +18125,15 @@ "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", "dev": true }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -17379,6 +18158,17 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -17691,6 +18481,12 @@ "isobject": "^3.0.1" } }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "is-regex": { "version": "1.1.4", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", @@ -18290,6 +19086,22 @@ } } }, + "jest-environment-jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-28.1.0.tgz", + "integrity": "sha512-8n6P4xiDjNVqTWv6W6vJPuQdLx+ZiA3dbYg7YJ+DPzR+9B61K6pMVJrSs2IxfGRG4J7pyAUA5shQ9G0KEun78w==", + "dev": true, + "requires": { + "@jest/environment": "^28.1.0", + "@jest/fake-timers": "^28.1.0", + "@jest/types": "^28.1.0", + "@types/jsdom": "^16.2.4", + "@types/node": "*", + "jest-mock": "^28.1.0", + "jest-util": "^28.1.0", + "jsdom": "^19.0.0" + } + }, "jest-environment-node": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-28.1.3.tgz", @@ -19142,6 +19954,60 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "jsdom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", + "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "acorn": "^8.5.0", + "acorn-globals": "^6.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.1", + "decimal.js": "^10.3.1", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^3.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^10.0.0", + "ws": "^8.2.3", + "xml-name-validator": "^4.0.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -19333,13 +20199,13 @@ "dev": true }, "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "dev": true, "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" } }, "lie": { @@ -19795,6 +20661,12 @@ "boolbase": "^1.0.0" } }, + "nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -19900,17 +20772,17 @@ } }, "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "dev": true, "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" } }, "os-locale": { @@ -20189,9 +21061,9 @@ } }, "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "dev": true }, "pretty-format": { @@ -20507,6 +21379,16 @@ "uuid": "^3.3.2" }, "dependencies": { + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -20644,6 +21526,15 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true }, + "saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "scheduler": { "version": "0.20.2", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", @@ -21154,6 +22045,12 @@ } } }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -21302,12 +22199,30 @@ "dev": true }, "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" + }, + "dependencies": { + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + } + } + }, + "tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "dev": true, "requires": { - "psl": "^1.1.28", "punycode": "^2.1.1" } }, @@ -21469,12 +22384,12 @@ "dev": true }, "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", "dev": true, "requires": { - "prelude-ls": "^1.2.1" + "prelude-ls": "~1.1.2" } }, "type-detect": { @@ -21633,6 +22548,24 @@ "extsprintf": "^1.2.0" } }, + "w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "requires": { + "browser-process-hrtime": "^1.0.0" + } + }, + "w3c-xmlserializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", + "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "dev": true, + "requires": { + "xml-name-validator": "^4.0.0" + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -21742,6 +22675,12 @@ } } }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, "webpack": { "version": "5.74.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", @@ -21838,6 +22777,42 @@ "wildcard": "^2.0.0" } }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true + }, + "whatwg-url": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", + "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", + "dev": true, + "requires": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + } + }, "when": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", @@ -21989,6 +22964,12 @@ "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "dev": true }, + "xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true + }, "xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -22005,6 +22986,12 @@ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "dev": true }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 1465d561..775e412b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "eslint-plugin-react": "^7.30.1", "fork-ts-checker-webpack-plugin": "^7.2.13", "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.0", "rimraf": "^3.0.2", "schema-utils": "^4.0.0", "selenium-webdriver": "^4.3.1", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index fe3176f1..ae14284a 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -25,6 +25,16 @@ "Segments": { "message": "segments" }, + "SegmentsCap": { + "message": "Segments" + }, + "Chapters": { + "message": "Chapters" + }, + "renderAsChapters": { + "message": "Render segments as chapters", + "description": "Refers to drawing segments on the YouTube seek bar as split up chapters, similar to the existing chapter system" + }, "upvoteButtonInfo": { "message": "Upvote this submission" }, @@ -289,6 +299,14 @@ "message": "Submit segments", "description": "Keybind label" }, + "nextChapterKeybind": { + "message": "Next chapter", + "description": "Keybind label" + }, + "previousChapterKeybind": { + "message": "Previous chapter", + "description": "Keybind label" + }, "keybindDescription": { "message": "Select a key by typing it and choose any modifier keys you wish to use." }, @@ -545,6 +563,10 @@ "message": "to", "description": "Used between segments. Example: 1:20 to 1:30" }, + "CopiedExclamation": { + "message": "Copied!", + "description": "Used after something has been copied to the clipboard. Example: 'Copied!'" + }, "generic_guideline1": { "message": "Include segue transitions" }, @@ -696,6 +718,21 @@ "category_poi_highlight_guideline3": { "message": "Can skip to the title or thumbnail" }, + "category_chapter": { + "message": "Chapter" + }, + "category_chapter_description": { + "message": "Custom named chapters describing major sections of a video." + }, + "category_chapter_guideline1": { + "message": "Don't mention sponsor brand names" + }, + "category_chapter_guideline2": { + "message": "Use larger chapters for general sections" + }, + "category_chapter_guideline3": { + "message": "Smaller chapters can be placed inside larger ones" + }, "category_livestream_messages": { "message": "Livestream: Donation/Message Readings" }, @@ -726,6 +763,9 @@ "showOverlay_full": { "message": "Show Label" }, + "showOverlay_chapter": { + "message": "Show Chapters" + }, "autoSkipOnMusicVideos": { "message": "Auto skip all segments when there is a non-music segment" }, @@ -781,6 +821,10 @@ "bracketEnd": { "message": "(End)" }, + "End": { + "message": "End", + "description": "Button that skips to the end of a segment" + }, "hiddenDueToDownvote": { "message": "hidden: downvote" }, @@ -821,6 +865,13 @@ "downvoteDescription": { "message": "Incorrect/Wrong Timing" }, + "incorrectVote": { + "message": "Incorrect" + }, + "harmfulVote": { + "message": "Harmful", + "description": "Used for chapter segments when the text is harmful/offensive to remove it faster" + }, "incorrectCategory": { "message": "Change Category" }, @@ -856,6 +907,9 @@ "categoryPillTitleText": { "message": "This entire video is labeled as this category and is too tightly integrated to be able to separate" }, + "chapterNameTooltipWarning": { + "message": "One of your chapter names is similar to a category. You should use categories when possible instead." + }, "experiementOptOut": { "message": "Opt-out of all future experiments", "description": "This is used in a popup about a new experiment to get a list of unlisted videos to back up since all unlisted videos uploaded before 2017 will be set to private." @@ -1039,5 +1093,62 @@ }, "confirmResetToDefault": { "message": "Are you sure you want to reset all settings to their default values? This cannot be undone." + }, + "exportSegments": { + "message": "Export segments" + }, + "importSegments": { + "message": "Import chapters" + }, + "Import": { + "message": "Import", + "description": "Button to initiate importing segments. Appears under the textbox where they paste in the data" + }, + "redeemSuccess": { + "message": "Reedem Successful!" + }, + "redeemFailed": { + "message": "License key is invalid" + }, + "hideUpsells": { + "message": "Hide options not available without extra payment" + }, + "chooseACountry": { + "message": "Choose a country" + }, + "noDiscount": { + "message": "You do not qualify for a discount" + }, + "discountLink": { + "message": "Discount Link (See the pink price)" + }, + "selectYourCountry": { + "message": "Select your country" + }, + "alreadyDonated": { + "message": "If you've donated any amount before now, you may redeem free access by emailing:", + "description": "After the colon is an email address" + }, + "cantAfford": { + "message": "If you can't afford to purchase a license, click {here} to see if you are eligible for a discount", + "description": "Keep the curly braces" + }, + "patreonSignIn": { + "message": "Sign in with Patreon" + }, + "redeem": { + "message": "Redeem" + }, + "joinOnPatreon": { + "message": "Subscribe on Patreon" + }, + "oneTimePurchase": { + "message": "One Time Purchase" + }, + "enterLicenseKey": { + "message": "Enter License Key" + }, + "chaptersPage1": { + "message": "SponsorBlock crowd-sourced chapters feature is only available to people who purchase a license, or for people who are granted access for free due their past contributions" } } diff --git a/public/content.css b/public/content.css index a4d2dfbd..da79f74d 100644 --- a/public/content.css +++ b/public/content.css @@ -1,3 +1,12 @@ +:root { + --skip-notice-right: 10px; + --skip-notice-padding: 5px; + --skip-notice-margin: 5px; + --skip-notice-border-horizontal: 5px; + --skip-notice-border-vertical: 10px; + --sb-dark-red-outline: rgb(130,0,0,0.9); +} + .hidden { display: none; } @@ -12,7 +21,7 @@ height: 100%; transform: scaleY(0.6) translateY(-30%) translateY(1.5px); - z-index: 40; + z-index: 42; transition: transform .1s cubic-bezier(0,0,0.2,1); } @@ -45,23 +54,48 @@ transform: translateY(-1em) !important; } +.ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips { + transform: translateY(-2em) !important; +} + .ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible { transform: translateY(-2em) !important; } +.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips { + transform: translateY(-4em) !important; +} + #movie_player:not(.ytp-big-mode) .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper { transform: translateY(1em) !important; } +#movie_player:not(.ytp-big-mode) .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper { + transform: translateY(2em) !important; +} + .ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper { transform: translateY(0.5em) !important; } +.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper { + transform: translateY(1em) !important; +} + .ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper > .ytp-tooltip-text { display: block !important; transform: translateY(1em) !important; } +.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper > .ytp-tooltip-text { + display: block !important; + transform: translateY(2em) !important; +} + +div:hover > .sponsorBlockChapterBar { + z-index: 41 !important; +} + /* */ .popup { @@ -88,6 +122,16 @@ vertical-align: top; } +/* Removes auto width from being a ytp-player-button */ +.sbPlayerDownvote { + width: auto !important; +} + +/* Adds back the padding */ +.sbPlayerDownvote svg { + padding-right: 3.6px; +} + .autoHiding { overflow: visible !important; } @@ -113,8 +157,8 @@ .sponsorSkipObject { font-family: Roboto, Arial, Helvetica, sans-serif; - margin-left: 2px; - margin-right: 2px; + margin-left: var(--skip-notice-margin); + margin-right: var(--skip-notice-margin); } .sponsorSkipLogo { @@ -145,7 +189,7 @@ position: absolute; right: 5px; bottom: 100px; - right: 10px; + right: var(--skip-notice-right); } .sponsorSkipNoticeParent { @@ -525,7 +569,7 @@ input::-webkit-inner-spin-button { margin-bottom: 5px; background-color: rgba(28, 28, 28, 0.9); - border-color: rgb(130,0,0,0.9); + border-color: var(--sb-dark-red-outline); color: white; border-width: 3px; padding: 3px; @@ -536,6 +580,45 @@ input::-webkit-inner-spin-button { color: white; } +/* Start SelectorComponent */ + +.sbSelector { + position: absolute; + text-align: center; + width: calc(100% - var(--skip-notice-right) - var(--skip-notice-padding) * 2 - var(--skip-notice-margin) * 2 - var(--skip-notice-border-horizontal) * 2); + + z-index: 1000; +} + +.sbSelectorBackground { + text-align: center; + + background-color: rgba(28, 28, 28, 0.9); + border-radius: 6px; + padding: 3px; + margin: auto; + width: 170px; +} + +.sbSelectorOption { + cursor: pointer; + background-color: rgb(43, 43, 43); + padding: 5px; + margin: 5px; + color: white; + border-radius: 5px; + font-size: 14px; + + margin-left: auto; + margin-right: auto; +} + +.sbSelectorOption:hover { + background-color: #3a0000; +} + +/* End SelectorComponent */ + .helpButton { height: 25px; cursor: pointer; @@ -623,6 +706,11 @@ input::-webkit-inner-spin-button { border-color: rgba(28, 28, 28, 0.7) transparent transparent transparent; } +.sponsorBlockTooltip.sbTriangle.centeredSBTriangle::after { + left: 50%; + right: 50%; +} + .sponsorBlockLockedColor { color: #ffc83d; } diff --git a/public/icons/export.svg b/public/icons/export.svg new file mode 100644 index 00000000..337823ab --- /dev/null +++ b/public/icons/export.svg @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + version="1.1" + id="Capa_1" + x="0px" + y="0px" + viewBox="0 0 67.671 67.671" + style="enable-background:new 0 0 67.671 67.671;" + xml:space="preserve" + sodipodi:docname="export.svg" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"><defs + id="defs41" /><sodipodi:namedview + id="namedview39" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + inkscape:zoom="9.309749" + inkscape:cx="33.835499" + inkscape:cy="16.649214" + inkscape:window-width="1366" + inkscape:window-height="731" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="Capa_1" /> +<g + id="g6" + style="fill:#ffffff"> + <path + d="M 52.946,23.348 H 42.834 v 6 h 10.112 c 3.007,0 5.34,1.536 5.34,2.858 v 26.606 c 0,1.322 -2.333,2.858 -5.34,2.858 H 14.724 c -3.007,0 -5.34,-1.536 -5.34,-2.858 V 32.207 c 0,-1.322 2.333,-2.858 5.34,-2.858 h 10.11 v -6 h -10.11 c -6.359,0 -11.34,3.891 -11.34,8.858 v 26.606 c 0,4.968 4.981,8.858 11.34,8.858 h 38.223 c 6.358,0 11.34,-3.891 11.34,-8.858 V 32.207 C 64.286,27.239 59.305,23.348 52.946,23.348 Z" + id="path2" + style="fill:#ffffff" /> + <path + d="m 24.957,14.955 c 0.768,0 1.535,-0.293 2.121,-0.879 l 3.756,-3.756 v 13.028 6 11.494 c 0,1.657 1.343,3 3,3 1.657,0 3,-1.343 3,-3 v -11.494 -6 -13.231 l 3.959,3.959 c 0.586,0.586 1.354,0.879 2.121,0.879 0.767,0 1.535,-0.293 2.121,-0.879 1.172,-1.171 1.172,-3.071 0,-4.242 L 36.078,0.877 C 35.492,0.291 34.725,0 33.958,0 33.95,0 33.943,0 33.935,0 33.927,0 33.92,0 33.912,0 33.145,0 32.378,0.291 31.792,0.877 l -8.957,8.957 c -1.172,1.171 -1.172,3.071 0,4.242 0.587,0.586 1.354,0.879 2.122,0.879 z" + id="path4" + style="fill:#ffffff" /> +</g> +<g + id="g8" + style="fill:#ffffff"> +</g> +<g + id="g10" + style="fill:#ffffff"> +</g> +<g + id="g12" + style="fill:#ffffff"> +</g> +<g + id="g14" + style="fill:#ffffff"> +</g> +<g + id="g16" + style="fill:#ffffff"> +</g> +<g + id="g18" + style="fill:#ffffff"> +</g> +<g + id="g20" + style="fill:#ffffff"> +</g> +<g + id="g22" + style="fill:#ffffff"> +</g> +<g + id="g24" + style="fill:#ffffff"> +</g> +<g + id="g26" + style="fill:#ffffff"> +</g> +<g + id="g28" + style="fill:#ffffff"> +</g> +<g + id="g30" + style="fill:#ffffff"> +</g> +<g + id="g32" + style="fill:#ffffff"> +</g> +<g + id="g34" + style="fill:#ffffff"> +</g> +<g + id="g36" + style="fill:#ffffff"> +</g> +</svg> diff --git a/public/icons/import.svg b/public/icons/import.svg new file mode 100644 index 00000000..b1692f37 --- /dev/null +++ b/public/icons/import.svg @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + version="1.1" + id="Capa_1" + x="0px" + y="0px" + viewBox="0 0 67.671 67.671" + style="enable-background:new 0 0 67.671 67.671;" + xml:space="preserve" + sodipodi:docname="import.svg" + inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"><defs + id="defs41" /><sodipodi:namedview + id="namedview39" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + inkscape:zoom="9.309749" + inkscape:cx="33.835499" + inkscape:cy="33.835499" + inkscape:window-width="1920" + inkscape:window-height="983" + inkscape:window-x="482" + inkscape:window-y="768" + inkscape:window-maximized="1" + inkscape:current-layer="g6" /> +<g + id="g6"> + <path + d="M52.946,23.348H42.834v6h10.112c3.007,0,5.34,1.536,5.34,2.858v26.606c0,1.322-2.333,2.858-5.34,2.858H14.724 c-3.007,0-5.34-1.536-5.34-2.858V32.207c0-1.322,2.333-2.858,5.34-2.858h10.11v-6h-10.11c-6.359,0-11.34,3.891-11.34,8.858v26.606 c0,4.968,4.981,8.858,11.34,8.858h38.223c6.358,0,11.34-3.891,11.34-8.858V32.207C64.286,27.239,59.305,23.348,52.946,23.348z" + id="path2" + style="fill:#ffffff" /> + <path + d="m 42.913,34.887 c -0.768,0 -1.370265,0.528017 -2.121,0.879 l -3.756,3.756 v -19.028 -6 V 3 c 0,-1.657 -1.343,-3 -3,-3 -1.657,0 -3,1.343 -3,3 v 11.494 12 13.231 l -3.959,-3.959 c -0.586,-0.586 -1.354,-0.879 -2.121,-0.879 -0.767,0 -1.535,0.293 -2.121,0.879 -1.172,1.171 -1.172,3.071 0,4.242 l 8.957,8.957 c 0.586,0.586 1.353,0.877 2.12,0.877 h 0.023 0.023 c 0.767,0 1.534,-0.291 2.12,-0.877 l 8.957,-8.957 c 1.172,-1.171 1.172,-3.071 0,-4.242 -0.587,-0.586 -1.354,-0.879 -2.122,-0.879 z" + id="path4" + sodipodi:nodetypes="sscccssscccssccsscssccs" + style="fill:#ffffff" /> +</g> +<g + id="g8"> +</g> +<g + id="g10"> +</g> +<g + id="g12"> +</g> +<g + id="g14"> +</g> +<g + id="g16"> +</g> +<g + id="g18"> +</g> +<g + id="g20"> +</g> +<g + id="g22"> +</g> +<g + id="g24"> +</g> +<g + id="g26"> +</g> +<g + id="g28"> +</g> +<g + id="g30"> +</g> +<g + id="g32"> +</g> +<g + id="g34"> +</g> +<g + id="g36"> +</g> +</svg> diff --git a/public/icons/skip.svg b/public/icons/skip.svg new file mode 100644 index 00000000..774e741c --- /dev/null +++ b/public/icons/skip.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>
\ No newline at end of file diff --git a/public/icons/sort.svg b/public/icons/sort.svg new file mode 100644 index 00000000..29c6d16f --- /dev/null +++ b/public/icons/sort.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
\ No newline at end of file diff --git a/public/options/options.css b/public/options/options.css index a2a829a1..2bd27161 100644 --- a/public/options/options.css +++ b/public/options/options.css @@ -123,6 +123,14 @@ html, body { border-image: linear-gradient(to right, var(--border-color), #00000000 80%) 1; } +.categoryExtraOptions { + padding-bottom: 20px; +} + +#music_offtopic_autoSkipOnMusicVideos { + padding-bottom: 0; +} + .option-group > div:last-child, .option-group > #keybind-dialog { border-bottom: inherit; } @@ -309,6 +317,10 @@ input[type='number'] { color: grey; } +tr.disabled { + opacity: 0.3; +} + #options { height: 100vh; flex-basis: 80%; @@ -670,4 +682,9 @@ svg { #options > div { max-width: 100%; } +} + +.upsellButton { + cursor: pointer; + vertical-align: middle; }
\ No newline at end of file diff --git a/public/options/options.html b/public/options/options.html index d50f9c5c..765134ec 100644 --- a/public/options/options.html +++ b/public/options/options.html @@ -66,18 +66,6 @@ </div> - <div data-type="toggle" data-sync="autoSkipOnMusicVideos"> - <div class="switch-container"> - <label class="switch"> - <input id="autoSkipOnMusicVideos" type="checkbox" checked> - <span class="slider round"></span> - </label> - <label class="switch-label" for="autoSkipOnMusicVideos"> - __MSG_autoSkipOnMusicVideos__ - </label> - </div> - </div> - <div data-type="toggle" data-sync="muteSegments"> <div class="switch-container"> <label class="switch"> @@ -314,6 +302,18 @@ </div> </div> + <div data-type="toggle" data-toggle-type="reverse" data-sync="showUpsells" data-no-safari="true"> + <div class="switch-container"> + <label class="switch"> + <input id="showUpsell" type="checkbox" checked> + <span class="slider round"></span> + </label> + <label class="switch-label" for="showUpsells"> + __MSG_hideUpsells__ + </label> + </div> + </div> + </div> <div id="keybinds" class="option-group hidden"> @@ -333,6 +333,16 @@ <div class="inline"></div> </div> + <div data-type="keybind-change" data-sync="nextChapterKeybind"> + <label class="optionLabel">__MSG_nextChapterKeybind__:</label> + <div class="inline"></div> + </div> + + <div data-type="keybind-change" data-sync="previousChapterKeybind"> + <label class="optionLabel">__MSG_previousChapterKeybind__:</label> + <div class="inline"></div> + </div> + </div> <div id="import" class="option-group hidden"> diff --git a/public/popup.css b/public/popup.css index f314ae03..e2494158 100644 --- a/public/popup.css +++ b/public/popup.css @@ -152,22 +152,46 @@ margin: 8px; } -/* - * Refresh segments button - */ #refreshSegmentsButton { display: flex; align-items: center; + padding: 5px; + margin: 5px auto; +} + +#issueReporterImportExport { + position: relative; +} + +#refreshSegmentsButton, #issueReporterImportExport button { background: transparent; border-radius: 50%; - margin: 5px auto; border: none; - padding: 5px; } -#refreshSegmentsButton:hover { + +#refreshSegmentsButton:hover, #issueReporterImportExport button:hover { background-color: var(--sb-grey-bg-color); } +#issueReporterImportExport button { + padding: 5px; + margin-right: 15px; + margin-left: 15px; +} + +#issueReporterImportExport img { + width: 24px; + display: block; +} + +#importSegmentsText { + margin-top: 7px; +} + +#importSegmentsMenu button { + padding: 10px; +} + /* * <details> wrapper around each segment */ @@ -199,6 +223,15 @@ .segmentSummary > div { text-align: left; } + +.segmentActive { + color: #bdfffb; +} + +.segmentPassed { + color: #adadad; +} + /* * Category dot in segment */ @@ -560,3 +593,45 @@ margin-bottom: 20px; padding: 5px; } + +#sponsorBlockPopupBody .u-mZ { + margin: 0 !important; +} + +#sponsorBlockPopupBody .hidden { + display: none !important; +} + +#issueReporterTabs { + margin: 5px; +} + +#issueReporterTabs > span { + padding: 2px 4px; + margin: 0 3px; + cursor: pointer; + background-color: #444848; + border-radius: 10px; +} + +#issueReporterTabs > span > span { + position: relative; + padding: 0.2em 0; +} + +#issueReporterTabs > span > span::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 0.1em; + background-color: rgb(145, 0, 0); + transition: transform 300ms; + transform: scaleX(0); + transform-origin: center; +} + +#issueReporterTabs > span.sbSelected > span::after { + transform: scaleX(0.8); +}
\ No newline at end of file diff --git a/public/popup.html b/public/popup.html index 48cdeea1..a31269c6 100644 --- a/public/popup.html +++ b/public/popup.html @@ -6,6 +6,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <link id="sponsorBlockPopupFont" href="/libs/Source+Sans+Pro.css" rel="stylesheet"> <link id="sponsorBlockStyleSheet" href="popup.css" rel="stylesheet"> + <link id="sponsorBlockStyleSheet" href="shared.css" rel="stylesheet"> </head> <body id="sponsorBlockPopupBody" style="visibility: hidden"> @@ -34,7 +35,33 @@ </button> <!-- Video Segments --> <div id="issueReporterContainer"> + <div id="issueReporterTabs" class="hidden"> + <span id="issueReporterTabSegments" class="sbSelected"> + <span>__MSG_SegmentsCap__</span> + </span> + <span id="issueReporterTabChapters"> + <span>__MSG_Chapters__</span> + </span> + </div> <div id="issueReporterTimeButtons"></div> + <div id="issueReporterImportExport" class="hidden"> + <div id="importExportButtons"> + <button id="importSegmentsButton" title="__MSG_importSegments__" class="hidden"> + <img src="/icons/import.svg" alt="Refresh icon" id="importSegments" /> + </button> + <button id="exportSegmentsButton" title="__MSG_exportSegments__"> + <img src="/icons/export.svg" alt="Export icon" id="exportSegments" /> + </button> + </div> + + <span id="importSegmentsMenu" class="hidden"> + <textarea id="importSegmentsText" rows="5" style="width:80%"></textarea> + + <button id="importSegmentsSubmit" title="__MSG_importSegments__"> + __MSG_Import__ + </button> + </span> + </div> </div> </div> diff --git a/public/res/countries.json b/public/res/countries.json new file mode 100644 index 00000000..33839f29 --- /dev/null +++ b/public/res/countries.json @@ -0,0 +1 @@ +{"Albania":{"allowed":true},"Algeria":{"allowed":true},"Angola":{"allowed":true},"Argentina":{"allowed":true},"Armenia":{"allowed":true},"Australia":{"allowed":false},"Austria":{"allowed":false},"Azerbaijan":{"allowed":true},"Bangladesh":{"allowed":true},"Belarus":{"allowed":true},"Belgium":{"allowed":false},"Belize":{"allowed":true},"Benin":{"allowed":true},"Bhutan":{"allowed":true},"Bolivia":{"allowed":true},"Bosnia and Herzegovina":{"allowed":true},"Botswana":{"allowed":true},"Brazil":{"allowed":true},"Bulgaria":{"allowed":true},"Burkina Faso":{"allowed":true},"Burundi":{"allowed":true},"Cameroon":{"allowed":true},"Canada":{"allowed":false},"Central African Republic":{"allowed":true},"Chad":{"allowed":true},"Chile":{"allowed":true},"China":{"allowed":true},"Colombia":{"allowed":true},"Comoros":{"allowed":true},"Costa Rica":{"allowed":true},"Croatia":{"allowed":true},"Cyprus":{"allowed":false},"Czech Republic":{"allowed":false},"Denmark":{"allowed":false},"Djibouti":{"allowed":true},"Dominican Republic":{"allowed":true},"DR Congo":{"allowed":true},"Ecuador":{"allowed":true},"Egypt":{"allowed":true},"El Salvador":{"allowed":true},"Estonia":{"allowed":false},"Eswatini":{"allowed":true},"Ethiopia":{"allowed":true},"Fiji":{"allowed":true},"Finland":{"allowed":false},"France":{"allowed":false},"Gabon":{"allowed":true},"Gambia":{"allowed":true},"Georgia":{"allowed":true},"Germany":{"allowed":false},"Ghana":{"allowed":true},"Greece":{"allowed":true},"Guatemala":{"allowed":true},"Guinea":{"allowed":true},"Guinea-Bissau":{"allowed":true},"Guyana":{"allowed":true},"Haiti":{"allowed":true},"Honduras":{"allowed":true},"Hungary":{"allowed":true},"Iceland":{"allowed":false},"India":{"allowed":true},"Iran":{"allowed":true},"Iraq":{"allowed":true},"Ireland":{"allowed":false},"Israel":{"allowed":false},"Italy":{"allowed":false},"Ivory Coast":{"allowed":true},"Jamaica":{"allowed":true},"Japan":{"allowed":false},"Jordan":{"allowed":true},"Kazakhstan":{"allowed":true},"Kenya":{"allowed":true},"Kiribati":{"allowed":true},"Kyrgyzstan":{"allowed":true},"Laos":{"allowed":true},"Latvia":{"allowed":true},"Lebanon":{"allowed":true},"Lesotho":{"allowed":true},"Liberia":{"allowed":true},"Lithuania":{"allowed":true},"Luxembourg":{"allowed":false},"Madagascar":{"allowed":true},"Malawi":{"allowed":true},"Malaysia":{"allowed":true},"Maldives":{"allowed":true},"Mali":{"allowed":true},"Malta":{"allowed":false},"Mauritania":{"allowed":true},"Mauritius":{"allowed":true},"Mexico":{"allowed":true},"Micronesia":{"allowed":true},"Moldova":{"allowed":true},"Mongolia":{"allowed":true},"Montenegro":{"allowed":true},"Morocco":{"allowed":true},"Mozambique":{"allowed":true},"Myanmar":{"allowed":true},"Namibia":{"allowed":true},"Nepal":{"allowed":true},"Netherlands":{"allowed":false},"Nicaragua":{"allowed":true},"Niger":{"allowed":true},"Nigeria":{"allowed":true},"North Macedonia":{"allowed":true},"Norway":{"allowed":false},"Pakistan":{"allowed":true},"Panama":{"allowed":true},"Papua New Guinea":{"allowed":true},"Paraguay":{"allowed":true},"Peru":{"allowed":true},"Philippines":{"allowed":true},"Poland":{"allowed":true},"Portugal":{"allowed":true},"Republic of the Congo":{"allowed":true},"Romania":{"allowed":true},"Russia":{"allowed":true},"Rwanda":{"allowed":true},"Saint Lucia":{"allowed":true},"Samoa":{"allowed":true},"Sao Tome and Principe":{"allowed":true},"Senegal":{"allowed":true},"Serbia":{"allowed":true},"Seychelles":{"allowed":true},"Sierra Leone":{"allowed":true},"Slovakia":{"allowed":true},"Slovenia":{"allowed":false},"Solomon Islands":{"allowed":true},"South Africa":{"allowed":true},"South Korea":{"allowed":false},"South Sudan":{"allowed":true},"Spain":{"allowed":false},"Sri Lanka":{"allowed":true},"Sudan":{"allowed":true},"Suriname":{"allowed":true},"Sweden":{"allowed":false},"Switzerland":{"allowed":false},"Syria":{"allowed":true},"Taiwan":{"allowed":false},"Tajikistan":{"allowed":true},"Tanzania":{"allowed":true},"Thailand":{"allowed":true},"Timor-Leste":{"allowed":true},"Togo":{"allowed":true},"Tonga":{"allowed":true},"Trinidad and Tobago":{"allowed":true},"Tunisia":{"allowed":true},"Turkey":{"allowed":true},"Turkmenistan":{"allowed":true},"Tuvalu":{"allowed":true},"Uganda":{"allowed":true},"Ukraine":{"allowed":true},"United Arab Emirates":{"allowed":false},"United Kingdom":{"allowed":false},"United States":{"allowed":false},"Uruguay":{"allowed":true},"Uzbekistan":{"allowed":true},"Vanuatu":{"allowed":true},"Venezuela":{"allowed":true},"Vietnam":{"allowed":true},"Yemen":{"allowed":true},"Zambia":{"allowed":true},"Zimbabwe":{"allowed":true}}
\ No newline at end of file diff --git a/public/shared.css b/public/shared.css new file mode 100644 index 00000000..5cb53f88 --- /dev/null +++ b/public/shared.css @@ -0,0 +1,219 @@ +.sponsorSkipNoticeParent { + position: absolute; + + bottom: 100px; + right: var(--skip-notice-right); +} + +.sponsorSkipNoticeParent, .sponsorSkipNotice { + border-spacing: var(--skip-notice-border-horizontal) var(--skip-notice-border-vertical); + padding-left: var(--skip-notice-padding); + padding-right: var(--skip-notice-padding); + + border-collapse: unset; +} + +.sponsorSkipNoticeParent { + min-width: 350px; + max-width: 50%; +} + +.sponsorSkipNotice { + width: 100%; +} + +.sponsorSkipNoticeTableContainer { + background-color: rgba(28, 28, 28, 0.9); + border-radius: 5px; + min-width: 100%; +} + +.sponsorSkipNotice { + transition: all 0.1s ease-out; +} + +.sponsorSkipNoticeLimitWidth { + max-width: calc(100% - 50px); +} + +.sponsorSkipNotice .hidden { + display: none; +} + +/* For Cloudtube */ +.sponsorSkipNotice td, .sponsorSkipNotice table, .sponsorSkipNotice th { + border: none; +} + +.sponsorSkipNoticeFadeIn { + animation: fadeIn 0.5s ease-out; +} + +.sponsorSkipNoticeFaded { + opacity: 0.5; +} + +.sponsorSkipNoticeFadeOut { + transition: opacity 3s cubic-bezier(0.55, 0.055, 0.675, 0.19); + opacity: 0 !important; + animation: none !important; +} + +.sponsorSkipNotice .sponsorSkipNoticeTimeLeft { + color: #eeeeee; + + border-radius: 4px; + padding: 2px 5px; + font-size: 12px; + + display: flex; + align-items: center; + + border: 1px solid #eeeeee; +} + +.sponsorSkipNoticeTimeLeft img { + vertical-align: middle; + height: 13px; + + padding-top: 7.8%; + padding-bottom: 7.8%; +} + +/* if two are very close to eachother */ +.secondSkipNotice { + bottom: 290px; +} + +.noticeLeftIcon { + display: flex; + align-items: center; +} + +.sponsorSkipNotice .sponsorSkipNoticeUnskipSection { + float: left; + + border-left: 1px solid rgb(150, 150, 150); +} + +.sponsorSkipNoticeButton { + background: none; + color: rgb(235, 235, 235); + border: none; + display: inline-block; + font-size: 13.3333px !important; + + cursor: pointer; + + margin-right: 10px; + + padding: 2px 5px; +} + +.sponsorSkipNoticeButton:hover { + background-color: rgba(235, 235, 235,0.2); + border-radius: 4px; + + transition: background-color 0.4s; +} + +.sponsorSkipNoticeFirstRow .sponsorSkipNoticeButton.sponsorSkipSmallButton { + height: 1.3em; + padding: 0; +} + +.sponsorTimesVoteButtonsContainer { + float: left; + vertical-align:middle; + padding: 2px 5px; + + margin-right: 4px; +} + +.sponsorTimesVoteButtonsContainer div{ + display: inline-block; +} + +.sponsorSkipNoticeRightSection { + right: 0; + position: absolute; + + float: right; + + margin-right: 10px; + display: flex; + align-items: center; +} + +.sponsorSkipNoticeRightButton { + margin-right: 0; +} + +.sponsorSkipNoticeCloseButton { + height: 10px; + width: 10px; + box-sizing: unset; + + padding: 2px 5px; + + margin-left: 2px; + float: right; +} + +.sponsorSkipNoticeCloseButton.biggerCloseButton { + padding: 20px; +} + +.sponsorSkipMessage { + font-size: 14px; + font-weight: bold; + color: rgb(235, 235, 235); + + margin-top: auto; + display: inline-block; + margin-right: 10px; + margin-bottom: auto; +} + +.sponsorSkipInfo { + font-size: 10px; + color: #000000; + text-align: center; + margin-top: 0px; +} + +#sponsorTimesThanksForVotingText { + font-size: 20px; + font-weight: bold; + color: #000000; + text-align: center; + margin-top: 0px; + margin-bottom: 0px; +} + +#sponsorTimesThanksForVotingInfoText { + font-size: 12px; + font-weight: bold; + color: #000000; + text-align: center; + margin-top: 0px; +} + +.sponsorTimesVoteButtonMessage { + float: left; +} + +.sponsorTimesInfoMessage { + font-size: 13.3333px; + color: rgb(235, 235, 235); +} + +.sb-guidelines-notice .sponsorTimesInfoMessage td { + padding-left: 5px; + padding-top: 2px; + padding-bottom: 2px; + font-size: 15px; + + display: flex; + align-items: center; +}
\ No newline at end of file diff --git a/public/upsell/index.html b/public/upsell/index.html new file mode 100644 index 00000000..1dbc21af --- /dev/null +++ b/public/upsell/index.html @@ -0,0 +1,94 @@ +<!DOCTYPE html> + +<head> + <title>Upsell - SponsorBlock</title> + <meta charset="utf-8"> + + <link href="styles.css" rel="stylesheet" /> + + <script src="../js/vendor.js"></script> + <script src="../js/upsell.js"></script> +</head> + +<body class="sponsorBlockPageBody"> + + <div id="title" class="titleBar"> + <img src="../icons/LogoSponsorBlocker256px.png" height="80" class="profilepic" /> + SponsorBlock + </div> + + <br /> + + <div class="center"> + <p> + __MSG_chaptersPage1__ + </p> + </div> + + <div class="center"> + <iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/H_mP7bpbA_c?modestbranding=1&rel=0" title="Demo Video" + frameborder="0" allow="autoplay; clipboard-write; encrypted-media; picture-in-picture" + allowfullscreen> + </iframe> + </div> + + + + <br /> + + <div class="center row-item"> + <a href="https://buy.ajay.app/l/sponsorblock" class="option-link side-by-side" target="_blank" rel="noreferrer"> + <div id="oneTimePurchase" class="option-button inline"> + __MSG_oneTimePurchase__ + </div> + </a> + + <a href="https://www.patreon.com/ajayyy" class="option-link side-by-side" target="_blank" rel="noreferrer"> + <div class="option-button side-by-side inline"> + __MSG_joinOnPatreon__ + </div> + </a> + </div> + + <div class="center row-item"> + <input id="redeemCodeInput" class="option-text-box" type="text" placeholder="__MSG_enterLicenseKey__"> + <div id="redeemButton" class="option-button inline"> + __MSG_redeem__ + </div> + </div> + + <div class="center row-item"> + <a href="https://www.patreon.com/oauth2/authorize?response_type=code&client_id=-W7ib8J-LB3jowb1fqE07A7RDUovy45_pOoWcjby6yr5upo6At8Jlg2BPhWDXO2k&redirect_uri=https%3A%2F%2Fsponsor.ajay.app%3A3000%2Fapi%2FgenerateToken%2Fpatreon" + class="option-link" target="_blank" rel="noreferrer"> + <div class="option-button inline"> + __MSG_patreonSignIn__ + </div> + </a> + </div> + + <div id="cantAfford" class="center"> + + </div> + + <div class="center"> + __MSG_alreadyDonated__ [email protected] + </div> + + <div id="subsidizedPrice" class="center hidden"> + __MSG_selectYourCountry__ + </div> + + <div id="subsidizedLink" class="center hidden"> + <a href="https://buy.ajay.app/l/sponsorblock/purchasing-power" class="option-link" target="_blank" + rel="noreferrer"> + <div class="option-button inline"> + __MSG_discountLink__ + </div> + </a> + </div> + + <div id="noSubsidizedLink" class="center hidden"> + __MSG_noDiscount__ + </div> + +</body>
\ No newline at end of file diff --git a/public/upsell/styles.css b/public/upsell/styles.css new file mode 100644 index 00000000..0c0d6b6b --- /dev/null +++ b/public/upsell/styles.css @@ -0,0 +1,387 @@ +/* Based on options page CSS */ +html { + color-scheme: dark; +} + +body { + font-family: sans-serif; +} + +.center { + text-align: center; +} + +.center p { + margin: auto; +} + +.inline { + display: inline-block; +} + +.bold { + font-weight: bold; +} + +.hidden { + display: none !important; +} + +.row-item { + margin-top: 10px; + margin-bottom: 10px; +} + +.keybind-status { + display: inline; +} + +.small-description { + color: white; + font-size: 13px; +} + +.medium-description { + color: white; + font-size: 15px; +} + +.option-text-box { + width: 300px; +} + +.option-button { + cursor: pointer; + + background-color: #c00000; + padding: 10px; + color: white; + border-radius: 5px; + font-size: 14px; + + width: max-content; +} + +.option-link { + text-decoration: none; +} + +.option-link.side-by-side { + padding: 50px; +} + +.option-button:hover { + background-color: #fc0303; +} + +.option-button.disabled { + cursor: default; + + background-color: #520000; + color: grey; +} + +#options { + max-width: 60%; + text-align: left; + display: inline-block; +} + +.switch-container:after { + content: attr(label-name); + position: absolute; + padding: 4px; + width: max-content; + + font-size: 14px; + color: white; +} + +.text-label-container { + font-size: 14px; + color: white; +} + +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #707070; +} + +.animated * { + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 4px; + bottom: 4px; + background-color: white; +} + +.animated .slider:before { + -webkit-transition: .4s; + transition: .4s; +} + +input:checked + .slider { + background-color: #fc0303; +} + +input:checked + .slider:before { + -webkit-transform: translateX(16px); + -ms-transform: translateX(16px); + transform: translateX(16px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} + +.slider.round:before { + border-radius: 50%; +} + + +/* Boilerplate CSS from https://ajay.app */ + +body { + background-color: #333333; +} + +.projectPreview { + position: relative; +} + +.projectPreviewImage { + position: absolute; + left: -90px; + width: 80px; + top: 50%; + transform: translateY(-50%); +} + +.projectPreviewImageLarge { + position: absolute; + left: -210px; + width: 200px; + top: 50%; + transform: translateY(-20%); +} + +.projectPreviewImageLargeRight { + position: absolute; + right: -210px; + width: 200px; + top: 50%; + transform: translateY(-50%); +} + +.createdBy { + font-size: 14px; + text-align: center; + padding-top: 0px; + padding-bottom: 0px; + + display: inline-block; +} + +#title { + background-color: #636363; + + text-align: center; + vertical-align: middle; + + font-size: 50px; + color: #212121; + + padding: 20px; + + text-decoration: none; + + transition: font-size 1s; +} + +.subtitle { + font-size: 40px; + color: #dad8d8; + + padding-top: 10px; + + transition: font-size 0.4s; +} + +.subtitle:hover { + font-size: 45px; + + transition: font-size 0.4s; +} + +.profilepic { + background-color: #636363 !important; + vertical-align: middle; +} + +.profilepiccircle { + vertical-align: middle; + overflow: hidden; + border-radius: 50%; +} + +a { + text-decoration: underline; + color: inherit; +} + +.link { + padding: 20px; + + height: 80px; + + transition: height 0.2s; +} + +.link:hover { + height: 95px; + + transition: height 0.2s; +} + +#contact,.smalllink { + font-size: 25px; + color: #e8e8e8; + + text-align: center; + + padding: 10px; +} + +#contact { + text-decoration: none; +} + +p,li { + font-size: 20px; + color: #c4c4c4; + + padding: 10px; +} + +p,li,code,a { + max-width: 60%; + text-align: left; + overflow-wrap: break-word; +} + +@media screen and (orientation:portrait) { + p,li,code,a { + max-width: 100%; + } + + .projectPreviewImage { + position: unset; + width: 130px; + display: block; + margin: auto; + transform: none; + } +} + +.previewImage { + max-height: 200px; +} + +img { + max-width: 100%; + + text-align: center; +} + +#recentPostTitle { + font-size: 30px; + color: #dad8d8; +} + +#recentPostDate { + font-size: 15px; + color: #dad8d8; +} + +h1,h2,h3,h4,h5,h6 { + color: #dad8d8; +} + +svg { + text-decoration: none; +} + +.number-container:before { + content: attr(label-name); + padding-right: 4px; + width: max-content; + + font-size: 14px; + color: white; +} + +/* React styles */ + +.categoryTableElement { + font-size: 16px; + + color: white; +} + +.categoryTableElement > * { + padding-right: 15px; + padding-bottom: 15px; +} + +.optionsSelector { + background-color: #c00000; + color: white; + + border: none; + font-size: 14px; + padding: 5px; + border-radius: 5px; +} + +.categoryColorTextBox { + width: 60px; + + background: none; + border: none; +} + +#subsidizedPrice { + margin-top: 5px; + margin-bottom: 5px; +} + +#discountButton { + text-decoration: underline; + cursor: pointer; +}
\ No newline at end of file diff --git a/src/background.ts b/src/background.ts index 6c92a10d..80f0cd8d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,6 +14,8 @@ const utils = new Utils({ unregisterFirefoxContentScript }); +const popupPort: Record<string, chrome.runtime.Port> = {}; + // Used only on Firefox, which does not support non persistent background pages. const contentScriptRegistrations = {}; @@ -53,7 +55,7 @@ if (!Config.configSyncListeners.includes(onNavigationApiAvailableChange)) { Config.configSyncListeners.push(onNavigationApiAvailableChange); } -chrome.runtime.onMessage.addListener(function (request, _, callback) { +chrome.runtime.onMessage.addListener(function (request, sender, callback) { switch(request.message) { case "openConfig": chrome.tabs.create({url: chrome.runtime.getURL('options/options.html' + (request.hash ? '#' + request.hash : ''))}); @@ -100,9 +102,25 @@ chrome.runtime.onMessage.addListener(function (request, _, callback) { }); return true; } + case "time": + if (sender.tab) { + popupPort[sender.tab.id]?.postMessage(request); + } + return false; } }); +chrome.runtime.onConnect.addListener((port) => { + if (port.name === "popup") { + chrome.tabs.query({ + active: true, + currentWindow: true + }, tabs => { + popupPort[tabs[0].id] = port; + }); + } +}); + //add help page on install chrome.runtime.onInstalled.addListener(function () { // This let's the config sync to run fully before checking. @@ -116,7 +134,7 @@ chrome.runtime.onInstalled.addListener(function () { chrome.tabs.create({url: chrome.extension.getURL("/help/index.html")}); //generate a userID - const newUserID = utils.generateUserID(); + const newUserID = GenericUtils.generateUserID(); //save this UUID Config.config.userID = newUserID; @@ -165,7 +183,7 @@ async function submitVote(type: number, UUID: string, category: string) { if (userID == undefined || userID === "undefined") { //generate one - userID = utils.generateUserID(); + userID = GenericUtils.generateUserID(); Config.config.userID = userID; } diff --git a/src/components/ChapterVoteComponent.tsx b/src/components/ChapterVoteComponent.tsx new file mode 100644 index 00000000..b1f590a7 --- /dev/null +++ b/src/components/ChapterVoteComponent.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import Config from "../config"; +import { Category, SegmentUUID, SponsorTime } from "../types"; + +import ThumbsUpSvg from "../svg-icons/thumbs_up_svg"; +import ThumbsDownSvg from "../svg-icons/thumbs_down_svg"; +import { downvoteButtonColor, SkipNoticeAction } from "../utils/noticeUtils"; +import { VoteResponse } from "../messageTypes"; +import { AnimationUtils } from "../utils/animationUtils"; +import { GenericUtils } from "../utils/genericUtils"; +import { Tooltip } from "../render/Tooltip"; + +export interface ChapterVoteProps { + vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>; +} + +export interface ChapterVoteState { + segment?: SponsorTime; + show: boolean; +} + +class ChapterVoteComponent extends React.Component<ChapterVoteProps, ChapterVoteState> { + tooltip?: Tooltip; + + constructor(props: ChapterVoteProps) { + super(props); + + this.state = { + segment: null, + show: false + }; + } + + render(): React.ReactElement { + return ( + <> + {/* Upvote Button */} + <button id={"sponsorTimesDownvoteButtonsContainerUpvoteChapter"} + className={"playerButton sbPlayerUpvote ytp-button " + (!this.state.show ? "hidden" : "")} + draggable="false" + title={chrome.i18n.getMessage("upvoteButtonInfo")} + onClick={(e) => this.vote(e, 1)}> + <ThumbsUpSvg className="playerButtonImage" + fill={Config.config.colorPalette.white} + width={"inherit"} height={"inherit"} /> + </button> + + {/* Downvote Button */} + <button id={"sponsorTimesDownvoteButtonsContainerDownvoteChapter"} + className={"playerButton sbPlayerDownvote ytp-button " + (!this.state.show ? "hidden" : "")} + draggable="false" + title={chrome.i18n.getMessage("reportButtonInfo")} + onClick={(e) => { + const chapterNode = document.querySelector(".ytp-chapter-container") as HTMLElement; + + if (this.tooltip) { + this.tooltip.close(); + this.tooltip = null; + } else { + const referenceNode = chapterNode?.parentElement?.parentElement; + if (referenceNode) { + const outerBounding = referenceNode.getBoundingClientRect(); + const buttonBounding = (e.target as HTMLElement)?.parentElement?.getBoundingClientRect(); + + this.tooltip = new Tooltip({ + referenceNode: chapterNode?.parentElement?.parentElement, + prependElement: chapterNode?.parentElement, + showLogo: false, + showGotIt: false, + bottomOffset: `${outerBounding.height + 25}px`, + leftOffset: `${buttonBounding.x - outerBounding.x}px`, + extraClass: "centeredSBTriangle", + buttons: [ + { + name: chrome.i18n.getMessage("incorrectVote"), + listener: (event) => this.vote(event, 0, e.target as HTMLElement).then(() => { + this.tooltip?.close(); + this.tooltip = null; + }) + }, { + name: chrome.i18n.getMessage("harmfulVote"), + listener: (event) => this.vote(event, 30, e.target as HTMLElement).then(() => { + this.tooltip?.close(); + this.tooltip = null; + }) + } + ] + }); + } + } + }}> + <ThumbsDownSvg + className="playerButtonImage" + fill={downvoteButtonColor(this.state.segment ? [this.state.segment] : null, SkipNoticeAction.Downvote, SkipNoticeAction.Downvote)} + width={"inherit"} + height={"inherit"} /> + </button> + </> + ); + } + + private async vote(event: React.MouseEvent, type: number, element?: HTMLElement): Promise<void> { + event.stopPropagation(); + if (this.state.segment) { + const stopAnimation = AnimationUtils.applyLoadingAnimation(element ?? event.currentTarget as HTMLElement, 0.3); + + const response = await this.props.vote(type, this.state.segment.UUID); + await stopAnimation(); + + if (response.successType == 1 || (response.successType == -1 && response.statusCode == 429)) { + this.setState({ + show: type === 1 + }); + } else if (response.statusCode !== 403) { + alert(GenericUtils.getErrorMessage(response.statusCode, response.responseText)); + } + } + } +} + +export default ChapterVoteComponent; diff --git a/src/components/NoticeComponent.tsx b/src/components/NoticeComponent.tsx index 83540875..cf108671 100644 --- a/src/components/NoticeComponent.tsx +++ b/src/components/NoticeComponent.tsx @@ -11,6 +11,7 @@ export interface NoticeProps { noticeTitle: string, maxCountdownTime?: () => number, + dontPauseCountdown?: boolean, amountOfPreviousNotices?: number, showInSecondSlot?: boolean, timed?: boolean, @@ -25,6 +26,8 @@ export interface NoticeProps { smaller?: boolean, limitWidth?: boolean, extraClass?: string, + hideLogo?: boolean, + hideRightInfo?: boolean, // Callback for when this is closed closeListener: () => void, @@ -117,13 +120,15 @@ class NoticeComponent extends React.Component<NoticeProps, NoticeState> { {/* Left column */} <td className="noticeLeftIcon"> {/* Logo */} - <img id={"sponsorSkipLogo" + this.idSuffix} - className="sponsorSkipLogo sponsorSkipObject" - src={chrome.extension.getURL("icons/IconSponsorBlocker256px.png")}> - </img> + {!this.props.hideLogo && + <img id={"sponsorSkipLogo" + this.idSuffix} + className="sponsorSkipLogo sponsorSkipObject" + src={chrome.extension.getURL("icons/IconSponsorBlocker256px.png")}> + </img> + } <span id={"sponsorSkipMessage" + this.idSuffix} - style={{float: "left"}} + style={{float: "left", marginRight: this.props.hideLogo ? "0px" : null}} className="sponsorSkipMessage sponsorSkipObject"> {this.props.noticeTitle} @@ -135,28 +140,30 @@ class NoticeComponent extends React.Component<NoticeProps, NoticeState> { {this.props.firstRow} {/* Right column */} - <td className="sponsorSkipNoticeRightSection" - style={{top: "9.32px"}}> + {!this.props.hideRightInfo && + <td className="sponsorSkipNoticeRightSection" + style={{top: "9.32px"}}> + + {/* Time left */} + {this.props.timed ? ( + <span id={"sponsorSkipNoticeTimeLeft" + this.idSuffix} + onClick={() => this.toggleManualPause()} + className="sponsorSkipObject sponsorSkipNoticeTimeLeft"> + + {this.getCountdownElements()} + + </span> + ) : ""} - {/* Time left */} - {this.props.timed ? ( - <span id={"sponsorSkipNoticeTimeLeft" + this.idSuffix} - onClick={() => this.toggleManualPause()} - className="sponsorSkipObject sponsorSkipNoticeTimeLeft"> - - {this.getCountdownElements()} - - </span> - ) : ""} - - - {/* Close button */} - <img src={chrome.extension.getURL("icons/close.png")} - className={"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeCloseButton sponsorSkipNoticeRightButton" - + (this.props.biggerCloseButton ? " biggerCloseButton" : "")} - onClick={() => this.close()}> - </img> - </td> + + {/* Close button */} + <img src={chrome.extension.getURL("icons/close.png")} + className={"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeCloseButton sponsorSkipNoticeRightButton" + + (this.props.biggerCloseButton ? " biggerCloseButton" : "")} + onClick={() => this.close()}> + </img> + </td> + } </tr> {this.props.children} @@ -289,7 +296,7 @@ class NoticeComponent extends React.Component<NoticeProps, NoticeState> { } pauseCountdown(): void { - if (!this.props.timed) return; + if (!this.props.timed || this.props.dontPauseCountdown) return; //remove setInterval if (this.countdownInterval) clearInterval(this.countdownInterval); diff --git a/src/components/SelectorComponent.tsx b/src/components/SelectorComponent.tsx new file mode 100644 index 00000000..018d78a6 --- /dev/null +++ b/src/components/SelectorComponent.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; + +export interface SelectorOption { + label: string; +} + +export interface SelectorProps { + id: string; + options: SelectorOption[]; + onChange: (value: string) => void; +} + +export interface SelectorState { + +} + +class SelectorComponent extends React.Component<SelectorProps, SelectorState> { + + constructor(props: SelectorProps) { + super(props); + + // Setup state + this.state = { + + } + } + + render(): React.ReactElement { + return ( + <div id={this.props.id} + className="sbSelector"> + <div className="sbSelectorBackground"> + {this.getOptions()} + </div> + </div> + ); + } + + getOptions(): React.ReactElement[] { + const result: React.ReactElement[] = []; + for (const option of this.props.options) { + result.push( + <div className="sbSelectorOption" + onClick={() => this.props.onChange(option.label)} + key={option.label}> + {option.label} + </div> + ); + } + + return result; + } +} + +export default SelectorComponent;
\ No newline at end of file diff --git a/src/components/SkipNoticeComponent.tsx b/src/components/SkipNoticeComponent.tsx index 7de0599d..f42c0396 100644 --- a/src/components/SkipNoticeComponent.tsx +++ b/src/components/SkipNoticeComponent.tsx @@ -13,6 +13,7 @@ import ThumbsUpSvg from "../svg-icons/thumbs_up_svg"; import ThumbsDownSvg from "../svg-icons/thumbs_down_svg"; import PencilSvg from "../svg-icons/pencil_svg"; import { downvoteButtonColor, SkipNoticeAction } from "../utils/noticeUtils"; +import { GenericUtils } from "../utils/genericUtils"; enum SkipButtonState { Undo, // Unskip @@ -540,7 +541,7 @@ class SkipNoticeComponent extends React.Component<SkipNoticeProps, SkipNoticeSta const sponsorVideoID = this.props.contentContainer().sponsorVideoID; const sponsorTimesSubmitting : SponsorTime = { segment: this.segments[index].segment, - UUID: utils.generateUserID() as SegmentUUID, + UUID: GenericUtils.generateUserID() as SegmentUUID, category: this.segments[index].category, actionType: this.segments[index].actionType, source: SponsorSourceType.Local diff --git a/src/components/SponsorTimeEditComponent.tsx b/src/components/SponsorTimeEditComponent.tsx index 42a8d070..1515644a 100644 --- a/src/components/SponsorTimeEditComponent.tsx +++ b/src/components/SponsorTimeEditComponent.tsx @@ -1,10 +1,12 @@ import * as React from "react"; import * as CompileConfig from "../../config.json"; import Config from "../config"; -import { ActionType, Category, ContentContainer, SponsorTime } from "../types"; +import { ActionType, Category, ChannelIDStatus, ContentContainer, SponsorTime } from "../types"; import Utils from "../utils"; import SubmissionNoticeComponent from "./SubmissionNoticeComponent"; import { RectangleTooltip } from "../render/RectangleTooltip"; +import SelectorComponent, { SelectorOption } from "./SelectorComponent"; +import { GenericUtils } from "../utils/genericUtils"; const utils = new Utils(); @@ -25,16 +27,23 @@ export interface SponsorTimeEditState { editing: boolean; sponsorTimeEdits: [string, string]; selectedCategory: Category; + description: string; + suggestedNames: SelectorOption[]; + chapterNameSelectorOpen: boolean; } const DEFAULT_CATEGORY = "chooseACategory"; +const categoryNamesGrams: string[] = [].concat(...CompileConfig.categoryList.filter((name) => name !== "chapter") + .map((name) => chrome.i18n.getMessage("category_" + name).split(/\/|\s|-/))); + class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, SponsorTimeEditState> { idSuffix: string; categoryOptionRef: React.RefObject<HTMLSelectElement>; actionTypeOptionRef: React.RefObject<HTMLSelectElement>; + descriptionOptionRef: React.RefObject<HTMLInputElement>; configUpdateListener: () => void; @@ -42,26 +51,35 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo // Used when selecting POI or Full timesBeforeChanging: number[] = []; fullVideoWarningShown = false; + categoryNameWarningShown = false; + + // For description auto-complete + fetchingSuggestions: boolean; constructor(props: SponsorTimeEditProps) { super(props); this.categoryOptionRef = React.createRef(); this.actionTypeOptionRef = React.createRef(); + this.descriptionOptionRef = React.createRef(); this.idSuffix = this.props.idSuffix; - this.previousSkipType = ActionType.Skip; + + const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; this.state = { editing: false, sponsorTimeEdits: [null, null], - selectedCategory: DEFAULT_CATEGORY as Category + selectedCategory: DEFAULT_CATEGORY as Category, + description: sponsorTime.description || "", + suggestedNames: [], + chapterNameSelectorOpen: false }; } componentDidMount(): void { // Prevent inputs from triggering key events - document.getElementById("sponsorTimesContainer" + this.idSuffix).addEventListener('keydown', function (event) { + document.getElementById("sponsorTimeEditContainer" + this.idSuffix).addEventListener('keydown', function (event) { event.stopPropagation(); }); @@ -87,6 +105,7 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo render(): React.ReactElement { this.checkToShowFullVideoWarning(); + this.checkToShowChapterWarning(); const style: React.CSSProperties = { textAlign: "center" @@ -118,8 +137,8 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo type="text" style={{color: "inherit", backgroundColor: "inherit"}} value={this.state.sponsorTimeEdits[0]} - onChange={(e) => {this.handleOnChange(0, e, sponsorTime, e.target.value)}} - onWheel={(e) => {this.changeTimesWhenScrolling(0, e, sponsorTime)}}> + onChange={(e) => this.handleOnChange(0, e, sponsorTime, e.target.value)} + onWheel={(e) => this.changeTimesWhenScrolling(0, e, sponsorTime)}> </input> {sponsorTime.actionType !== ActionType.Poi ? ( @@ -133,8 +152,8 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo type="text" style={{color: "inherit", backgroundColor: "inherit"}} value={this.state.sponsorTimeEdits[1]} - onChange={(e) => {this.handleOnChange(1, e, sponsorTime, e.target.value)}} - onWheel={(e) => {this.changeTimesWhenScrolling(1, e, sponsorTime)}}> + onChange={(e) => this.handleOnChange(1, e, sponsorTime, e.target.value)} + onWheel={(e) => this.changeTimesWhenScrolling(1, e, sponsorTime)}> </input> <span id={"nowButton1" + this.idSuffix} @@ -159,15 +178,15 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo style={timeDisplayStyle} className="sponsorTimeDisplay" onClick={this.toggleEditTime.bind(this)}> - {utils.getFormattedTime(segment[0], true) + + {GenericUtils.getFormattedTime(segment[0], true) + ((!isNaN(segment[1]) && sponsorTime.actionType !== ActionType.Poi) - ? " " + chrome.i18n.getMessage("to") + " " + utils.getFormattedTime(segment[1], true) : "")} + ? " " + chrome.i18n.getMessage("to") + " " + GenericUtils.getFormattedTime(segment[1], true) : "")} </div> ); } return ( - <div style={style}> + <div id={"sponsorTimeEditContainer" + this.idSuffix} style={style}> {timeDisplay} @@ -178,7 +197,7 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo defaultValue={sponsorTime.category} ref={this.categoryOptionRef} style={{color: "inherit", backgroundColor: "inherit"}} - onChange={this.categorySelectionChange.bind(this)}> + onChange={(event) => this.categorySelectionChange(event)}> {this.getCategoryOptions()} </select> @@ -209,6 +228,27 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo </div> ): ""} + {/* Chapter Name */} + {sponsorTime.actionType === ActionType.Chapter ? ( + <div onMouseLeave={() => this.setState({chapterNameSelectorOpen: false})}> + <input id={"chapterName" + this.idSuffix} + className="sponsorTimeEdit" + ref={this.descriptionOptionRef} + type="text" + value={this.state.description} + onChange={(e) => this.descriptionUpdate(e.target.value)} + onFocus={() => this.setState({chapterNameSelectorOpen: true})}> + </input> + {this.state.chapterNameSelectorOpen && this.state.description && + <SelectorComponent + id={"chapterNameSelector" + this.idSuffix} + options={this.state.suggestedNames} + onChange={(v) => this.descriptionUpdate(v)} + /> + } + </div> + ): ""} + <br/> {/* Editing Tools */} @@ -223,7 +263,8 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo <span id={"sponsorTimePreviewButton" + this.idSuffix} className="sponsorTimeEditButton" onClick={(e) => this.previewTime(e.ctrlKey, e.shiftKey)}> - {chrome.i18n.getMessage("preview")} + {sponsorTime.actionType !== ActionType.Chapter ? chrome.i18n.getMessage("preview") + : chrome.i18n.getMessage("End")} </span> ): ""} @@ -250,16 +291,15 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo const sponsorTimeEdits = this.state.sponsorTimeEdits; // check if change is small engough to show tooltip - const before = utils.getFormattedTimeToSeconds(sponsorTimeEdits[index]); - const after = utils.getFormattedTimeToSeconds(targetValue); + const before = GenericUtils.getFormattedTimeToSeconds(sponsorTimeEdits[index]); + const after = GenericUtils.getFormattedTimeToSeconds(targetValue); const difference = Math.abs(before - after); - if (0 < difference && difference< 0.5) this.showScrollToEditToolTip(); + if (0 < difference && difference < 0.5) this.showScrollToEditToolTip(); sponsorTimeEdits[index] = targetValue; if (index === 0 && sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = targetValue; - this.setState({sponsorTimeEdits}); - this.saveEditTimes(); + this.setState({sponsorTimeEdits}, () => this.saveEditTimes()); } changeTimesWhenScrolling(index: number, e: React.WheelEvent, sponsorTime: SponsorTime): void { @@ -275,7 +315,7 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo } const sponsorTimeEdits = this.state.sponsorTimeEdits; - let timeAsNumber = utils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[index]); + let timeAsNumber = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[index]); if (timeAsNumber !== null && e.deltaY != 0) { if (e.deltaY < 0) { timeAsNumber += step; @@ -284,7 +324,8 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo } else { timeAsNumber = 0; } - sponsorTimeEdits[index] = utils.getFormattedTime(timeAsNumber, true); + + sponsorTimeEdits[index] = GenericUtils.getFormattedTime(timeAsNumber, true); if (sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = sponsorTimeEdits[0]; this.setState({sponsorTimeEdits}); @@ -294,26 +335,29 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo showScrollToEditToolTip(): void { if (!Config.config.scrollToEditTimeUpdate && document.getElementById("sponsorRectangleTooltip" + "sponsorTimesContainer" + this.idSuffix) === null) { - this.showToolTip(chrome.i18n.getMessage("SponsorTimeEditScrollNewFeature"), () => { Config.config.scrollToEditTimeUpdate = true }); + this.showToolTip(chrome.i18n.getMessage("SponsorTimeEditScrollNewFeature"), "scrollToEdit", () => { Config.config.scrollToEditTimeUpdate = true }); } } - showToolTip(text: string, buttonFunction?: () => void): boolean { + showToolTip(text: string, id: string, buttonFunction?: () => void): boolean { const element = document.getElementById("sponsorTimesContainer" + this.idSuffix); - if (element) { - new RectangleTooltip({ - text, - referenceNode: element.parentElement, - prependElement: element, - timeout: 15, - bottomOffset: 0 + "px", - leftOffset: -318 + "px", - backgroundColor: "rgba(28, 28, 28, 1.0)", - htmlId: "sponsorTimesContainer" + this.idSuffix, - buttonFunction, - fontSize: "14px", - maxHeight: "200px" - }); + if (element) { + const htmlId = `sponsorRectangleTooltip${id + this.idSuffix}`; + if (!document.getElementById(htmlId)) { + new RectangleTooltip({ + text, + referenceNode: element.parentElement, + prependElement: element, + timeout: 15, + bottomOffset: 0 + "px", + leftOffset: -318 + "px", + backgroundColor: "rgba(28, 28, 28, 1.0)", + htmlId, + buttonFunction, + fontSize: "14px", + maxHeight: "200px" + }); + } return true; } else { @@ -328,12 +372,25 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo if (videoPercentage > 0.6 && !this.fullVideoWarningShown && (sponsorTime.category === "sponsor" || sponsorTime.category === "selfpromo" || sponsorTime.category === "chooseACategory")) { - if (this.showToolTip(chrome.i18n.getMessage("fullVideoTooltipWarning"))) { + if (this.showToolTip(chrome.i18n.getMessage("fullVideoTooltipWarning"), "fullVideoWarning")) { this.fullVideoWarningShown = true; } } } + checkToShowChapterWarning(): void { + const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; + + if (sponsorTime.actionType === ActionType.Chapter && sponsorTime.description + && !this.categoryNameWarningShown + && categoryNamesGrams.some( + (category) => sponsorTime.description.toLowerCase().includes(category.toLowerCase()))) { + if (this.showToolTip(chrome.i18n.getMessage("chapterNameTooltipWarning"), "chapterWarning")) { + this.categoryNameWarningShown = true; + } + } + } + getCategoryOptions(): React.ReactElement[] { const elements = [( <option value={DEFAULT_CATEGORY} @@ -343,6 +400,11 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo )]; for (const category of (this.props.categoryList ?? CompileConfig.categoryList)) { + // If permission not loaded, treat it like we have permission except chapter + const defaultBlockCategories = ["chapter"]; + const permission = Config.config.permissions[category as Category]; + if ((defaultBlockCategories.includes(category) || permission !== undefined) && !permission) continue; + elements.push( <option value={category} key={category} @@ -363,7 +425,7 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo const chosenCategory = event.target.value as Category; // See if show more categories was pressed - if (event.target.value !== DEFAULT_CATEGORY && !Config.config.categorySelections.some((category) => category.name === event.target.value)) { + if (chosenCategory !== DEFAULT_CATEGORY && !Config.config.categorySelections.some((category) => category.name === chosenCategory)) { event.target.value = DEFAULT_CATEGORY; // Alert that they have to enable this category first @@ -464,7 +526,7 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo this.setState({ sponsorTimeEdits: this.getFormattedSponsorTimesEdits(sponsorTime) - }, this.saveEditTimes); + }, () => this.saveEditTimes()); } toggleEditTime(): void { @@ -487,16 +549,16 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo /** Returns an array in the sponsorTimeEdits form (formatted time string) from a normal seconds sponsor time */ getFormattedSponsorTimesEdits(sponsorTime: SponsorTime): [string, string] { - return [utils.getFormattedTime(sponsorTime.segment[0], true), - utils.getFormattedTime(sponsorTime.segment[1], true)]; + return [GenericUtils.getFormattedTime(sponsorTime.segment[0], true), + GenericUtils.getFormattedTime(sponsorTime.segment[1], true)]; } saveEditTimes(): void { const sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting; if (this.state.editing) { - const startTime = utils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[0]); - const endTime = utils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[1]); + const startTime = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[0]); + const endTime = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[1]); // Change segment time only if the format was correct if (startTime !== null && endTime !== null) { @@ -507,8 +569,11 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo const category = this.categoryOptionRef.current.value as Category sponsorTimesSubmitting[this.props.index].category = category; - const inputActionType = this.actionTypeOptionRef?.current?.value as ActionType; - sponsorTimesSubmitting[this.props.index].actionType = this.getNextActionType(category, inputActionType); + const actionType = this.getNextActionType(category, this.actionTypeOptionRef?.current?.value as ActionType); + sponsorTimesSubmitting[this.props.index].actionType = actionType; + + const description = actionType === ActionType.Chapter ? this.descriptionOptionRef?.current?.value : ""; + sponsorTimesSubmitting[this.props.index].description = description; Config.config.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimesSubmitting; Config.forceSyncUpdate("unsubmittedSegments"); @@ -530,19 +595,19 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo previewTime(ctrlPressed = false, shiftPressed = false): void { const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting; const index = this.props.index; - - const skipTime = sponsorTimes[index].segment[0]; - // If segment starts at 0:00, start playback at the end of the segment - if (skipTime === 0) { - this.props.contentContainer().previewTime(sponsorTimes[index].segment[1]); - return; - } - let seekTime = 2; if (ctrlPressed) seekTime = 0.5; if (shiftPressed) seekTime = 0.25; - this.props.contentContainer().previewTime(skipTime - (seekTime * this.props.contentContainer().v.playbackRate)); + const startTime = sponsorTimes[index].segment[0]; + const endTime = sponsorTimes[index].segment[1]; + const isChapter = sponsorTimes[index].actionType === ActionType.Chapter; + + // If segment starts at 0:00, start playback at the end of the segment + const skipToEndTime = startTime === 0 || isChapter; + const skipTime = skipToEndTime ? endTime : (startTime - (seekTime * this.props.contentContainer().v.playbackRate)); + + this.props.contentContainer().previewTime(skipTime, !isChapter); } inspectTime(): void { @@ -586,6 +651,41 @@ class SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, Spo } } + descriptionUpdate(description: string): void { + this.setState({ + description + }); + + if (!this.fetchingSuggestions) { + this.fetchSuggestions(description); + } + + this.saveEditTimes(); + } + + async fetchSuggestions(description: string): Promise<void> { + if (this.props.contentContainer().channelIDInfo.status !== ChannelIDStatus.Found) return; + + this.fetchingSuggestions = true; + const result = await utils.asyncRequestToServer("GET", "/api/chapterNames", { + description, + channelID: this.props.contentContainer().channelIDInfo.id + }); + + if (result.ok) { + try { + const names = JSON.parse(result.responseText) as {description: string}[]; + this.setState({ + suggestedNames: names.map(n => ({ + label: n.description + })) + }); + } catch (e) {} //eslint-disable-line no-empty + } + + this.fetchingSuggestions = false; + } + configUpdate(): void { this.forceUpdate(); } diff --git a/src/components/SubmissionNoticeComponent.tsx b/src/components/SubmissionNoticeComponent.tsx index fea0c762..7997303b 100644 --- a/src/components/SubmissionNoticeComponent.tsx +++ b/src/components/SubmissionNoticeComponent.tsx @@ -73,12 +73,19 @@ class SubmissionNoticeComponent extends React.Component<SubmissionNoticeProps, S } render(): React.ReactElement { + const sortButton = + <img id={"sponsorSkipSortButton" + this.state.idSuffix} + className="sponsorSkipObject sponsorSkipNoticeButton sponsorSkipSmallButton" + onClick={() => this.sortSegments()} + src={chrome.extension.getURL("icons/sort.svg")}> + </img>; return ( <NoticeComponent noticeTitle={this.state.noticeTitle} idSuffix={this.state.idSuffix} ref={this.noticeRef} closeListener={this.cancel.bind(this)} - zIndex={5000}> + zIndex={5000} + firstColumn={sortButton}> {/* Text Boxes */} {this.getMessageBoxes()} @@ -198,6 +205,16 @@ class SubmissionNoticeComponent extends React.Component<SubmissionNoticeProps, S this.cancel(); } + sortSegments(): void { + let sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting; + sponsorTimesSubmitting = sponsorTimesSubmitting.sort((a, b) => a.segment[0] - b.segment[0]); + + Config.config.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimesSubmitting; + Config.forceSyncUpdate("unsubmittedSegments"); + + this.forceUpdate(); + } + categoryChangeListener(index: number, category: Category): void { const dialogWidth = this.noticeRef?.current?.getElement()?.current?.offsetWidth; if (category !== "chooseACategory" && Config.config.showCategoryGuidelines diff --git a/src/components/CategoryChooserComponent.tsx b/src/components/options/CategoryChooserComponent.tsx index 763254b9..1da4f641 100644 --- a/src/components/CategoryChooserComponent.tsx +++ b/src/components/options/CategoryChooserComponent.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import * as CompileConfig from "../../config.json"; -import { Category } from "../types"; +import * as CompileConfig from "../../../config.json"; +import { Category } from "../../types"; import CategorySkipOptionsComponent from "./CategorySkipOptionsComponent"; export interface CategoryChooserProps { diff --git a/src/components/CategorySkipOptionsComponent.tsx b/src/components/options/CategorySkipOptionsComponent.tsx index 4c68e957..338435a8 100644 --- a/src/components/CategorySkipOptionsComponent.tsx +++ b/src/components/options/CategorySkipOptionsComponent.tsx @@ -1,10 +1,13 @@ import * as React from "react"; -import Config from "../config" -import * as CompileConfig from "../../config.json"; -import { Category, CategorySkipOption } from "../types"; +import Config from "../../config" +import * as CompileConfig from "../../../config.json"; +import { Category, CategorySkipOption } from "../../types"; -import { getCategorySuffix } from "../utils/categoryUtils"; +import { getCategorySuffix } from "../../utils/categoryUtils"; +import ToggleOptionComponent, { ToggleOptionProps } from "./ToggleOptionComponent"; +import { fetchingChaptersAllowed } from "../../utils/licenseKey"; +import LockSvg from "../../svg-icons/lock_svg"; export interface CategorySkipOptionsProps { category: Category; @@ -15,6 +18,7 @@ export interface CategorySkipOptionsProps { export interface CategorySkipOptionsState { color: string; previewColor: string; + hideChapter: boolean; } class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsProps, CategorySkipOptionsState> { @@ -27,7 +31,14 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr this.state = { color: props.defaultColor || Config.config.barTypes[this.props.category]?.color, previewColor: props.defaultPreviewColor || Config.config.barTypes["preview-" + this.props.category]?.color, - } + hideChapter: true + }; + + fetchingChaptersAllowed().then((allowed) => { + this.setState({ + hideChapter: !allowed + }); + }) } render(): React.ReactElement { @@ -51,12 +62,25 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr } } + let extraClasses = ""; + const disabled = this.props.category === "chapter" && this.state.hideChapter; + if (disabled) { + extraClasses += " disabled"; + + if (!Config.config.showUpsells) { + return <></>; + } + } + return ( <> <tr id={this.props.category + "OptionsRow"} - className="categoryTableElement"> + className={`categoryTableElement${extraClasses}`} > <td id={this.props.category + "OptionName"} className="categoryTableLabel"> + {disabled && + <LockSvg className="upsellButton" onClick={() => chrome.tabs.create({url: chrome.runtime.getURL('upsell/index.html')})}/> + } {chrome.i18n.getMessage("category_" + this.props.category)} </td> @@ -65,21 +89,25 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr <select className="optionsSelector" defaultValue={defaultOption} + disabled={disabled} onChange={this.skipOptionSelected.bind(this)}> {this.getCategorySkipOptions()} </select> </td> - <td id={this.props.category + "ColorOption"} - className="colorOption"> - <input - className="categoryColorTextBox option-text-box" - type="color" - onChange={(event) => this.setColorState(event, false)} - value={this.state.color} /> - </td> + {this.props.category !== "chapter" && + <td id={this.props.category + "ColorOption"} + className="colorOption"> + <input + className="categoryColorTextBox option-text-box" + type="color" + disabled={disabled} + onChange={(event) => this.setColorState(event, false)} + value={this.state.color} /> + </td> + } - {this.props.category !== "exclusive_access" && + {!["chapter", "exclusive_access"].includes(this.props.category) && <td id={this.props.category + "PreviewColorOption"} className="previewColorOption"> <input @@ -93,7 +121,7 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr </tr> <tr id={this.props.category + "DescriptionRow"} - className="small-description categoryTableDescription"> + className={`small-description categoryTableDescription${extraClasses}`}> <td colSpan={2}> {chrome.i18n.getMessage("category_" + this.props.category + "_description")} @@ -103,6 +131,8 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr </a> </td> </tr> + + {this.getExtraOptionComponents(this.props.category, extraClasses, disabled)} </> ); @@ -147,7 +177,8 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr const elements: JSX.Element[] = []; let optionNames = ["disable", "showOverlay", "manualSkip", "autoSkip"]; - if (this.props.category === "exclusive_access") optionNames = ["disable", "showOverlay"]; + if (this.props.category === "chapter") optionNames = ["disable", "showOverlay"] + else if (this.props.category === "exclusive_access") optionNames = ["disable", "showOverlay"]; for (const optionName of optionNames) { elements.push( @@ -184,6 +215,42 @@ class CategorySkipOptionsComponent extends React.Component<CategorySkipOptionsPr Config.config.barTypes = Config.config.barTypes; }, 50); } + + getExtraOptionComponents(category: string, extraClasses: string, disabled: boolean): JSX.Element[] { + const result = []; + for (const option of this.getExtraOptions(category)) { + result.push( + <tr key={option.configKey} className={extraClasses}> + <td id={`${category}_${option.configKey}`} className="categoryExtraOptions"> + <ToggleOptionComponent + configKey={option.configKey} + label={option.label} + disabled={disabled} + /> + </td> + </tr> + ) + } + + return result; + } + + getExtraOptions(category: string): ToggleOptionProps[] { + switch (category) { + case "chapter": + return [{ + configKey: "renderSegmentsAsChapters", + label: chrome.i18n.getMessage("renderAsChapters"), + }]; + case "music_offtopic": + return [{ + configKey: "autoSkipOnMusicVideos", + label: chrome.i18n.getMessage("autoSkipOnMusicVideos"), + }]; + default: + return []; + } + } } export default CategorySkipOptionsComponent;
\ No newline at end of file diff --git a/src/components/KeybindComponent.tsx b/src/components/options/KeybindComponent.tsx index 17aefcbb..34345301 100644 --- a/src/components/KeybindComponent.tsx +++ b/src/components/options/KeybindComponent.tsx @@ -1,9 +1,9 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import Config from "../config"; -import { Keybind } from "../types"; +import Config from "../../config"; +import { Keybind } from "../../types"; import KeybindDialogComponent from "./KeybindDialogComponent"; -import { keybindEquals, keybindToString, formatKey } from "../utils/configUtils"; +import { keybindEquals, keybindToString, formatKey } from "../../utils/configUtils"; export interface KeybindProps { option: string; diff --git a/src/components/KeybindDialogComponent.tsx b/src/components/options/KeybindDialogComponent.tsx index c4e7cf48..7c8684a7 100644 --- a/src/components/KeybindDialogComponent.tsx +++ b/src/components/options/KeybindDialogComponent.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { ChangeEvent } from "react"; -import Config from "../config"; -import { Keybind } from "../types"; -import { keybindEquals, formatKey } from "../utils/configUtils"; +import Config from "../../config"; +import { Keybind } from "../../types"; +import { keybindEquals, formatKey } from "../../utils/configUtils"; export interface KeybindDialogProps { option: string; diff --git a/src/components/options/ToggleOptionComponent.tsx b/src/components/options/ToggleOptionComponent.tsx new file mode 100644 index 00000000..c6ea698d --- /dev/null +++ b/src/components/options/ToggleOptionComponent.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import Config from "../../config"; + +export interface ToggleOptionProps { + configKey: string; + label: string; + disabled?: boolean; +} + +export interface ToggleOptionState { + enabled: boolean; +} + +class ToggleOptionComponent extends React.Component<ToggleOptionProps, ToggleOptionState> { + + constructor(props: ToggleOptionProps) { + super(props); + + // Setup state + this.state = { + enabled: Config.config[props.configKey] + } + } + + render(): React.ReactElement { + return ( + <div> + <div className="switch-container"> + <label className="switch"> + <input id={this.props.configKey} + type="checkbox" + checked={this.state.enabled} + disabled={this.props.disabled} + onChange={(e) => this.clicked(e)}/> + <span className="slider round"></span> + </label> + <label className="switch-label" htmlFor={this.props.configKey}> + {this.props.label} + </label> + </div> + </div> + ); + } + + clicked(event: React.ChangeEvent<HTMLInputElement>): void { + Config.config[this.props.configKey] = event.target.checked; + + this.setState({ + enabled: event.target.checked + }); + } + +} + +export default ToggleOptionComponent;
\ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 10baf5b7..2fc48814 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,12 +3,18 @@ import * as invidiousList from "../ci/invidiouslist.json"; import { Category, CategorySelection, CategorySkipOption, NoticeVisbilityMode, PreviewBarOption, SponsorTime, StorageChangesObject, Keybind, HashedValue, VideoID, SponsorHideType } from "./types"; import { keybindEquals } from "./utils/configUtils"; +export interface Permission { + canSubmit: boolean; +} + interface SBConfig { userID: string, isVip: boolean, + permissions: Record<Category, Permission>, /* Contains unsubmitted segments that the user has created. */ unsubmittedSegments: Record<string, SponsorTime[]>, defaultCategory: Category, + renderSegmentsAsChapters: boolean, whitelistedChannels: string[], forceChannelCheck: boolean, minutesSaved: number, @@ -44,6 +50,7 @@ interface SBConfig { allowExpirements: boolean, showDonationLink: boolean, showPopupDonationCount: number, + showUpsells: boolean, donateClicked: number, autoHideInfoButton: boolean, autoSkipOnMusicVideos: boolean, @@ -56,6 +63,7 @@ interface SBConfig { categoryPillUpdate: boolean, darkMode: boolean, showCategoryGuidelines: boolean, + chaptersAvailable: boolean, // Used to cache calculated text color info categoryPillColors: { @@ -68,10 +76,19 @@ interface SBConfig { skipKeybind: Keybind, startSponsorKeybind: Keybind, submitKeybind: Keybind, + nextChapterKeybind: Keybind, + previousChapterKeybind: Keybind, // What categories should be skipped categorySelections: CategorySelection[], + payments: { + licenseKey: string, + lastCheck: number, + freeAccess: boolean, + chaptersAllowed: boolean + } + // Preview bar barTypes: { "preview-chooseACategory": PreviewBarOption, @@ -128,8 +145,10 @@ const Config: SBObject = { syncDefaults: { userID: null, isVip: false, + permissions: {}, unsubmittedSegments: {}, defaultCategory: "chooseACategory" as Category, + renderSegmentsAsChapters: false, whitelistedChannels: [], forceChannelCheck: false, minutesSaved: 0, @@ -165,6 +184,7 @@ const Config: SBObject = { allowExpirements: true, showDonationLink: true, showPopupDonationCount: 0, + showUpsells: true, donateClicked: 0, autoHideInfoButton: true, autoSkipOnMusicVideos: false, @@ -172,6 +192,7 @@ const Config: SBObject = { categoryPillUpdate: false, darkMode: true, showCategoryGuidelines: true, + chaptersAvailable: true, categoryPillColors: {}, @@ -185,6 +206,8 @@ const Config: SBObject = { skipKeybind: {key: "Enter"}, startSponsorKeybind: {key: ";"}, submitKeybind: {key: "'"}, + nextChapterKeybind: {key: "]"}, + previousChapterKeybind: {key: "["}, categorySelections: [{ name: "sponsor" as Category, @@ -197,6 +220,13 @@ const Config: SBObject = { option: CategorySkipOption.ShowOverlay }], + payments: { + licenseKey: null, + lastCheck: 0, + freeAccess: false, + chaptersAllowed: false + }, + colorPalette: { red: "#780303", white: "#ffffff", @@ -516,6 +546,8 @@ function migrateOldSyncFormats(config: SBConfig) { } async function setupConfig() { + if (typeof(chrome) === "undefined") return; + await fetchConfig(); addDefaults(); const config = configProxy(); diff --git a/src/content.ts b/src/content.ts index e0af1f34..a1f22fbc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -12,12 +12,14 @@ import SubmissionNotice from "./render/SubmissionNotice"; import { Message, MessageResponse, VoteResponse } from "./messageTypes"; import { SkipButtonControlBar } from "./js-components/skipButtonControlBar"; import { getStartTimeFromUrl } from "./utils/urlParser"; -import { findValidElement, getControls, getHashParams, isVisible } from "./utils/pageUtils"; +import { findValidElement, getControls, getExistingChapters, getHashParams, isVisible } from "./utils/pageUtils"; import { isSafari, keybindEquals } from "./utils/configUtils"; import { CategoryPill } from "./render/CategoryPill"; import { AnimationUtils } from "./utils/animationUtils"; import { GenericUtils } from "./utils/genericUtils"; import { logDebug } from "./utils/logger"; +import { importTimes } from "./utils/exporter"; +import { ChapterVote } from "./render/ChapterVote"; import { openWarningDialog } from "./utils/warnings"; // Hack to get the CSS loaded on permission-based sites (Invidious) @@ -26,7 +28,8 @@ utils.wait(() => Config.config !== null, 5000, 10).then(addCSS); //was sponsor data found when doing SponsorsLookup let sponsorDataFound = false; //the actual sponsorTimes if loaded and UUIDs associated with them -let sponsorTimes: SponsorTime[] = null; +let sponsorTimes: SponsorTime[] = []; +let existingChaptersImported = false; //what video id are these sponsors for let sponsorVideoID: VideoID = null; // List of open skip notices @@ -138,7 +141,8 @@ const skipNoticeContentContainer: ContentContainer = () => ({ previewTime, videoInfo, getRealCurrentTime: getRealCurrentTime, - lockedCategories + lockedCategories, + channelIDInfo }); // value determining when to count segment as skipped and send telemetry to server (percent based) @@ -167,6 +171,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo found: sponsorDataFound, status: lastResponseStatus, sponsorTimes: sponsorTimes, + time: video.currentTime, onMobileYouTube }); @@ -212,10 +217,17 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo found: sponsorDataFound, status: lastResponseStatus, sponsorTimes: sponsorTimes, + time: video.currentTime, onMobileYouTube })); return true; + case "unskip": + unskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), null, true); + break; + case "reskip": + reskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), true); + break; case "submitVote": vote(request.type, request.UUID).then((response) => sendResponse(response)); return true; @@ -230,6 +242,31 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo case "copyToClipboard": navigator.clipboard.writeText(request.text); break; + case "importSegments": { + const importedSegments = importTimes(request.data, video.duration); + let addedSegments = false; + for (const segment of importedSegments) { + if (!sponsorTimesSubmitting.concat(sponsorTimes ?? []).some( + (s) => Math.abs(s.segment[0] - segment.segment[0]) < 1 + && Math.abs(s.segment[1] - segment.segment[1]) < 1)) { + sponsorTimesSubmitting.push(segment); + addedSegments = true; + } + } + + if (addedSegments) { + Config.config.unsubmittedSegments[sponsorVideoID] = sponsorTimesSubmitting; + Config.forceSyncUpdate("unsubmittedSegments"); + + updateEditButtonsOnPlayer(); + updateSponsorTimesSubmitting(false); + } + + sendResponse({ + importedSegments + }); + break; + } case "keydown": document.dispatchEvent(new KeyboardEvent('keydown', { key: request.key, @@ -249,8 +286,6 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo /** * Called when the config is updated - * - * @param {String} changes */ function contentConfigUpdateListener(changes: StorageChangesObject) { for (const key in changes) { @@ -276,8 +311,8 @@ function resetValues() { lastCheckVideoTime = -1; retryCount = 0; - //reset sponsor times - sponsorTimes = null; + sponsorTimes = []; + existingChaptersImported = false; sponsorSkipped = []; videoInfo = null; @@ -423,7 +458,7 @@ function createPreviewBar(): void { isVisibleCheck: true }, { // For Desktop YouTube - selector: ".ytp-progress-bar-container", + selector: ".ytp-progress-bar", isVisibleCheck: true }, { // For Desktop YouTube @@ -441,7 +476,8 @@ function createPreviewBar(): void { const el = option.isVisibleCheck ? findValidElement(allElements) : allElements[0]; if (el) { - previewBar = new PreviewBar(el, onMobileYouTube, onInvidious); + const chapterVote = new ChapterVote(voteAsync); + previewBar = new PreviewBar(el, onMobileYouTube, onInvidious, chapterVote); updatePreviewBar(); @@ -507,13 +543,15 @@ function startSponsorSchedule(includeIntersectingSegments = false, currentTime?: } logDebug(`Considering to start skipping: ${!video}, ${video?.paused}`); - - if (!video || video.paused) return; + if (!video) return; if (currentTime === undefined || currentTime === null) { currentTime = getVirtualTime(); } lastTimeFromWaitingEvent = null; + updateActiveSegment(currentTime); + + if (video.paused) return; const skipInfo = getNextSkipIndex(currentTime, includeIntersectingSegments, includeNonIntersectingSegments); const currentSkip = skipInfo.array[skipInfo.index]; @@ -568,35 +606,41 @@ function startSponsorSchedule(includeIntersectingSegments = false, currentTime?: if (incorrectVideoCheck(videoID, currentSkip)) return; forceVideoTime ||= Math.max(video.currentTime, getVirtualTime()); - if (forceVideoTime >= skipTime[0] - skipBuffer && forceVideoTime < skipTime[1]) { - skipToTime({ - v: video, - skipTime, - skippingSegments, - openNotice: skipInfo.openNotice - }); + if ((shouldSkip(currentSkip) || sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment))) { + if (forceVideoTime >= skipTime[0] - skipBuffer && forceVideoTime < skipTime[1]) { + skipToTime({ + v: video, + skipTime, + skippingSegments, + openNotice: skipInfo.openNotice + }); - // These are segments that start at the exact same time but need seperate notices - for (const extra of skipInfo.extraIndexes) { - const extraSkip = skipInfo.array[extra]; - if (shouldSkip(extraSkip)) { - skipToTime({ - v: video, - skipTime: [extraSkip.scheduledTime, extraSkip.segment[1]], - skippingSegments: [extraSkip], - openNotice: skipInfo.openNotice - }); + // These are segments that start at the exact same time but need seperate notices + for (const extra of skipInfo.extraIndexes) { + const extraSkip = skipInfo.array[extra]; + if (shouldSkip(extraSkip)) { + skipToTime({ + v: video, + skipTime: [extraSkip.scheduledTime, extraSkip.segment[1]], + skippingSegments: [extraSkip], + openNotice: skipInfo.openNotice + }); + } + } + + if (utils.getCategorySelection(currentSkip.category)?.option === CategorySkipOption.ManualSkip + || currentSkip.actionType === ActionType.Mute) { + forcedSkipTime = skipTime[0] + 0.001; + } else { + forcedSkipTime = skipTime[1]; + forcedIncludeIntersectingSegments = true; + forcedIncludeNonIntersectingSegments = false; } - } - - if (utils.getCategorySelection(currentSkip.category)?.option === CategorySkipOption.ManualSkip - || currentSkip.actionType === ActionType.Mute) { - forcedSkipTime = skipTime[0] + 0.001; } else { - forcedSkipTime = skipTime[1]; - forcedIncludeIntersectingSegments = true; - forcedIncludeNonIntersectingSegments = false; + forcedSkipTime = forceVideoTime + 0.001; } + } else { + forcedSkipTime = forceVideoTime + 0.001; } startSponsorSchedule(forcedIncludeIntersectingSegments, forcedSkipTime, forcedIncludeNonIntersectingSegments); @@ -793,8 +837,12 @@ function setupVideoListeners() { lastTimeFromWaitingEvent = null; startSponsorSchedule(); - } else if (video.currentTime === 0) { - lastPausedAtZero = true; + } else { + updateActiveSegment(video.currentTime); + + if (video.currentTime === 0) { + lastPausedAtZero = true; + } } }); video.addEventListener('ratechange', () => startSponsorSchedule()); @@ -888,7 +936,12 @@ async function sponsorsLookup(keepOldSubmissions = true) { if (response?.ok) { const recievedSegments: SponsorTime[] = JSON.parse(response.responseText) ?.filter((video) => video.videoID === sponsorVideoID) - ?.map((video) => video.segments)[0]; + ?.map((video) => video.segments)?.[0] + ?.map((segment) => ({ + ...segment, + source: SponsorSourceType.Server + })) + ?.sort((a, b) => a.segment[0] - b.segment[0]); if (!recievedSegments || !recievedSegments.length) { // return if no video found retryFetch(404); @@ -909,6 +962,7 @@ async function sponsorsLookup(keepOldSubmissions = true) { const oldSegments = sponsorTimes || []; sponsorTimes = recievedSegments; + existingChaptersImported = false; // Hide all submissions smaller than the minimum duration if (Config.config.minDuration !== 0) { @@ -956,13 +1010,28 @@ async function sponsorsLookup(keepOldSubmissions = true) { retryFetch(lastResponseStatus); } + importExistingChapters(true); + if (Config.config.isVip) { lockedCategoriesLookup(); } } +function importExistingChapters(wait: boolean) { + if (!existingChaptersImported) { + GenericUtils.wait(() => video && getExistingChapters(sponsorVideoID, video.duration), + wait ? 5000 : 0, 100, (c) => c?.length > 0).then((chapters) => { + if (!existingChaptersImported && chapters?.length > 0) { + sponsorTimes = (sponsorTimes ?? []).concat(...chapters).sort((a, b) => a.segment[0] - b.segment[0]); + existingChaptersImported = true; + updatePreviewBar(); + } + }).catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function + } +} + function getEnabledActionTypes(): ActionType[] { - const actionTypes = [ActionType.Skip, ActionType.Poi]; + const actionTypes = [ActionType.Skip, ActionType.Poi, ActionType.Chapter]; if (Config.config.muteSegments) { actionTypes.push(ActionType.Mute); } @@ -1000,7 +1069,8 @@ function retryFetch(errorCode: number): void { const delay = errorCode === 404 ? (10000 + Math.random() * 30000) : (2000 + Math.random() * 10000); setTimeout(() => { - if (sponsorVideoID && sponsorTimes?.length === 0) { + if (sponsorVideoID && sponsorTimes?.length === 0 + || sponsorTimes.every((segment) => segment.source !== SponsorSourceType.Server)) { sponsorsLookup(); } }, delay); @@ -1164,9 +1234,11 @@ function updatePreviewBar(): void { previewBarSegments.push({ segment: segment.segment as [number, number], category: segment.category, - unsubmitted: false, actionType: segment.actionType, - showLarger: segment.actionType === ActionType.Poi + unsubmitted: false, + showLarger: segment.actionType === ActionType.Poi, + description: segment.description, + source: segment.source, }); }); } @@ -1175,16 +1247,21 @@ function updatePreviewBar(): void { previewBarSegments.push({ segment: segment.segment as [number, number], category: segment.category, - unsubmitted: true, actionType: segment.actionType, - showLarger: segment.actionType === ActionType.Poi + unsubmitted: true, + showLarger: segment.actionType === ActionType.Poi, + description: segment.description, + source: segment.source }); }); previewBar.set(previewBarSegments.filter((segment) => segment.actionType !== ActionType.Full), video?.duration) + updateActiveSegment(video.currentTime); if (Config.config.showTimeWithSkips) { - const skippedDuration = utils.getTimestampsDuration(previewBarSegments.map(({segment}) => segment)); + const skippedDuration = utils.getTimestampsDuration(previewBarSegments + .filter(({actionType}) => actionType !== ActionType.Chapter) + .map(({segment}) => segment)); showTimeWithoutSkips(skippedDuration); } @@ -1248,7 +1325,7 @@ function getNextSkipIndex(currentTime: number, includeIntersectingSegments: bool const { includedTimes: submittedArray, scheduledTimes: sponsorStartTimes } = getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments); - const { scheduledTimes: sponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, true, true); + const { scheduledTimes: sponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, true); // This is an array in-case multiple segments have the exact same start time const minSponsorTimeIndexes = GenericUtils.indexesOf(sponsorStartTimes, Math.min(...sponsorStartTimesAfterCurrentTime)); @@ -1263,7 +1340,7 @@ function getNextSkipIndex(currentTime: number, includeIntersectingSegments: bool const { includedTimes: unsubmittedArray, scheduledTimes: unsubmittedSponsorStartTimes } = getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments); - const { scheduledTimes: unsubmittedSponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, false, false); + const { scheduledTimes: unsubmittedSponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, false); const minUnsubmittedSponsorTimeIndex = unsubmittedSponsorStartTimes.indexOf(Math.min(...unsubmittedSponsorStartTimesAfterCurrentTime)); const previewEndTimeIndex = getLatestEndTimeIndex(unsubmittedArray, minUnsubmittedSponsorTimeIndex); @@ -1344,7 +1421,7 @@ function getLatestEndTimeIndex(sponsorTimes: SponsorTime[], index: number, hideH * the current time, but end after */ function getStartTimes(sponsorTimes: SponsorTime[], includeIntersectingSegments: boolean, includeNonIntersectingSegments: boolean, - minimum?: number, onlySkippableSponsors = false, hideHiddenSponsors = false): {includedTimes: ScheduledTime[], scheduledTimes: number[]} { + minimum?: number, hideHiddenSponsors = false): {includedTimes: ScheduledTime[], scheduledTimes: number[]} { if (!sponsorTimes) return {includedTimes: [], scheduledTimes: []}; const includedTimes: ScheduledTime[] = []; @@ -1355,9 +1432,8 @@ function getStartTimes(sponsorTimes: SponsorTime[], includeIntersectingSegments: scheduledTime: sponsorTime.segment[0] })); - // Schedule at the end time to know when to unmute - sponsorTimes.filter(sponsorTime => sponsorTime.actionType === ActionType.Mute) - .forEach(sponsorTime => { + // Schedule at the end time to know when to unmute and remove title from seek bar + sponsorTimes.forEach(sponsorTime => { if (!possibleTimes.some((time) => sponsorTime.segment[1] === time.scheduledTime)) { possibleTimes.push({ ...sponsorTime, @@ -1369,9 +1445,9 @@ function getStartTimes(sponsorTimes: SponsorTime[], includeIntersectingSegments: for (let i = 0; i < possibleTimes.length; i++) { if ((minimum === undefined || ((includeNonIntersectingSegments && possibleTimes[i].scheduledTime >= minimum) - || (includeIntersectingSegments && possibleTimes[i].scheduledTime < minimum && possibleTimes[i].segment[1] > minimum))) - && (!onlySkippableSponsors || shouldSkip(possibleTimes[i])) + || (includeIntersectingSegments && possibleTimes[i].scheduledTime < minimum && possibleTimes[i].segment[1] > minimum))) && (!hideHiddenSponsors || possibleTimes[i].hidden === SponsorHideType.Visible) + && possibleTimes[i].segment.length === 2 && possibleTimes[i].actionType !== ActionType.Poi) { scheduledTimes.push(possibleTimes[i].scheduledTime); @@ -1535,7 +1611,7 @@ function reskipSponsorTime(segment: SponsorTime, forceSeek = false) { const fullSkip = skippedTime / segmentDuration > manualSkipPercentCount; video.currentTime = segment.segment[1]; - sendTelemetryAndCount([segment], skippedTime, fullSkip); + sendTelemetryAndCount([segment], segment.actionType !== ActionType.Chapter ? skippedTime : 0, fullSkip); startSponsorSchedule(true, segment.segment[1], false); } } @@ -1586,6 +1662,7 @@ function shouldAutoSkip(segment: SponsorTime): boolean { function shouldSkip(segment: SponsorTime): boolean { return (segment.actionType !== ActionType.Full + && segment.source !== SponsorSourceType.YouTube && utils.getCategorySelection(segment.category)?.option !== CategorySkipOption.ShowOverlay) || (Config.config.autoSkipOnMusicVideos && sponsorTimes?.some((s) => s.category === "music_offtopic")); } @@ -1692,7 +1769,7 @@ function startOrEndTimingNewSegment() { if (!isSegmentCreationInProgress()) { sponsorTimesSubmitting.push({ segment: [roundedTime], - UUID: utils.generateUserID() as SegmentUUID, + UUID: GenericUtils.generateUserID() as SegmentUUID, category: Config.config.defaultCategory, actionType: ActionType.Skip, source: SponsorSourceType.Local @@ -1716,6 +1793,8 @@ function startOrEndTimingNewSegment() { updateEditButtonsOnPlayer(); updateSponsorTimesSubmitting(false); + + importExistingChapters(false); } function getIncompleteSegment(): SponsorTime { @@ -1754,9 +1833,14 @@ function updateSponsorTimesSubmitting(getFromConfig = true) { UUID: segmentTime.UUID, category: segmentTime.category, actionType: segmentTime.actionType, + description: segmentTime.description, source: segmentTime.source }); } + + if (sponsorTimesSubmitting.length > 0) { + importExistingChapters(true); + } } updatePreviewBar(); @@ -1878,7 +1962,7 @@ async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): const sponsorIndex = utils.getSponsorIndexFromUUID(sponsorTimes, UUID); // Don't vote for preview sponsors - if (sponsorIndex == -1 || sponsorTimes[sponsorIndex].source === SponsorSourceType.Local) return; + if (sponsorIndex == -1 || sponsorTimes[sponsorIndex].source !== SponsorSourceType.Server) return; // See if the local time saved count and skip count should be saved if (type === 0 && sponsorSkipped[sponsorIndex] || type === 1 && !sponsorSkipped[sponsorIndex]) { @@ -2025,7 +2109,7 @@ async function sendSubmitMessage() { } catch(e) {} // eslint-disable-line no-empty // Add submissions to current sponsors list - sponsorTimes = (sponsorTimes || []).concat(newSegments); + sponsorTimes = (sponsorTimes || []).concat(newSegments).sort((a, b) => a.segment[0] - b.segment[0]); // Increase contribution count Config.config.sponsorTimesContributed = Config.config.sponsorTimesContributed + sponsorTimesSubmitting.length; @@ -2062,7 +2146,7 @@ function getSegmentsMessage(sponsorTimes: SponsorTime[]): string { for (let i = 0; i < sponsorTimes.length; i++) { for (let s = 0; s < sponsorTimes[i].segment.length; s++) { - let timeMessage = utils.getFormattedTime(sponsorTimes[i].segment[s]); + let timeMessage = GenericUtils.getFormattedTime(sponsorTimes[i].segment[s]); //if this is an end time if (s == 1) { timeMessage = " " + chrome.i18n.getMessage("to") + " " + timeMessage; @@ -2078,6 +2162,44 @@ function getSegmentsMessage(sponsorTimes: SponsorTime[]): string { return sponsorTimesMessage; } +function updateActiveSegment(currentTime: number): void { + previewBar?.updateChapterText(sponsorTimes, sponsorTimesSubmitting, currentTime); + chrome.runtime.sendMessage({ + message: "time", + time: currentTime + }); +} + +function nextChapter(): void { + const chapters = sponsorTimes.filter((time) => time.actionType === ActionType.Chapter) + .sort((a, b) => a.segment[1] - b.segment[1]); + if (chapters.length <= 0) return; + + const nextChapter = chapters.findIndex((time) => time.actionType === ActionType.Chapter + && time.segment[1] > video.currentTime); + if (nextChapter !== -1) { + reskipSponsorTime(chapters[nextChapter], true); + } else { + video.currentTime = video.duration; + } +} + +function previousChapter(): void { + const chapters = sponsorTimes.filter((time) => time.actionType === ActionType.Chapter); + if (chapters.length <= 0) return; + + // subtract 5 seconds to allow skipping back to the previous chapter if close to start of + // the current one + const nextChapter = chapters.findIndex((time) => time.actionType === ActionType.Chapter + && time.segment[0] > video.currentTime - Math.min(5, time.segment[1] - time.segment[0])); + const previousChapter = nextChapter !== -1 ? (nextChapter - 1) : (chapters.length - 1); + if (previousChapter !== -1) { + unskipSponsorTime(chapters[previousChapter], null, true); + } else { + video.currentTime = 0; + } +} + function addPageListeners(): void { const refreshListners = () => { if (!isVisible(video)) { @@ -2107,6 +2229,8 @@ function hotkeyListener(e: KeyboardEvent): void { const skipKey = Config.config.skipKeybind; const startSponsorKey = Config.config.startSponsorKeybind; const submitKey = Config.config.submitKeybind; + const nextChapterKey = Config.config.nextChapterKeybind; + const previousChapterKey = Config.config.previousChapterKeybind; if (keybindEquals(key, skipKey)) { if (activeSkipKeybindElement) @@ -2118,6 +2242,12 @@ function hotkeyListener(e: KeyboardEvent): void { } else if (keybindEquals(key, submitKey)) { submitSponsorTimes(); return; + } else if (keybindEquals(key, nextChapterKey)) { + nextChapter(); + return; + } else if (keybindEquals(key, previousChapterKey)) { + previousChapter(); + return; } //legacy - to preserve keybinds for skipKey, startSponsorKey and submitKey for people who set it before the update. (shouldn't be changed for future keybind options) @@ -2187,8 +2317,8 @@ function showTimeWithoutSkips(skippedDuration: number): void { display.appendChild(duration); } - - const durationAfterSkips = utils.getFormattedTime(video?.duration - skippedDuration) + + const durationAfterSkips = GenericUtils.getFormattedTime(video?.duration - skippedDuration); duration.innerText = (durationAfterSkips == null || skippedDuration <= 0) ? "" : " (" + durationAfterSkips + ")"; } @@ -2207,7 +2337,7 @@ function checkForPreloadedSegment() { if (!sponsorTimesSubmitting.some((s) => s.segment[0] === segment.segment[0] && s.segment[1] === s.segment[1])) { sponsorTimesSubmitting.push({ segment: segment.segment, - UUID: utils.generateUserID() as SegmentUUID, + UUID: GenericUtils.generateUserID() as SegmentUUID, category: segment.category ? segment.category : Config.config.defaultCategory, actionType: segment.actionType ? segment.actionType : ActionType.Skip, source: SponsorSourceType.Local diff --git a/src/js-components/previewBar.ts b/src/js-components/previewBar.ts index 16098b7d..9779913f 100644 --- a/src/js-components/previewBar.ts +++ b/src/js-components/previewBar.ts @@ -6,41 +6,63 @@ https://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd 'use strict'; import Config from "../config"; -import { ActionType } from "../types"; -import Utils from "../utils"; -const utils = new Utils(); +import { ChapterVote } from "../render/ChapterVote"; +import { ActionType, Category, SegmentContainer, SponsorHideType, SponsorSourceType, SponsorTime } from "../types"; +import { partition } from "../utils/arrayUtils"; +import { shortCategoryName } from "../utils/categoryUtils"; +import { GenericUtils } from "../utils/genericUtils"; const TOOLTIP_VISIBLE_CLASS = 'sponsorCategoryTooltipVisible'; +const MIN_CHAPTER_SIZE = 0.003; export interface PreviewBarSegment { segment: [number, number]; - category: string; - unsubmitted: boolean; + category: Category; actionType: ActionType; + unsubmitted: boolean; showLarger: boolean; + description: string; + source: SponsorSourceType; +} + +interface ChapterGroup extends SegmentContainer { + originalDuration: number } class PreviewBar { container: HTMLUListElement; categoryTooltip?: HTMLDivElement; - tooltipContainer?: HTMLElement; + categoryTooltipContainer?: HTMLElement; + chapterTooltip?: HTMLDivElement; parent: HTMLElement; onMobileYouTube: boolean; onInvidious: boolean; segments: PreviewBarSegment[] = []; + existingChapters: PreviewBarSegment[] = []; videoDuration = 0; - constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean) { + // For chapter bar + hoveredSection: HTMLElement; + customChaptersBar: HTMLElement; + chaptersBarSegments: PreviewBarSegment[]; + chapterVote: ChapterVote; + originalChapterBar: HTMLElement; + originalChapterBarBlocks: NodeListOf<HTMLElement>; + + constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean, chapterVote: ChapterVote, test=false) { + if (test) return; this.container = document.createElement('ul'); this.container.id = 'previewbar'; this.parent = parent; this.onMobileYouTube = onMobileYouTube; this.onInvidious = onInvidious; + this.chapterVote = chapterVote; this.createElement(parent); + this.createChapterMutationObservers(); this.setupHoverText(); } @@ -51,16 +73,19 @@ class PreviewBar { // Create label placeholder this.categoryTooltip = document.createElement("div"); this.categoryTooltip.className = "ytp-tooltip-title sponsorCategoryTooltip"; + this.chapterTooltip = document.createElement("div"); + this.chapterTooltip.className = "ytp-tooltip-title sponsorCategoryTooltip"; const tooltipTextWrapper = document.querySelector(".ytp-tooltip-text-wrapper"); if (!tooltipTextWrapper || !tooltipTextWrapper.parentElement) return; // Grab the tooltip from the text wrapper as the tooltip doesn't have its classes on init - this.tooltipContainer = tooltipTextWrapper.parentElement; + this.categoryTooltipContainer = tooltipTextWrapper.parentElement; const titleTooltip = tooltipTextWrapper.querySelector(".ytp-tooltip-title"); - if (!this.tooltipContainer || !titleTooltip) return; + if (!this.categoryTooltipContainer || !titleTooltip) return; tooltipTextWrapper.insertBefore(this.categoryTooltip, titleTooltip.nextSibling); + tooltipTextWrapper.insertBefore(this.chapterTooltip, titleTooltip.nextSibling); const seekBar = document.querySelector(".ytp-progress-bar-container"); if (!seekBar) return; @@ -76,7 +101,7 @@ class PreviewBar { }); const observer = new MutationObserver((mutations) => { - if (!mouseOnSeekBar || !this.categoryTooltip || !this.tooltipContainer) return; + if (!mouseOnSeekBar || !this.categoryTooltip || !this.categoryTooltipContainer) return; // If the mutation observed is only for our tooltip text, ignore if (mutations.length === 1 && (mutations[0].target as HTMLElement).classList.contains("sponsorCategoryTooltip")) { @@ -93,7 +118,7 @@ class PreviewBar { const tooltipText = tooltipTextElement.textContent; if (tooltipText === null || tooltipText.length === 0) continue; - timeInSeconds = utils.getFormattedTimeToSeconds(tooltipText); + timeInSeconds = GenericUtils.getFormattedTimeToSeconds(tooltipText); if (timeInSeconds !== null) break; } @@ -101,36 +126,32 @@ class PreviewBar { if (timeInSeconds === null) return; // Find the segment at that location, using the shortest if multiple found - let segment: PreviewBarSegment | null = null; - let currentSegmentLength = Infinity; - - for (const seg of this.segments) {// - const segmentLength = seg.segment[1] - seg.segment[0]; - const minSize = this.getMinimumSize(seg.showLarger); - - const startTime = segmentLength !== 0 ? seg.segment[0] : Math.floor(seg.segment[0]); - const endTime = segmentLength > minSize ? seg.segment[1] : Math.ceil(seg.segment[0] + minSize); - if (startTime <= timeInSeconds && endTime >= timeInSeconds) { - if (segmentLength < currentSegmentLength) { - currentSegmentLength = segmentLength; - segment = seg; - } - } + const [normalSegments, chapterSegments] = + partition(this.segments.filter((s) => s.source !== SponsorSourceType.YouTube), + (segment) => segment.actionType !== ActionType.Chapter); + let mainSegment = this.getSmallestSegment(timeInSeconds, normalSegments); + let secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments); + if (mainSegment === null && secondarySegment !== null) { + mainSegment = secondarySegment; + secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments.filter((s) => s !== secondarySegment)); } - if (segment === null && this.tooltipContainer.classList.contains(TOOLTIP_VISIBLE_CLASS)) { - this.tooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); - } else if (segment !== null) { - this.tooltipContainer.classList.add(TOOLTIP_VISIBLE_CLASS); - - if (segment.unsubmitted) { - this.categoryTooltip.textContent = chrome.i18n.getMessage("unsubmitted") + " " + utils.shortCategoryName(segment.category); + if (mainSegment === null && secondarySegment === null) { + this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); + } else { + this.categoryTooltipContainer.classList.add(TOOLTIP_VISIBLE_CLASS); + if (mainSegment !== null && secondarySegment !== null) { + this.categoryTooltipContainer.classList.add("sponsorTwoTooltips"); } else { - this.categoryTooltip.textContent = utils.shortCategoryName(segment.category); + this.categoryTooltipContainer.classList.remove("sponsorTwoTooltips"); } - // Use the class if the timestamp text uses it to prevent overlapping + this.setTooltipTitle(mainSegment, this.categoryTooltip); + this.setTooltipTitle(secondarySegment, this.chapterTooltip); + + // Used to prevent overlapping this.categoryTooltip.classList.toggle("ytp-tooltip-text-no-title", noYoutubeChapters); + this.chapterTooltip.classList.toggle("ytp-tooltip-text-no-title", noYoutubeChapters); } }); @@ -140,6 +161,21 @@ class PreviewBar { }); } + private setTooltipTitle(segment: PreviewBarSegment, tooltip: HTMLElement): void { + if (segment) { + const name = segment.description || shortCategoryName(segment.category); + if (segment.unsubmitted) { + tooltip.textContent = chrome.i18n.getMessage("unsubmitted") + " " + name; + } else { + tooltip.textContent = name; + } + + tooltip.style.removeProperty("display"); + } else { + tooltip.style.display = "none"; + } + } + createElement(parent: HTMLElement): void { this.parent = parent; @@ -148,7 +184,7 @@ class PreviewBar { parent.style.backgroundColor = "rgba(255, 255, 255, 0.3)"; parent.style.opacity = "1"; } - + this.container.style.transform = "none"; } else if (!this.onInvidious) { // Hover listener @@ -157,39 +193,62 @@ class PreviewBar { this.parent.addEventListener("mouseleave", () => this.container.classList.remove("hovered")); } - - // On the seek bar this.parent.prepend(this.container); } clear(): void { - this.videoDuration = 0; - this.segments = []; - while (this.container.firstChild) { this.container.removeChild(this.container.firstChild); } } set(segments: PreviewBarSegment[], videoDuration: number): void { + this.segments = segments ?? []; + this.videoDuration = videoDuration ?? 0; + + const progressBar = document.querySelector('.ytp-progress-bar') as HTMLElement; + // Sometimes video duration is inaccurate, pull from accessibility info + const ariaDuration = parseInt(progressBar?.getAttribute('aria-valuemax')) ?? 0; + if (ariaDuration && Math.abs(ariaDuration - this.videoDuration) > 3) { + this.videoDuration = ariaDuration; + } + + this.update(); + } + + private update(): void { this.clear(); - if (!segments) return; + if (!this.segments) return; - this.segments = segments; - this.videoDuration = videoDuration; + this.originalChapterBar = document.querySelector(".ytp-chapters-container:not(.sponsorBlockChapterBar)") as HTMLElement; + this.originalChapterBarBlocks = this.originalChapterBar.querySelectorAll(":scope > div") as NodeListOf<HTMLElement> + this.existingChapters = this.segments.filter((s) => s.source === SponsorSourceType.YouTube).sort((a, b) => a.segment[0] - b.segment[0]) - this.segments.sort(({segment: a}, {segment: b}) => { + const sortedSegments = this.segments.sort(({ segment: a }, { segment: b }) => { // Sort longer segments before short segments to make shorter segments render later return (b[1] - b[0]) - (a[1] - a[0]); - }).forEach((segment) => { + }); + for (const segment of sortedSegments) { const bar = this.createBar(segment); this.container.appendChild(bar); - }); + } + + this.createChaptersBar(this.segments.sort((a, b) => a.segment[0] - b.segment[0])); + + const chapterChevron = this.getChapterChevron(); + if (this.segments.some((segment) => segment.actionType !== ActionType.Chapter + && segment.source === SponsorSourceType.YouTube)) { + chapterChevron.style.removeProperty("display"); + } else { + chapterChevron.style.display = "none"; + } } - createBar({category, unsubmitted, segment, showLarger}: PreviewBarSegment): HTMLLIElement { + createBar(barSegment: PreviewBarSegment): HTMLLIElement { + const { category, unsubmitted, segment, showLarger } = barSegment; + const bar = document.createElement('li'); bar.classList.add('previewbar'); bar.innerHTML = showLarger ? ' ' : ' '; @@ -202,7 +261,9 @@ class PreviewBar { bar.style.position = "absolute"; const duration = Math.min(segment[1], this.videoDuration) - segment[0]; - if (duration > 0) bar.style.width = this.timeToPercentage(duration); + if (duration > 0) { + bar.style.width = `calc(${this.intervalToPercentage(segment[0], segment[1])}${this.chapterFilter(barSegment) ? ' - 2px' : ''})`; + } const time = segment[1] ? Math.min(this.videoDuration, segment[0]) : segment[0]; bar.style.left = this.timeToPercentage(time); @@ -210,6 +271,413 @@ class PreviewBar { return bar; } + createChaptersBar(segments: PreviewBarSegment[]): void { + const progressBar = document.querySelector('.ytp-progress-bar') as HTMLElement; + if (!progressBar || !this.originalChapterBar || this.originalChapterBar.childElementCount <= 0) return; + + if (segments.every((segments) => segments.source === SponsorSourceType.YouTube) + || (!Config.config.renderSegmentsAsChapters + && segments.every((segment) => segment.actionType !== ActionType.Chapter + || segment.source === SponsorSourceType.YouTube))) { + if (this.customChaptersBar) this.customChaptersBar.style.display = "none"; + this.originalChapterBar.style.removeProperty("display"); + return; + } + + // Merge overlapping chapters + const filteredSegments = segments?.filter((segment) => this.chapterFilter(segment)); + const chaptersToRender = this.createChapterRenderGroups(filteredSegments).filter((segment) => this.chapterGroupFilter(segment)); + + if (chaptersToRender?.length <= 0) { + if (this.customChaptersBar) this.customChaptersBar.style.display = "none"; + this.originalChapterBar.style.removeProperty("display"); + return; + } + + // Create it from cloning + let createFromScratch = false; + if (!this.customChaptersBar) { + createFromScratch = true; + this.customChaptersBar = this.originalChapterBar.cloneNode(true) as HTMLElement; + this.customChaptersBar.classList.add("sponsorBlockChapterBar"); + } + this.customChaptersBar.style.removeProperty("display"); + const originalSections = this.customChaptersBar.querySelectorAll(".ytp-chapter-hover-container"); + const originalSection = originalSections[0]; + + this.customChaptersBar = this.customChaptersBar; + + // For switching to a video with less chapters + if (originalSections.length > chaptersToRender.length) { + for (let i = originalSections.length - 1; i >= chaptersToRender.length; i--) { + this.customChaptersBar.removeChild(originalSections[i]); + } + } + + // Modify it to have sections for each segment + for (let i = 0; i < chaptersToRender.length; i++) { + const chapter = chaptersToRender[i].segment; + let newSection = originalSections[i] as HTMLElement; + if (!newSection) { + newSection = originalSection.cloneNode(true) as HTMLElement; + + this.firstTimeSetupChapterSection(newSection); + this.customChaptersBar.appendChild(newSection); + } + + this.setupChapterSection(newSection, chapter[0], chapter[1], i !== chaptersToRender.length - 1); + } + + // Hide old bar + this.originalChapterBar.style.display = "none"; + + if (createFromScratch) { + if (this.container?.parentElement === progressBar) { + progressBar.insertBefore(this.customChaptersBar, this.container.nextSibling); + } else { + progressBar.prepend(this.customChaptersBar); + } + } + + this.updateChapterAllMutation(this.originalChapterBar, progressBar, true); + } + + createChapterRenderGroups(segments: PreviewBarSegment[]): ChapterGroup[] { + const result: ChapterGroup[] = []; + + segments?.forEach((segment, index) => { + const latestChapter = result[result.length - 1]; + if (latestChapter && latestChapter.segment[1] > segment.segment[0]) { + const segmentDuration = segment.segment[1] - segment.segment[0]; + if (segment.segment[0] < latestChapter.segment[0] + || segmentDuration < latestChapter.originalDuration) { + // Remove latest if it starts too late + let latestValidChapter = latestChapter; + const chaptersToAddBack: ChapterGroup[] = [] + while (latestValidChapter?.segment[0] >= segment.segment[0]) { + const invalidChapter = result.pop(); + if (invalidChapter.segment[1] > segment.segment[1]) { + if (invalidChapter.segment[0] === segment.segment[0]) { + invalidChapter.segment[0] = segment.segment[1]; + } + + chaptersToAddBack.push(invalidChapter); + } + latestValidChapter = result[result.length - 1]; + } + + // Split the latest chapter if smaller + result.push({ + segment: [segment.segment[0], segment.segment[1]], + originalDuration: segmentDuration, + }); + if (latestValidChapter?.segment[1] > segment.segment[1]) { + result.push({ + segment: [segment.segment[1], latestValidChapter.segment[1]], + originalDuration: latestValidChapter.originalDuration + }); + } + + chaptersToAddBack.reverse(); + let lastChapterChecked: number[] = segment.segment; + for (const chapter of chaptersToAddBack) { + if (chapter.segment[0] < lastChapterChecked[1]) { + chapter.segment[0] = lastChapterChecked[1]; + } + + lastChapterChecked = chapter.segment; + } + result.push(...chaptersToAddBack); + if (latestValidChapter) latestValidChapter.segment[1] = segment.segment[0]; + } else { + // Start at end of old one otherwise + result.push({ + segment: [latestChapter.segment[1], segment.segment[1]], + originalDuration: segmentDuration + }); + } + } else { + // Add empty buffer before segment if needed + const lastTime = latestChapter?.segment[1] || 0; + if (segment.segment[0] > lastTime) { + result.push({ + segment: [lastTime, segment.segment[0]], + originalDuration: 0 + }); + } + + // Normal case + const endTime = Math.min(segment.segment[1], this.videoDuration); + result.push({ + segment: [segment.segment[0], endTime], + originalDuration: endTime - segment.segment[0] + }); + } + + // Add empty buffer after segment if needed + if (index === segments.length - 1) { + const nextSegment = segments[index + 1]; + const nextTime = nextSegment ? nextSegment.segment[0] : this.videoDuration; + const lastTime = result[result.length - 1]?.segment[1] || segment.segment[1]; + if (this.intervalToDecimal(lastTime, nextTime) > MIN_CHAPTER_SIZE) { + result.push({ + segment: [lastTime, nextTime], + originalDuration: 0 + }); + } + } + }); + + return result; + } + + private setupChapterSection(section: HTMLElement, startTime: number, endTime: number, addMargin: boolean): void { + const sizePercent = this.intervalToPercentage(startTime, endTime); + if (addMargin) { + section.style.marginRight = "2px"; + section.style.width = `calc(${sizePercent} - 2px)`; + } else { + section.style.marginRight = "0"; + section.style.width = sizePercent; + } + + section.setAttribute("decimal-width", String(this.intervalToDecimal(startTime, endTime))); + } + + private firstTimeSetupChapterSection(section: HTMLElement): void { + section.addEventListener("mouseenter", () => { + this.hoveredSection?.classList.remove("ytp-exp-chapter-hover-effect"); + section.classList.add("ytp-exp-chapter-hover-effect"); + this.hoveredSection = section; + }); + } + + private createChapterMutationObservers(): void { + const progressBar = document.querySelector('.ytp-progress-bar') as HTMLElement; + const chapterBar = document.querySelector(".ytp-chapters-container:not(.sponsorBlockChapterBar)") as HTMLElement; + if (!progressBar || !chapterBar) return; + + const attributeObserver = new MutationObserver((mutations) => { + const changes: Record<string, HTMLElement> = {}; + for (const mutation of mutations) { + const currentElement = mutation.target as HTMLElement; + if (mutation.type === "attributes" + && currentElement.parentElement?.classList.contains("ytp-progress-list")) { + changes[currentElement.classList[0]] = mutation.target as HTMLElement; + } + } + + this.updateChapterMutation(changes, progressBar); + }); + + attributeObserver.observe(chapterBar, { + subtree: true, + attributes: true, + attributeFilter: ["style", "class"] + }); + + const childListObserver = new MutationObserver((mutations) => { + const changes: Record<string, HTMLElement> = {}; + for (const mutation of mutations) { + if (mutation.type === "childList") { + this.update(); + } + } + + this.updateChapterMutation(changes, progressBar); + }); + + // Only direct children, no subtree + childListObserver.observe(chapterBar, { + childList: true + }); + } + + private updateChapterAllMutation(originalChapterBar: HTMLElement, progressBar: HTMLElement, firstUpdate = false): void { + const elements = originalChapterBar.querySelectorAll(".ytp-progress-list > *"); + const changes: Record<string, HTMLElement> = {}; + for (const element of elements) { + changes[element.classList[0]] = element as HTMLElement; + } + + this.updateChapterMutation(changes, progressBar, firstUpdate); + } + + private updateChapterMutation(changes: Record<string, HTMLElement>, progressBar: HTMLElement, firstUpdate = false): void { + // Go through each newly generated chapter bar and update the width based on changes array + if (this.customChaptersBar) { + // Width reached so far in decimal percent + let cursor = 0; + + const sections = this.customChaptersBar.querySelectorAll(".ytp-chapter-hover-container") as NodeListOf<HTMLElement>; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + + const sectionWidthDecimal = parseFloat(section.getAttribute("decimal-width")); + const sectionWidthDecimalNoMargin = sectionWidthDecimal - 2 / progressBar.clientWidth; + + for (const className in changes) { + const selector = `.${className}` + const customChangedElement = section.querySelector(selector) as HTMLElement; + if (customChangedElement) { + const fullSectionWidth = i === sections.length - 1 ? sectionWidthDecimal : sectionWidthDecimalNoMargin; + const changedElement = changes[className]; + const changedData = this.findLeftAndScale(selector, changedElement, progressBar); + + const left = (changedData.left) / progressBar.clientWidth; + const calculatedLeft = Math.max(0, Math.min(1, (left - cursor) / fullSectionWidth)); + if (!isNaN(left) && !isNaN(calculatedLeft)) { + customChangedElement.style.left = `${calculatedLeft * 100}%`; + customChangedElement.style.removeProperty("display"); + } + + if (changedData.scale !== null) { + const transformScale = (changedData.scale) / progressBar.clientWidth; + + customChangedElement.style.transform = + `scaleX(${Math.max(0, Math.min(1 - calculatedLeft, (transformScale - cursor) / fullSectionWidth - calculatedLeft))}`; + if (firstUpdate) { + customChangedElement.style.transition = "none"; + setTimeout(() => customChangedElement.style.removeProperty("transition"), 50); + } + } + + if (customChangedElement.className !== changedElement.className) { + customChangedElement.className = changedElement.className; + } + } + } + + cursor += sectionWidthDecimal; + } + } + } + + private findLeftAndScale(selector: string, currentElement: HTMLElement, progressBar: HTMLElement): + { left: number, scale: number } { + const sections = currentElement.parentElement.parentElement.parentElement.children; + let currentWidth = 0; + + let left = 0; + let leftPosition = 0; + + let scale = null; + let scalePosition = 0; + let scaleWidth = 0; + + for (let i = 0; i < sections.length; i++) { + const section = sections[i] as HTMLElement; + const checkElement = section.querySelector(selector) as HTMLElement; + const currentSectionWidthNoMargin = this.getPartialChapterSectionStyle(section, "width") || progressBar.clientWidth; + const currentSectionWidth = currentSectionWidthNoMargin + + this.getPartialChapterSectionStyle(section, "marginRight"); + + // First check for left + const checkLeft = parseFloat(checkElement.style.left.replace("px", "")); + if (checkLeft !== 0) { + left = checkLeft; + leftPosition = currentWidth; + } + + // Then check for scale + const transformMatch = checkElement.style.transform.match(/scaleX\(([0-9.]+?)\)/); + if (transformMatch) { + const transformScale = parseFloat(transformMatch[1]); + if (i === sections.length - 1 || (transformScale < 1 && transformScale + checkLeft / currentSectionWidthNoMargin < 0.99999)) { + scale = transformScale; + scaleWidth = currentSectionWidthNoMargin; + + if (transformScale > 0) { + // reached the end of this section for sure, since the scale is now between 0 and 1 + // if the scale is always zero, then it will go through all sections but still return 0 + + scalePosition = currentWidth; + if (checkLeft !== 0) { + scalePosition += left; + } + break; + } + } + } + + currentWidth += currentSectionWidth; + } + + return { + left: left + leftPosition, + scale: scale !== null ? scale * scaleWidth + scalePosition : null + }; + } + + private getPartialChapterSectionStyle(element: HTMLElement, param: string): number { + const data = element.style[param]; + if (data?.includes("100%")) { + return 0; + } else { + return parseInt(element.style[param].match(/\d+/g)?.[0]) || 0; + } + } + + updateChapterText(segments: SponsorTime[], submittingSegments: SponsorTime[], currentTime: number): void { + if (!segments && submittingSegments?.length <= 0) return; + + segments ??= []; + if (submittingSegments?.length > 0) segments = segments.concat(submittingSegments); + const activeSegments = segments.filter((segment) => { + return segment.hidden === SponsorHideType.Visible + && segment.segment[0] <= currentTime && segment.segment[1] > currentTime; + }); + + this.setActiveSegments(activeSegments); + } + + /** + * Adds the text to the chapters slot if not filled by default + */ + private setActiveSegments(segments: SponsorTime[]): void { + const chaptersContainer = document.querySelector(".ytp-chapter-container") as HTMLDivElement; + + if (chaptersContainer) { + // TODO: Check if existing chapters exist (if big chapters menu is available?) + + if (segments.length > 0) { + chaptersContainer.style.removeProperty("display"); + + const chosenSegment = segments.sort((a, b) => { + if (a.actionType === ActionType.Chapter && b.actionType !== ActionType.Chapter) { + return -1; + } else if (a.actionType !== ActionType.Chapter && b.actionType === ActionType.Chapter) { + return 1; + } else { + return (b.segment[0] - a.segment[0]); + } + })[0]; + + const chapterButton = chaptersContainer.querySelector("button.ytp-chapter-title") as HTMLButtonElement; + chapterButton.classList.remove("ytp-chapter-container-disabled"); + chapterButton.disabled = false; + + const chapterTitle = chaptersContainer.querySelector(".ytp-chapter-title-content") as HTMLDivElement; + chapterTitle.innerText = chosenSegment.description || shortCategoryName(chosenSegment.category); + + const chapterVoteContainer = this.chapterVote.getContainer(); + if (chosenSegment.source === SponsorSourceType.Server) { + if (!chapterButton.contains(chapterVoteContainer)) { + chapterButton.insertBefore(chapterVoteContainer, this.getChapterChevron()); + } + + this.chapterVote.setVisibility(true); + this.chapterVote.setSegment(chosenSegment); + } else { + this.chapterVote.setVisibility(false); + } + } else { + // Hide chapters menu again + chaptersContainer.style.display = "none"; + } + } + } + remove(): void { this.container.remove(); @@ -218,14 +686,66 @@ class PreviewBar { this.categoryTooltip = undefined; } - if (this.tooltipContainer) { - this.tooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); - this.tooltipContainer = undefined; + if (this.categoryTooltipContainer) { + this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); + this.categoryTooltipContainer = undefined; } } + private chapterFilter(segment: PreviewBarSegment): boolean { + return (Config.config.renderSegmentsAsChapters || segment.actionType === ActionType.Chapter) + && segment.actionType !== ActionType.Poi + && this.chapterGroupFilter(segment); + } + + private chapterGroupFilter(segment: SegmentContainer): boolean { + return segment.segment.length === 2 && this.intervalToDecimal(segment.segment[0], segment.segment[1]) > MIN_CHAPTER_SIZE; + } + + intervalToPercentage(startTime: number, endTime: number) { + return `${this.intervalToDecimal(startTime, endTime) * 100}%`; + } + + intervalToDecimal(startTime: number, endTime: number) { + return (this.timeToDecimal(endTime) - this.timeToDecimal(startTime)); + } + timeToPercentage(time: number): string { - return Math.min(100, time / this.videoDuration * 100) + '%'; + return `${this.timeToDecimal(time) * 100}%` + } + + timeToDecimal(time: number): number { + if (this.originalChapterBarBlocks?.length > 1 && this.existingChapters.length === this.originalChapterBarBlocks?.length) { + // Parent element to still work when display: none + const totalPixels = this.originalChapterBar.parentElement.clientWidth; + let pixelOffset = 0; + let lastCheckedChapter = -1; + for (let i = 0; i < this.originalChapterBarBlocks.length; i++) { + const chapterElement = this.originalChapterBarBlocks[i]; + const widthPixels = parseFloat(chapterElement.style.width.replace("px", "")); + + if (time >= this.existingChapters[i].segment[1]) { + const marginPixels = chapterElement.style.marginRight ? parseFloat(chapterElement.style.marginRight.replace("px", "")) : 0; + pixelOffset += widthPixels + marginPixels; + lastCheckedChapter = i; + } else { + break; + } + } + + // The next chapter is the one we are currently inside of + const latestChapter = this.existingChapters[lastCheckedChapter + 1]; + if (latestChapter) { + const latestWidth = parseFloat(this.originalChapterBarBlocks[lastCheckedChapter + 1].style.width.replace("px", "")); + const latestChapterDuration = latestChapter.segment[1] - latestChapter.segment[0]; + + const percentageInCurrentChapter = (time - latestChapter.segment[0]) / latestChapterDuration; + const sizeOfCurrentChapter = latestWidth / totalPixels; + return Math.min(1, ((pixelOffset / totalPixels) + (percentageInCurrentChapter * sizeOfCurrentChapter))); + } + } + + return Math.min(1, time / this.videoDuration); } /* @@ -234,6 +754,31 @@ class PreviewBar { getMinimumSize(showLarger = false): number { return this.videoDuration * (showLarger ? 0.006 : 0.003); } + + private getSmallestSegment(timeInSeconds: number, segments: PreviewBarSegment[]): PreviewBarSegment | null { + let segment: PreviewBarSegment | null = null; + let currentSegmentLength = Infinity; + + for (const seg of segments) { // + const segmentLength = seg.segment[1] - seg.segment[0]; + const minSize = this.getMinimumSize(seg.showLarger); + + const startTime = segmentLength !== 0 ? seg.segment[0] : Math.floor(seg.segment[0]); + const endTime = segmentLength > minSize ? seg.segment[1] : Math.ceil(seg.segment[0] + minSize); + if (startTime <= timeInSeconds && endTime >= timeInSeconds) { + if (segmentLength < currentSegmentLength) { + currentSegmentLength = segmentLength; + segment = seg; + } + } + } + + return segment; + } + + private getChapterChevron(): HTMLElement { + return document.querySelector(".ytp-chapter-title-chevron"); + } } export default PreviewBar; diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 764d2dc4..c48aba34 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -30,6 +30,11 @@ interface IsInfoFoundMessage { updating: boolean; } +interface SkipMessage { + message: "unskip" | "reskip"; + UUID: SegmentUUID; +} + interface SubmitVoteMessage { message: "submitVote"; type: number; @@ -47,6 +52,11 @@ interface CopyToClipboardMessage { text: string; } +interface ImportSegmentsMessage { + message: "importSegments"; + data: string; +} + interface KeyDownMessage { message: "keydown"; key: string; @@ -59,12 +69,13 @@ interface KeyDownMessage { metaKey: boolean; } -export type Message = BaseMessage & (DefaultMessage | BoolValueMessage | IsInfoFoundMessage | SubmitVoteMessage | HideSegmentMessage | CopyToClipboardMessage | KeyDownMessage); +export type Message = BaseMessage & (DefaultMessage | BoolValueMessage | IsInfoFoundMessage | SkipMessage | SubmitVoteMessage | HideSegmentMessage | CopyToClipboardMessage | ImportSegmentsMessage | KeyDownMessage); export interface IsInfoFoundMessageResponse { found: boolean; status: number; sponsorTimes: SponsorTime[]; + time: number; onMobileYouTube: boolean; } @@ -90,11 +101,23 @@ export type MessageResponse = | GetChannelIDResponse | SponsorStartResponse | IsChannelWhitelistedResponse - | Record<never, never> // empty object response {} - | VoteResponse; + | Record<string, never> // empty object response {} + | VoteResponse + | ImportSegmentsResponse; export interface VoteResponse { successType: number; statusCode: number; responseText: string; -}
\ No newline at end of file +} + +export interface ImportSegmentsResponse { + importedSegments: SponsorTime[]; +} + +export interface TimeUpdateMessage { + message: "time"; + time: number; +} + +export type PopupMessage = TimeUpdateMessage; diff --git a/src/options.ts b/src/options.ts index 9d456936..1a522c9b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -10,7 +10,7 @@ window.SB = Config; import Utils from "./utils"; import CategoryChooser from "./render/CategoryChooser"; -import KeybindComponent from "./components/KeybindComponent"; +import KeybindComponent from "./components/options/KeybindComponent"; import { showDonationLink } from "./utils/configUtils"; import { localizeHtmlPage } from "./utils/pageUtils"; const utils = new Utils(); diff --git a/src/popup.ts b/src/popup.ts index 73d33f18..e357034c 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -1,12 +1,15 @@ import Config from "./config"; import Utils from "./utils"; -import { SponsorTime, SponsorHideType, ActionType, StorageChangesObject } from "./types"; -import { Message, MessageResponse, IsInfoFoundMessageResponse } from "./messageTypes"; +import { SponsorTime, SponsorHideType, ActionType, SegmentUUID, SponsorSourceType, StorageChangesObject, CategorySkipOption } from "./types"; +import { Message, MessageResponse, IsInfoFoundMessageResponse, ImportSegmentsResponse, PopupMessage } from "./messageTypes"; import { showDonationLink } from "./utils/configUtils"; import { AnimationUtils } from "./utils/animationUtils"; import { GenericUtils } from "./utils/genericUtils"; +import { shortCategoryName } from "./utils/categoryUtils"; import { localizeHtmlPage } from "./utils/pageUtils"; +import { exportTimes } from "./utils/exporter"; +import GenericNotice from "./render/GenericNotice"; const utils = new Utils(); interface MessageListener { @@ -68,10 +71,18 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { //the start and end time pairs (2d) let sponsorTimes: SponsorTime[] = []; + let downloadedTimes: SponsorTime[] = []; //current video ID of this tab let currentVideoID = null; + enum SegmentTab { + Segments, + Chapters + } + let segmentTab = SegmentTab.Segments; + let port: chrome.runtime.Port = null; + const PageElements: PageElements = {}; [ @@ -124,11 +135,21 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { "refreshSegmentsButton", "whitelistButton", "sbDonate", + "issueReporterTabs", + "issueReporterTabSegments", + "issueReporterTabChapters", "sponsorTimesDonateContainer", "sbConsiderDonateLink", "sbCloseDonate", "sbBetaServerWarning", - "sbCloseButton" + "sbCloseButton", + "issueReporterImportExport", + "importSegmentsButton", + "exportSegmentsButton", + "importSegmentsMenu", + "importSegmentsText", + "importSegmentsSubmit" + ].forEach(id => PageElements[id] = document.getElementById(id)); getSegmentsFromContentScript(false); @@ -162,7 +183,11 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { }); } - //setup click listeners + PageElements.exportSegmentsButton.addEventListener("click", exportSegments); + PageElements.importSegmentsButton.addEventListener("click", + () => PageElements.importSegmentsMenu.classList.toggle("hidden")); + PageElements.importSegmentsSubmit.addEventListener("click", importSegments); + PageElements.sponsorStart.addEventListener("click", sendSponsorStartMessage); PageElements.whitelistToggle.addEventListener("change", function () { if (this.checked) { @@ -215,6 +240,8 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { }); } + setupComPort(); + //show proper disable skipping button const disableSkipping = Config.config.disableSkipping; if (disableSkipping != undefined && disableSkipping) { @@ -230,7 +257,8 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { PageElements.showNoticeAgain.style.display = "unset"; } - utils.sendRequestToServer("GET", "/api/userInfo?value=userName&value=viewCount&value=minutesSaved&value=vip&userID=" + Config.config.userID, (res) => { + utils.sendRequestToServer("GET", "/api/userInfo?value=userName&value=viewCount&value=minutesSaved&value=vip&value=permissions&value=freeChaptersAccess&userID=" + + Config.config.userID, (res) => { if (res.status === 200) { const userInfo = JSON.parse(res.responseText); PageElements.usernameValue.innerText = userInfo.userName; @@ -259,6 +287,14 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { } Config.config.isVip = userInfo.vip; + Config.config.permissions = userInfo.permissions; + + if (userInfo.freeChaptersAccess) { + Config.config.payments.chaptersAllowed = userInfo.freeChaptersAccess; + Config.config.payments.freeAccess = userInfo.freeChaptersAccess; + Config.config.payments.lastCheck = Date.now(); + Config.forceSyncUpdate("payments"); + } } }); @@ -294,6 +330,22 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { // Must be delayed so it only happens once loaded setTimeout(() => PageElements.sponsorblockPopup.classList.remove("preload"), 250); + PageElements.issueReporterTabSegments.addEventListener("click", () => { + PageElements.issueReporterTabSegments.classList.add("sbSelected"); + PageElements.issueReporterTabChapters.classList.remove("sbSelected"); + + segmentTab = SegmentTab.Segments; + getSegmentsFromContentScript(true); + }); + + PageElements.issueReporterTabChapters.addEventListener("click", () => { + PageElements.issueReporterTabSegments.classList.remove("sbSelected"); + PageElements.issueReporterTabChapters.classList.add("sbSelected"); + + segmentTab = SegmentTab.Chapters; + getSegmentsFromContentScript(true); + }); + function showDonateWidget(viewCount: number) { if (Config.config.showDonationLink && Config.config.donateClicked <= 0 && Config.config.showPopupDonationCount < 5 && viewCount < 50000 && !Config.config.isVip && Config.config.skipCount > 10) { @@ -365,10 +417,13 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { PageElements.whitelistButton.classList.remove("hidden"); PageElements.loadingIndicator.style.display = "none"; + downloadedTimes = request.sponsorTimes ?? []; if (request.found) { PageElements.videoFound.innerHTML = chrome.i18n.getMessage("sponsorFound"); - displayDownloadedSponsorTimes(request); + if (request.sponsorTimes) { + displayDownloadedSponsorTimes(request.sponsorTimes, request.time); + } } else if (request.status == 404 || request.status == 200) { PageElements.videoFound.innerHTML = chrome.i18n.getMessage("sponsor404"); } else { @@ -441,165 +496,208 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { } //display the video times from the array at the top, in a different section - function displayDownloadedSponsorTimes(request: { found: boolean, sponsorTimes: SponsorTime[] }) { - if (request.sponsorTimes != undefined) { - // Sort list by start time - const segmentTimes = request.sponsorTimes - .sort((a, b) => a.segment[1] - b.segment[1]) - .sort((a, b) => a.segment[0] - b.segment[0]); - - //add them as buttons to the issue reporting container - const container = document.getElementById("issueReporterTimeButtons"); - while (container.firstChild) { - container.removeChild(container.firstChild); - } + function displayDownloadedSponsorTimes(sponsorTimes: SponsorTime[], time: number) { + let currentSegmentTab = segmentTab; + if (!sponsorTimes.some((segment) => segment.actionType === ActionType.Chapter)) { + PageElements.issueReporterTabs.classList.add("hidden"); + currentSegmentTab = SegmentTab.Segments; + } else { + PageElements.issueReporterTabs.classList.remove("hidden"); + } - const isVip = Config.config.isVip; - for (let i = 0; i < segmentTimes.length; i++) { - const UUID = segmentTimes[i].UUID; - const locked = segmentTimes[i].locked; - - const segmentSummary = document.createElement("summary"); - segmentSummary.className = "segmentSummary"; - - const categoryColorCircle = document.createElement("span"); - categoryColorCircle.id = "sponsorTimesCategoryColorCircle" + UUID; - categoryColorCircle.style.backgroundColor = Config.config.barTypes[segmentTimes[i].category]?.color; - categoryColorCircle.classList.add("dot"); - categoryColorCircle.classList.add("sponsorTimesCategoryColorCircle"); - - let extraInfo = ""; - if (segmentTimes[i].hidden === SponsorHideType.Downvoted) { - //this one is downvoted - extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDownvote") + ")"; - } else if (segmentTimes[i].hidden === SponsorHideType.MinimumDuration) { - //this one is too short - extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDuration") + ")"; - } else if (segmentTimes[i].hidden === SponsorHideType.Hidden) { - extraInfo = " (" + chrome.i18n.getMessage("manuallyHidden") + ")"; + // Sort list by start time + const downloadedTimes = sponsorTimes + .filter((segment) => { + if (currentSegmentTab === SegmentTab.Segments) { + return segment.actionType !== ActionType.Chapter; + } else if (currentSegmentTab === SegmentTab.Chapters) { + return segment.actionType === ActionType.Chapter + && segment.source !== SponsorSourceType.YouTube; + } else { + return true; } + }) + .sort((a, b) => a.segment[1] - b.segment[1]) + .sort((a, b) => a.segment[0] - b.segment[0]); + + //add them as buttons to the issue reporting container + const container = document.getElementById("issueReporterTimeButtons"); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + if (downloadedTimes.length > 0) { + PageElements.issueReporterImportExport.classList.remove("hidden"); + if (utils.getCategorySelection("chapter")?.option === CategorySkipOption.ShowOverlay) { + PageElements.importSegmentsButton.classList.remove("hidden"); + } + } else { + PageElements.issueReporterImportExport.classList.add("hidden"); + } - const textNode = document.createTextNode(utils.shortCategoryName(segmentTimes[i].category) + extraInfo); - const segmentTimeFromToNode = document.createElement("div"); - if (segmentTimes[i].actionType === ActionType.Full) { - segmentTimeFromToNode.innerText = chrome.i18n.getMessage("full"); + const isVip = Config.config.isVip; + for (let i = 0; i < downloadedTimes.length; i++) { + const UUID = downloadedTimes[i].UUID; + const locked = downloadedTimes[i].locked; + const category = downloadedTimes[i].category; + const actionType = downloadedTimes[i].actionType; + + const segmentSummary = document.createElement("summary"); + segmentSummary.classList.add("segmentSummary"); + if (time >= downloadedTimes[i].segment[0]) { + if (time < downloadedTimes[i].segment[1]) { + segmentSummary.classList.add("segmentActive"); } else { - segmentTimeFromToNode.innerText = utils.getFormattedTime(segmentTimes[i].segment[0], true) + - (segmentTimes[i].actionType !== ActionType.Poi - ? " " + chrome.i18n.getMessage("to") + " " + utils.getFormattedTime(segmentTimes[i].segment[1], true) - : ""); + segmentSummary.classList.add("segmentPassed"); } + } - segmentTimeFromToNode.style.margin = "5px"; - - // for inline-styling purposes - const labelContainer = document.createElement("div"); - labelContainer.appendChild(categoryColorCircle); - - const span = document.createElement('span'); - span.className = "summaryLabel"; - span.appendChild(textNode); - labelContainer.appendChild(span); - // for inline-styling purposes - - segmentSummary.appendChild(labelContainer); - segmentSummary.appendChild(segmentTimeFromToNode); - - const votingButtons = document.createElement("details"); - votingButtons.classList.add("votingButtons"); - - //thumbs up and down buttons - const voteButtonsContainer = document.createElement("div"); - voteButtonsContainer.id = "sponsorTimesVoteButtonsContainer" + UUID; - voteButtonsContainer.classList.add("sbVoteButtonsContainer"); - - const upvoteButton = document.createElement("img"); - upvoteButton.id = "sponsorTimesUpvoteButtonsContainer" + UUID; - upvoteButton.className = "voteButton"; - upvoteButton.title = chrome.i18n.getMessage("upvote"); - upvoteButton.src = chrome.runtime.getURL("icons/thumbs_up.svg"); - upvoteButton.addEventListener("click", () => vote(1, UUID)); - - const downvoteButton = document.createElement("img"); - downvoteButton.id = "sponsorTimesDownvoteButtonsContainer" + UUID; - downvoteButton.className = "voteButton"; - downvoteButton.title = chrome.i18n.getMessage("downvote"); - downvoteButton.src = locked && isVip ? chrome.runtime.getURL("icons/thumbs_down_locked.svg") : chrome.runtime.getURL("icons/thumbs_down.svg"); - downvoteButton.addEventListener("click", () => vote(0, UUID)); - - const uuidButton = document.createElement("img"); - uuidButton.id = "sponsorTimesCopyUUIDButtonContainer" + UUID; - uuidButton.className = "voteButton"; - uuidButton.src = chrome.runtime.getURL("icons/clipboard.svg"); - uuidButton.title = chrome.i18n.getMessage("copySegmentID"); - uuidButton.addEventListener("click", () => { - copyToClipboard(UUID); - const stopAnimation = AnimationUtils.applyLoadingAnimation(uuidButton, 0.3); - stopAnimation(); - }); + const categoryColorCircle = document.createElement("span"); + categoryColorCircle.id = "sponsorTimesCategoryColorCircle" + UUID; + categoryColorCircle.style.backgroundColor = Config.config.barTypes[category]?.color; + categoryColorCircle.classList.add("dot"); + categoryColorCircle.classList.add("sponsorTimesCategoryColorCircle"); + + let extraInfo = ""; + if (downloadedTimes[i].hidden === SponsorHideType.Downvoted) { + //this one is downvoted + extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDownvote") + ")"; + } else if (downloadedTimes[i].hidden === SponsorHideType.MinimumDuration) { + //this one is too short + extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDuration") + ")"; + } else if (downloadedTimes[i].hidden === SponsorHideType.Hidden) { + extraInfo = " (" + chrome.i18n.getMessage("manuallyHidden") + ")"; + } - const hideButton = document.createElement("img"); - hideButton.id = "sponsorTimesCopyUUIDButtonContainer" + UUID; - hideButton.className = "voteButton"; - hideButton.title = chrome.i18n.getMessage("hideSegment"); - if (segmentTimes[i].hidden === SponsorHideType.Hidden) { - hideButton.src = chrome.runtime.getURL("icons/not_visible.svg"); - } else { + const name = downloadedTimes[i].description || shortCategoryName(category); + const textNode = document.createTextNode(name + extraInfo); + const segmentTimeFromToNode = document.createElement("div"); + if (downloadedTimes[i].actionType === ActionType.Full) { + segmentTimeFromToNode.innerText = chrome.i18n.getMessage("full"); + } else { + segmentTimeFromToNode.innerText = GenericUtils.getFormattedTime(downloadedTimes[i].segment[0], true) + + (actionType !== ActionType.Poi + ? " " + chrome.i18n.getMessage("to") + " " + GenericUtils.getFormattedTime(downloadedTimes[i].segment[1], true) + : ""); + } + + segmentTimeFromToNode.style.margin = "5px"; + + // for inline-styling purposes + const labelContainer = document.createElement("div"); + if (actionType !== ActionType.Chapter) labelContainer.appendChild(categoryColorCircle); + + const span = document.createElement('span'); + span.className = "summaryLabel"; + span.appendChild(textNode); + labelContainer.appendChild(span); + + segmentSummary.appendChild(labelContainer); + segmentSummary.appendChild(segmentTimeFromToNode); + + const votingButtons = document.createElement("details"); + votingButtons.classList.add("votingButtons"); + + //thumbs up and down buttons + const voteButtonsContainer = document.createElement("div"); + voteButtonsContainer.id = "sponsorTimesVoteButtonsContainer" + UUID; + voteButtonsContainer.classList.add("sbVoteButtonsContainer"); + + const upvoteButton = document.createElement("img"); + upvoteButton.id = "sponsorTimesUpvoteButtonsContainer" + UUID; + upvoteButton.className = "voteButton"; + upvoteButton.title = chrome.i18n.getMessage("upvote"); + upvoteButton.src = chrome.runtime.getURL("icons/thumbs_up.svg"); + upvoteButton.addEventListener("click", () => vote(1, UUID)); + + const downvoteButton = document.createElement("img"); + downvoteButton.id = "sponsorTimesDownvoteButtonsContainer" + UUID; + downvoteButton.className = "voteButton"; + downvoteButton.title = chrome.i18n.getMessage("downvote"); + downvoteButton.src = locked && isVip ? chrome.runtime.getURL("icons/thumbs_down_locked.svg") : chrome.runtime.getURL("icons/thumbs_down.svg"); + downvoteButton.addEventListener("click", () => vote(0, UUID)); + + const uuidButton = document.createElement("img"); + uuidButton.id = "sponsorTimesCopyUUIDButtonContainer" + UUID; + uuidButton.className = "voteButton"; + uuidButton.src = chrome.runtime.getURL("icons/clipboard.svg"); + uuidButton.title = chrome.i18n.getMessage("copySegmentID"); + uuidButton.addEventListener("click", () => { + copyToClipboard(UUID); + const stopAnimation = AnimationUtils.applyLoadingAnimation(uuidButton, 0.3); + stopAnimation(); + }); + + const hideButton = document.createElement("img"); + hideButton.id = "sponsorTimesCopyUUIDButtonContainer" + UUID; + hideButton.className = "voteButton"; + hideButton.title = chrome.i18n.getMessage("hideSegment"); + if (downloadedTimes[i].hidden === SponsorHideType.Hidden) { + hideButton.src = chrome.runtime.getURL("icons/not_visible.svg"); + } else { + hideButton.src = chrome.runtime.getURL("icons/visible.svg"); + } + hideButton.addEventListener("click", () => { + const stopAnimation = AnimationUtils.applyLoadingAnimation(hideButton, 0.4); + stopAnimation(); + + if (downloadedTimes[i].hidden === SponsorHideType.Hidden) { hideButton.src = chrome.runtime.getURL("icons/visible.svg"); + downloadedTimes[i].hidden = SponsorHideType.Visible; + } else { + hideButton.src = chrome.runtime.getURL("icons/not_visible.svg"); + downloadedTimes[i].hidden = SponsorHideType.Hidden; } - hideButton.addEventListener("click", () => { - const stopAnimation = AnimationUtils.applyLoadingAnimation(hideButton, 0.4); - stopAnimation(); - if (segmentTimes[i].hidden === SponsorHideType.Hidden) { - hideButton.src = chrome.runtime.getURL("icons/visible.svg"); - segmentTimes[i].hidden = SponsorHideType.Visible; - } else { - hideButton.src = chrome.runtime.getURL("icons/not_visible.svg"); - segmentTimes[i].hidden = SponsorHideType.Hidden; - } - - messageHandler.query({ - active: true, - currentWindow: true - }, tabs => { - messageHandler.sendMessage( - tabs[0].id, - { - message: "hideSegment", - type: segmentTimes[i].hidden, - UUID: UUID - } - ); - }); + messageHandler.query({ + active: true, + currentWindow: true + }, tabs => { + messageHandler.sendMessage( + tabs[0].id, + { + message: "hideSegment", + type: downloadedTimes[i].hidden, + UUID: UUID + } + ); }); + }); - //add thumbs up, thumbs down and uuid copy buttons to the container - voteButtonsContainer.appendChild(upvoteButton); - voteButtonsContainer.appendChild(downvoteButton); - voteButtonsContainer.appendChild(uuidButton); - if ((segmentTimes[i].actionType === ActionType.Skip || segmentTimes[i].actionType === ActionType.Mute) - && [SponsorHideType.Visible, SponsorHideType.Hidden].includes(segmentTimes[i].hidden)) { - voteButtonsContainer.appendChild(hideButton); - } + const skipButton = document.createElement("img"); + skipButton.id = "sponsorTimesSkipButtonContainer" + UUID; + skipButton.className = "voteButton"; + skipButton.src = chrome.runtime.getURL("icons/skip.svg"); + skipButton.addEventListener("click", () => skipSegment(actionType, UUID, skipButton)); + votingButtons.addEventListener("dblclick", () => skipSegment(actionType, UUID)); + + //add thumbs up, thumbs down and uuid copy buttons to the container + voteButtonsContainer.appendChild(upvoteButton); + voteButtonsContainer.appendChild(downvoteButton); + voteButtonsContainer.appendChild(uuidButton); + if (downloadedTimes[i].actionType === ActionType.Skip || downloadedTimes[i].actionType === ActionType.Mute + && [SponsorHideType.Visible, SponsorHideType.Hidden].includes(downloadedTimes[i].hidden)) { + voteButtonsContainer.appendChild(hideButton); + } + voteButtonsContainer.appendChild(skipButton); - // Will contain request status - const voteStatusContainer = document.createElement("div"); - voteStatusContainer.id = "sponsorTimesVoteStatusContainer" + UUID; - voteStatusContainer.classList.add("sponsorTimesVoteStatusContainer"); - voteStatusContainer.style.display = "none"; - const thanksForVotingText = document.createElement("div"); - thanksForVotingText.id = "sponsorTimesThanksForVotingText" + UUID; - thanksForVotingText.classList.add("sponsorTimesThanksForVotingText"); - voteStatusContainer.appendChild(thanksForVotingText); + // Will contain request status + const voteStatusContainer = document.createElement("div"); + voteStatusContainer.id = "sponsorTimesVoteStatusContainer" + UUID; + voteStatusContainer.classList.add("sponsorTimesVoteStatusContainer"); + voteStatusContainer.style.display = "none"; - votingButtons.append(segmentSummary); - votingButtons.append(voteButtonsContainer); - votingButtons.append(voteStatusContainer); + const thanksForVotingText = document.createElement("div"); + thanksForVotingText.id = "sponsorTimesThanksForVotingText" + UUID; + thanksForVotingText.classList.add("sponsorTimesThanksForVotingText"); + voteStatusContainer.appendChild(thanksForVotingText); - container.appendChild(votingButtons); - } + votingButtons.append(segmentSummary); + votingButtons.append(voteButtonsContainer); + votingButtons.append(voteStatusContainer); + + container.appendChild(votingButtons); } } @@ -708,6 +806,8 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { //this is not a YouTube video page function displayNoVideo() { document.getElementById("loadingIndicator").innerText = chrome.i18n.getMessage("noVideoID"); + + PageElements.issueReporterTabs.classList.add("hidden"); } function addVoteMessage(message, UUID) { @@ -881,6 +981,37 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { ); } + function skipSegment(actionType: ActionType, UUID: SegmentUUID, element?: HTMLElement): void { + if (actionType === ActionType.Chapter) { + sendMessage({ + message: "unskip", + UUID: UUID + }); + } else { + sendMessage({ + message: "reskip", + UUID: UUID + }); + } + + if (element) { + const stopAnimation = AnimationUtils.applyLoadingAnimation(element, 0.3); + stopAnimation(); + } + } + + function sendMessage(request: Message): void { + messageHandler.query({ + active: true, + currentWindow: true + }, tabs => { + messageHandler.sendMessage( + tabs[0].id, + request + ); + }); + } + /** * Should skipping be disabled (visuals stay) */ @@ -910,6 +1041,41 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { } } + async function importSegments() { + const text = (PageElements.importSegmentsText as HTMLInputElement).value; + + await sendTabMessage({ + message: "importSegments", + data: text + }) as ImportSegmentsResponse; + + PageElements.importSegmentsMenu.classList.add("hidden"); + } + + function exportSegments() { + copyToClipboard(exportTimes(downloadedTimes)); + + const stopAnimation = AnimationUtils.applyLoadingAnimation(PageElements.exportSegmentsButton, 0.3); + stopAnimation(); + new GenericNotice(null, "exportCopied", { + title: chrome.i18n.getMessage(`CopiedExclamation`), + timed: true, + maxCountdownTime: () => 0.6, + referenceNode: PageElements.exportSegmentsButton.parentElement, + dontPauseCountdown: true, + style: { + top: 0, + bottom: 0, + minWidth: 0, + right: "30px", + margin: "auto", + height: "max-content" + }, + hideLogo: true, + hideRightInfo: true + }); + } + /** * Converts time in minutes to 2d 5h 25.1 * If less than 1 hour, just returns minutes @@ -934,6 +1100,20 @@ async function runThePopup(messageListener?: MessageListener): Promise<void> { } } } + + function setupComPort(): void { + port = chrome.runtime.connect({ name: "popup" }); + port.onDisconnect.addListener(() => setupComPort()); + port.onMessage.addListener((msg) => onMessage(msg)); + } + + function onMessage(msg: PopupMessage) { + switch (msg.message) { + case "time": + displayDownloadedSponsorTimes(downloadedTimes, msg.time); + break; + } + } } runThePopup(); diff --git a/src/render/CategoryChooser.tsx b/src/render/CategoryChooser.tsx index eab9edf6..97c33cf9 100644 --- a/src/render/CategoryChooser.tsx +++ b/src/render/CategoryChooser.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import CategoryChooserComponent from "../components/CategoryChooserComponent"; +import CategoryChooserComponent from "../components/options/CategoryChooserComponent"; class CategoryChooser { diff --git a/src/render/ChapterVote.tsx b/src/render/ChapterVote.tsx new file mode 100644 index 00000000..b78a5ce8 --- /dev/null +++ b/src/render/ChapterVote.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import ChapterVoteComponent, { ChapterVoteState } from "../components/ChapterVoteComponent"; +import { VoteResponse } from "../messageTypes"; +import { Category, SegmentUUID, SponsorTime } from "../types"; + +export class ChapterVote { + container: HTMLElement; + ref: React.RefObject<ChapterVoteComponent>; + + unsavedState: ChapterVoteState; + + mutationObserver?: MutationObserver; + + constructor(vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>) { + this.ref = React.createRef(); + + this.container = document.createElement('span'); + this.container.id = "chapterVote"; + this.container.style.height = "100%"; + + ReactDOM.render( + <ChapterVoteComponent ref={this.ref} vote={vote} />, + this.container + ); + } + + getContainer(): HTMLElement { + return this.container; + } + + close(): void { + ReactDOM.unmountComponentAtNode(this.container); + this.container.remove(); + } + + setVisibility(show: boolean): void { + const newState = { + show, + }; + + if (this.ref.current) { + this.ref.current?.setState(newState); + } else { + this.unsavedState = newState; + } + } + + async setSegment(segment: SponsorTime): Promise<void> { + if (this.ref.current?.state?.segment !== segment) { + const newState = { + segment, + show: true + }; + + if (this.ref.current) { + this.ref.current?.setState(newState); + } else { + this.unsavedState = newState; + } + } + } +}
\ No newline at end of file diff --git a/src/render/GenericNotice.tsx b/src/render/GenericNotice.tsx index 60b69835..1b691488 100644 --- a/src/render/GenericNotice.tsx +++ b/src/render/GenericNotice.tsx @@ -5,14 +5,9 @@ import NoticeComponent from "../components/NoticeComponent"; import Utils from "../utils"; const utils = new Utils(); -import { ContentContainer } from "../types"; +import { ButtonListener, ContentContainer } from "../types"; import NoticeTextSelectionComponent from "../components/NoticeTextSectionComponent"; -export interface ButtonListener { - name: string, - listener: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void -} - export interface TextBox { icon: string, text: string @@ -20,12 +15,17 @@ export interface TextBox { export interface NoticeOptions { title: string, + referenceNode?: HTMLElement, textBoxes?: TextBox[], buttons?: ButtonListener[], fadeIn?: boolean, timed?: boolean style?: React.CSSProperties; extraClass?: string; + maxCountdownTime?: () => number; + dontPauseCountdown?: boolean; + hideLogo?: boolean; + hideRightInfo?: boolean; } export default class GenericNotice { @@ -42,7 +42,7 @@ export default class GenericNotice { this.contentContainer = contentContainer; - const referenceNode = utils.findReferenceNode(); + const referenceNode = options.referenceNode ?? utils.findReferenceNode(); this.noticeElement = document.createElement("div"); this.noticeElement.id = "sponsorSkipNoticeContainer" + idSuffix; @@ -62,6 +62,10 @@ export default class GenericNotice { ref={this.noticeRef} style={options.style} extraClass={options.extraClass} + maxCountdownTime={options.maxCountdownTime} + dontPauseCountdown={options.dontPauseCountdown} + hideLogo={options.hideLogo} + hideRightInfo={options.hideRightInfo} closeListener={() => this.close()} > <tr id={"sponsorSkipNoticeMiddleRow" + this.idSuffix} diff --git a/src/render/RectangleTooltip.tsx b/src/render/RectangleTooltip.tsx index 1887cbbc..ea019db7 100644 --- a/src/render/RectangleTooltip.tsx +++ b/src/render/RectangleTooltip.tsx @@ -33,8 +33,8 @@ export class RectangleTooltip { props.fontSize ??= "10px"; this.container = document.createElement('div'); - props.htmlId ??= props.text; - this.container.id = "sponsorRectangleTooltip" + props.htmlId; + props.htmlId ??= "sponsorRectangleTooltip" + props.text; + this.container.id = props.htmlId; this.container.style.display = "relative"; if (props.prependElement) { diff --git a/src/render/Tooltip.tsx b/src/render/Tooltip.tsx index 66e581d5..d59728fe 100644 --- a/src/render/Tooltip.tsx +++ b/src/render/Tooltip.tsx @@ -1,29 +1,37 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; +import { ButtonListener } from "../types"; export interface TooltipProps { - text: string, - link?: string, - referenceNode: HTMLElement, - prependElement?: HTMLElement, // Element to append before - bottomOffset?: string + text?: string; + link?: string; + referenceNode: HTMLElement; + prependElement?: HTMLElement; // Element to append before + bottomOffset?: string; + leftOffset?: string; + rightOffset?: string; timeout?: number; opacity?: number; displayTriangle?: boolean; + extraClass?: string; showLogo?: boolean; showGotIt?: boolean; + buttons?: ButtonListener[]; } export class Tooltip { - text: string; + text?: string; container: HTMLDivElement; timer: NodeJS.Timeout; constructor(props: TooltipProps) { props.bottomOffset ??= "70px"; + props.leftOffset ??= "inherit"; + props.rightOffset ??= "inherit"; props.opacity ??= 0.7; props.displayTriangle ??= true; + props.extraClass ??= ""; props.showLogo ??= true; props.showGotIt ??= true; this.text = props.text; @@ -45,25 +53,29 @@ export class Tooltip { const backgroundColor = `rgba(28, 28, 28, ${props.opacity})`; ReactDOM.render( - <div style={{bottom: props.bottomOffset, backgroundColor}} - className={"sponsorBlockTooltip" + (props.displayTriangle ? " sbTriangle" : "")} > + <div style={{bottom: props.bottomOffset, left: props.leftOffset, right: props.rightOffset, backgroundColor}} + className={"sponsorBlockTooltip" + (props.displayTriangle ? " sbTriangle" : "") + ` ${props.extraClass}`}> <div> {props.showLogo ? <img className="sponsorSkipLogo sponsorSkipObject" src={chrome.extension.getURL("icons/IconSponsorBlocker256px.png")}> </img> : null} - <span className="sponsorSkipObject"> - {this.text + (props.link ? ". " : "")} - {props.link ? - <a style={{textDecoration: "underline"}} - target="_blank" - rel="noopener noreferrer" - href={props.link}> - {chrome.i18n.getMessage("LearnMore")} - </a> - : null} - </span> + {this.text ? + <span className="sponsorSkipObject"> + {this.text + (props.link ? ". " : "")} + {props.link ? + <a style={{textDecoration: "underline"}} + target="_blank" + rel="noopener noreferrer" + href={props.link}> + {chrome.i18n.getMessage("LearnMore")} + </a> + : null} + </span> + : null} + + {this.getButtons(props.buttons)} </div> {props.showGotIt ? <button className="sponsorSkipObject sponsorSkipNoticeButton" @@ -78,6 +90,27 @@ export class Tooltip { ) } + getButtons(buttons?: ButtonListener[]): JSX.Element[] { + if (buttons) { + const result: JSX.Element[] = []; + + for (const button of buttons) { + result.push( + <button className="sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeRightButton" + key={button.name} + onClick={(e) => button.listener(e)}> + + {button.name} + </button> + ) + } + + return result; + } else { + return null; + } + } + close(): void { ReactDOM.unmountComponentAtNode(this.container); this.container.remove(); diff --git a/src/svg-icons/lock_svg.tsx b/src/svg-icons/lock_svg.tsx new file mode 100644 index 00000000..ef077541 --- /dev/null +++ b/src/svg-icons/lock_svg.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +const lockSvg = ({ + fill = "#fcba03", + className = "", + width = "20", + height = "20", + onClick +}): JSX.Element => ( + <svg + xmlns="http://www.w3.org/2000/svg" + height={width} + width={height} + className={className} + fill={fill} + onClick={onClick} > + <path + d="M5.5 18q-.625 0-1.062-.438Q4 17.125 4 16.5v-8q0-.625.438-1.062Q4.875 7 5.5 7H6V5q0-1.667 1.167-2.833Q8.333 1 10 1q1.667 0 2.833 1.167Q14 3.333 14 5v2h.5q.625 0 1.062.438Q16 7.875 16 8.5v8q0 .625-.438 1.062Q15.125 18 14.5 18Zm4.5-4q.625 0 1.062-.438.438-.437.438-1.062t-.438-1.062Q10.625 11 10 11t-1.062.438Q8.5 11.875 8.5 12.5t.438 1.062Q9.375 14 10 14ZM7.5 7h5V5q0-1.042-.729-1.771Q11.042 2.5 10 2.5q-1.042 0-1.771.729Q7.5 3.958 7.5 5Z"/> + </svg> +); + +export default lockSvg; diff --git a/src/svg-icons/thumbs_down_svg.tsx b/src/svg-icons/thumbs_down_svg.tsx index ce61db5a..a1161c10 100644 --- a/src/svg-icons/thumbs_down_svg.tsx +++ b/src/svg-icons/thumbs_down_svg.tsx @@ -1,13 +1,17 @@ import * as React from "react"; const thumbsDownSvg = ({ - fill = "#ffffff" + fill = "#ffffff", + className = "", + width = "18", + height = "18" }): JSX.Element => ( <svg xmlns="http://www.w3.org/2000/svg" - width="18" - height="18" + width={width} + height={height} fill={fill} + className={className} viewBox="0 0 24 24" > <path diff --git a/src/svg-icons/thumbs_up_svg.tsx b/src/svg-icons/thumbs_up_svg.tsx index 10c95d94..08afec11 100644 --- a/src/svg-icons/thumbs_up_svg.tsx +++ b/src/svg-icons/thumbs_up_svg.tsx @@ -1,13 +1,17 @@ import * as React from "react"; const thumbsUpSvg = ({ - fill = "#ffffff" + fill = "#ffffff", + className = "", + width = "18", + height = "18" }): JSX.Element => ( <svg xmlns="http://www.w3.org/2000/svg" - width="18" - height="18" fill={fill} + width={width} + height={height} + className={className} viewBox="0 0 24 24" > <path diff --git a/src/types.ts b/src/types.ts index 48dfb7a8..6b8f9969 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,7 +21,8 @@ export interface ContentContainer { previewTime: (time: number, unpause?: boolean) => void, videoInfo: VideoInfo, getRealCurrentTime: () => number, - lockedCategories: string[] + lockedCategories: string[], + channelIDInfo: ChannelIDInfo } } @@ -58,6 +59,7 @@ export enum SponsorHideType { export enum ActionType { Skip = "skip", Mute = "mute", + Chapter = "chapter", Full = "full", Poi = "poi" } @@ -69,19 +71,24 @@ export type Category = string & { __categoryBrand: unknown }; export enum SponsorSourceType { Server = undefined, - Local = 1 + Local = 1, + YouTube = 2 } -export interface SponsorTime { +export interface SegmentContainer { segment: [number] | [number, number]; +} + +export interface SponsorTime extends SegmentContainer { UUID: SegmentUUID; locked?: number; category: Category; actionType: ActionType; + description?: string; hidden?: SponsorHideType; - source?: SponsorSourceType; + source: SponsorSourceType; videoDuration?: number; } @@ -230,4 +237,9 @@ export type Keybind = { ctrl?: boolean, alt?: boolean, shift?: boolean +} + +export interface ButtonListener { + name: string, + listener: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void }
\ No newline at end of file diff --git a/src/upsell.ts b/src/upsell.ts new file mode 100644 index 00000000..faa94a1d --- /dev/null +++ b/src/upsell.ts @@ -0,0 +1,71 @@ +import Config from "./config"; +import { checkLicenseKey } from "./utils/licenseKey"; +import { localizeHtmlPage } from "./utils/pageUtils"; + +import * as countries from "../public/res/countries.json"; + +// This is needed, if Config is not imported before Utils, things break. +// Probably due to cyclic dependencies +Config.config; + +window.addEventListener('DOMContentLoaded', init); + +async function init() { + localizeHtmlPage(); + + const cantAfford = document.getElementById("cantAfford"); + const cantAffordTexts = chrome.i18n.getMessage("cantAfford").split(/{|}/); + cantAfford.appendChild(document.createTextNode(cantAffordTexts[0])); + const discountButton = document.createElement("span"); + discountButton.id = "discountButton"; + discountButton.innerText = cantAffordTexts[1]; + cantAfford.appendChild(discountButton); + cantAfford.appendChild(document.createTextNode(cantAffordTexts[2])); + + const redeemButton = document.getElementById("redeemButton") as HTMLInputElement; + redeemButton.addEventListener("click", async () => { + const licenseKey = redeemButton.value; + + if (await checkLicenseKey(licenseKey)) { + Config.config.payments.licenseKey = licenseKey; + Config.forceSyncUpdate("payments"); + + alert(chrome.i18n.getMessage("redeemSuccess")); + } else { + alert(chrome.i18n.getMessage("redeemFailed")); + } + }); + + discountButton.addEventListener("click", async () => { + const subsidizedSection = document.getElementById("subsidizedPrice"); + subsidizedSection.classList.remove("hidden"); + + const oldSelector = document.getElementById("countrySelector"); + if (oldSelector) oldSelector.remove(); + const countrySelector = document.createElement("select"); + countrySelector.id = "countrySelector"; + countrySelector.className = "optionsSelector"; + const defaultOption = document.createElement("option"); + defaultOption.innerText = chrome.i18n.getMessage("chooseACountry"); + countrySelector.appendChild(defaultOption); + + for (const country of Object.keys(countries)) { + const option = document.createElement("option"); + option.value = country; + option.innerText = country; + countrySelector.appendChild(option); + } + + countrySelector.addEventListener("change", () => { + if (countries[countrySelector.value]?.allowed) { + document.getElementById("subsidizedLink").classList.remove("hidden"); + document.getElementById("noSubsidizedLink").classList.add("hidden"); + } else { + document.getElementById("subsidizedLink").classList.add("hidden"); + document.getElementById("noSubsidizedLink").classList.remove("hidden"); + } + }); + + subsidizedSection.appendChild(countrySelector); + }); +}
\ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index ae390d78..081e014e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,7 +30,7 @@ export default class Utils { this.backgroundScriptContainer = backgroundScriptContainer; } - async wait<T>(condition: () => T | false, timeout = 5000, check = 100): Promise<T> { + async wait<T>(condition: () => T, timeout = 5000, check = 100): Promise<T> { return GenericUtils.wait(condition, timeout, check); } @@ -331,24 +331,6 @@ export default class Utils { return permissionRegex; } - generateUserID(length = 36): string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - if (window.crypto && window.crypto.getRandomValues) { - const values = new Uint32Array(length); - window.crypto.getRandomValues(values); - for (let i = 0; i < length; i++) { - result += charset[values[i] % charset.length]; - } - return result; - } else { - for (let i = 0; i < length; i++) { - result += charset[Math.floor(Math.random() * charset.length)]; - } - return result; - } - } - /** * Sends a request to a custom server * @@ -434,54 +416,6 @@ export default class Utils { return referenceNode; } - getFormattedTime(seconds: number, precise?: boolean): string { - seconds = Math.max(seconds, 0); - - const hours = Math.floor(seconds / 60 / 60); - const minutes = Math.floor(seconds / 60) % 60; - let minutesDisplay = String(minutes); - let secondsNum = seconds % 60; - if (!precise) { - secondsNum = Math.floor(secondsNum); - } - - let secondsDisplay = String(precise ? secondsNum.toFixed(3) : secondsNum); - - if (secondsNum < 10) { - //add a zero - secondsDisplay = "0" + secondsDisplay; - } - if (hours && minutes < 10) { - //add a zero - minutesDisplay = "0" + minutesDisplay; - } - if (isNaN(hours) || isNaN(minutes)) { - return null; - } - - const formatted = (hours ? hours + ":" : "") + minutesDisplay + ":" + secondsDisplay; - - return formatted; - } - - getFormattedTimeToSeconds(formatted: string): number | null { - const fragments = /^(?:(?:(\d+):)?(\d+):)?(\d*(?:[.,]\d+)?)$/.exec(formatted); - - if (fragments === null) { - return null; - } - - const hours = fragments[1] ? parseInt(fragments[1]) : 0; - const minutes = fragments[2] ? parseInt(fragments[2] || '0') : 0; - const seconds = fragments[3] ? parseFloat(fragments[3].replace(',', '.')) : 0; - - return hours * 3600 + minutes * 60 + seconds; - } - - shortCategoryName(categoryName: string): string { - return chrome.i18n.getMessage("category_" + categoryName + "_short") || chrome.i18n.getMessage("category_" + categoryName); - } - isContentScript(): boolean { return window.location.protocol === "http:" || window.location.protocol === "https:"; } diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts new file mode 100644 index 00000000..b19b376d --- /dev/null +++ b/src/utils/arrayUtils.ts @@ -0,0 +1,6 @@ +export function partition<T>(array: T[], filter: (element: T) => boolean): [T[], T[]] { + const pass = [], fail = []; + array.forEach((element) => (filter(element) ? pass : fail).push(element)); + + return [pass, fail]; +}
\ No newline at end of file diff --git a/src/utils/categoryUtils.ts b/src/utils/categoryUtils.ts index c00a81b8..e6bc175e 100644 --- a/src/utils/categoryUtils.ts +++ b/src/utils/categoryUtils.ts @@ -41,7 +41,13 @@ export function getCategorySuffix(category: Category): string { return "_POI"; } else if (category === "exclusive_access") { return "_full"; + } else if (category === "chapter") { + return "_chapter"; } else { return ""; } +} + +export function shortCategoryName(categoryName: string): string { + return chrome.i18n.getMessage("category_" + categoryName + "_short") || chrome.i18n.getMessage("category_" + categoryName); }
\ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3530f234..74f0da71 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -137,5 +137,16 @@ export function getGuidelineInfo(category: Category): TextBox[] { icon: "icons/bolt.svg", text: chrome.i18n.getMessage(`category_${category}_guideline3`) }]; + case "chapter": + return [{ + icon: "icons/close-smaller.svg", + text: chrome.i18n.getMessage(`category_${category}_guideline1`) + }, { + icon: "icons/check-smaller.svg", + text: chrome.i18n.getMessage(`category_${category}_guideline2`) + }, { + icon: "icons/check-smaller.svg", + text: chrome.i18n.getMessage(`category_${category}_guideline3`) + }]; } }
\ No newline at end of file diff --git a/src/utils/exporter.ts b/src/utils/exporter.ts new file mode 100644 index 00000000..2620a40f --- /dev/null +++ b/src/utils/exporter.ts @@ -0,0 +1,65 @@ +import { ActionType, Category, SegmentUUID, SponsorSourceType, SponsorTime } from "../types"; +import { shortCategoryName } from "./categoryUtils"; +import { GenericUtils } from "./genericUtils"; + +export function exportTimes(segments: SponsorTime[]): string { + let result = ""; + for (const segment of segments) { + if (![ActionType.Full, ActionType.Mute].includes(segment.actionType) + && segment.source !== SponsorSourceType.YouTube) { + result += exportTime(segment) + "\n"; + } + } + + return result.replace(/\n$/, ""); +} + +function exportTime(segment: SponsorTime): string { + const name = segment.description || shortCategoryName(segment.category); + + return `${GenericUtils.getFormattedTime(segment.segment[0], true)}${ + segment.segment[1] && segment.segment[0] !== segment.segment[1] + ? ` - ${GenericUtils.getFormattedTime(segment.segment[1], true)}` : ""} ${name}`; +} + +export function importTimes(data: string, videoDuration: number): SponsorTime[] { + const lines = data.split("\n"); + const result: SponsorTime[] = []; + for (const line of lines) { + const match = line.match(/(?:(\d+:\d+)+(?:\.\d+)?)|(?:\d+(?=s| second))/g); + if (match) { + const startTime = GenericUtils.getFormattedTimeToSeconds(match[0]); + if (startTime) { + const specialCharsMatcher = /^(?:\s+seconds?)?[:()-\s]*|(?:\s+at)?[:()-\s]+$/g + const titleLeft = line.split(match[0])[0].replace(specialCharsMatcher, ""); + let titleRight = null; + const split2 = line.split(match[1] || match[0]); + titleRight = split2[split2.length - 1].replace(specialCharsMatcher, ""); + + const title = titleLeft?.length > titleRight?.length ? titleLeft : titleRight; + if (title) { + const segment: SponsorTime = { + segment: [startTime, GenericUtils.getFormattedTimeToSeconds(match[1])], + category: "chapter" as Category, + actionType: ActionType.Chapter, + description: title, + source: SponsorSourceType.Local, + UUID: GenericUtils.generateUserID() as SegmentUUID + }; + + if (result.length > 0 && result[result.length - 1].segment[1] === null) { + result[result.length - 1].segment[1] = segment.segment[0]; + } + + result.push(segment); + } + } + } + } + + if (result.length > 0 && result[result.length - 1].segment[1] === null) { + result[result.length - 1].segment[1] = videoDuration; + } + + return result; +}
\ No newline at end of file diff --git a/src/utils/genericUtils.ts b/src/utils/genericUtils.ts index 2d912a66..8b07e699 100644 --- a/src/utils/genericUtils.ts +++ b/src/utils/genericUtils.ts @@ -1,5 +1,5 @@ /** Function that can be used to wait for a condition before returning. */ -async function wait<T>(condition: () => T | false, timeout = 5000, check = 100): Promise<T> { +async function wait<T>(condition: () => T, timeout = 5000, check = 100, predicate?: (obj: T) => boolean): Promise<T> { return await new Promise((resolve, reject) => { setTimeout(() => { clearInterval(interval); @@ -8,7 +8,7 @@ async function wait<T>(condition: () => T | false, timeout = 5000, check = 100): const intervalCheck = () => { const result = condition(); - if (result) { + if (predicate ? predicate(result) : result) { resolve(result); clearInterval(interval); } @@ -21,6 +21,50 @@ async function wait<T>(condition: () => T | false, timeout = 5000, check = 100): }); } +function getFormattedTimeToSeconds(formatted: string): number | null { + const fragments = /^(?:(?:(\d+):)?(\d+):)?(\d*(?:[.,]\d+)?)$/.exec(formatted); + + if (fragments === null) { + return null; + } + + const hours = fragments[1] ? parseInt(fragments[1]) : 0; + const minutes = fragments[2] ? parseInt(fragments[2] || '0') : 0; + const seconds = fragments[3] ? parseFloat(fragments[3].replace(',', '.')) : 0; + + return hours * 3600 + minutes * 60 + seconds; +} + +function getFormattedTime(seconds: number, precise?: boolean): string { + seconds = Math.max(seconds, 0); + + const hours = Math.floor(seconds / 60 / 60); + const minutes = Math.floor(seconds / 60) % 60; + let minutesDisplay = String(minutes); + let secondsNum = seconds % 60; + if (!precise) { + secondsNum = Math.floor(secondsNum); + } + + let secondsDisplay = String(precise ? secondsNum.toFixed(3) : secondsNum); + + if (secondsNum < 10) { + //add a zero + secondsDisplay = "0" + secondsDisplay; + } + if (hours && minutes < 10) { + //add a zero + minutesDisplay = "0" + minutesDisplay; + } + if (isNaN(hours) || isNaN(minutes)) { + return null; + } + + const formatted = (hours ? hours + ":" : "") + minutesDisplay + ":" + secondsDisplay; + + return formatted; +} + /** * Gets the error message in a nice string * @@ -85,10 +129,31 @@ function objectToURI<T>(url: string, data: T, includeQuestionMark: boolean): str return url; } +function generateUserID(length = 36): string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + if (window.crypto && window.crypto.getRandomValues) { + const values = new Uint32Array(length); + window.crypto.getRandomValues(values); + for (let i = 0; i < length; i++) { + result += charset[values[i] % charset.length]; + } + return result; + } else { + for (let i = 0; i < length; i++) { + result += charset[Math.floor(Math.random() * charset.length)]; + } + return result; + } +} + export const GenericUtils = { wait, + getFormattedTime, + getFormattedTimeToSeconds, getErrorMessage, getLuminance, + generateUserID, indexesOf, objectToURI }
\ No newline at end of file diff --git a/src/utils/licenseKey.ts b/src/utils/licenseKey.ts new file mode 100644 index 00000000..53c54c76 --- /dev/null +++ b/src/utils/licenseKey.ts @@ -0,0 +1,65 @@ +import Config from "../config"; +import Utils from "../utils"; +import * as CompileConfig from "../../config.json"; + +const utils = new Utils(); + +export async function checkLicenseKey(licenseKey: string): Promise<boolean> { + const result = await utils.asyncRequestToServer("GET", "/api/verifyToken", { + licenseKey + }); + + try { + if (result.ok && JSON.parse(result.responseText).allowed) { + Config.config.payments.chaptersAllowed = true; + Config.config.payments.lastCheck = Date.now(); + Config.forceSyncUpdate("payments"); + + return true; + } + } catch (e) { } //eslint-disable-line no-empty + + return false +} + +export async function fetchingChaptersAllowed(): Promise<boolean> { + if (Config.config.payments.freeAccess || CompileConfig["freeChapterAccess"]) { + return true; + } + + //more than 14 days + if (Config.config.payments.licenseKey && Date.now() - Config.config.payments.lastCheck > 14 * 24 * 60 * 60 * 1000) { + const licensePromise = checkLicenseKey(Config.config.payments.licenseKey); + + if (!Config.config.payments.chaptersAllowed) { + return licensePromise; + } + } + + if (Config.config.payments.chaptersAllowed) return true; + + if (Config.config.payments.lastCheck === 0) { + // Check for free access if no license key, and it is the first time + const result = await utils.asyncRequestToServer("GET", "/api/userInfo", { + value: "freeChaptersAccess", + userID: Config.config.userID + }); + + try { + if (result.ok) { + const userInfo = JSON.parse(result.responseText); + + Config.config.payments.lastCheck = Date.now(); + if (userInfo.freeChaptersAccess) { + Config.config.payments.freeAccess = true; + Config.config.payments.chaptersAllowed = true; + Config.forceSyncUpdate("payments"); + + return true; + } + } + } catch (e) { } //eslint-disable-line no-empty + } + + return false; +}
\ No newline at end of file diff --git a/src/utils/pageUtils.ts b/src/utils/pageUtils.ts index de4d8308..9ab31328 100644 --- a/src/utils/pageUtils.ts +++ b/src/utils/pageUtils.ts @@ -1,4 +1,7 @@ -export function getControls(): HTMLElement | false { +import { ActionType, Category, SponsorSourceType, SponsorTime, VideoID } from "../types"; +import { GenericUtils } from "./genericUtils"; + +export function getControls(): HTMLElement { const controlsSelectors = [ // YouTube ".ytp-right-controls", @@ -16,7 +19,7 @@ export function getControls(): HTMLElement | false { } } - return false; + return null; } export function isVisible(element: HTMLElement): boolean { @@ -63,6 +66,44 @@ export function getHashParams(): Record<string, unknown> { return {}; } +export function getExistingChapters(currentVideoID: VideoID, duration: number): SponsorTime[] { + const chaptersBox = document.querySelector("ytd-macro-markers-list-renderer"); + + const chapters: SponsorTime[] = []; + if (chaptersBox) { + let lastSegment: SponsorTime = null; + const links = chaptersBox.querySelectorAll("ytd-macro-markers-list-item-renderer > a"); + for (const link of links) { + const timeElement = link.querySelector("#time") as HTMLElement; + const description = link.querySelector("#details h4") as HTMLElement; + if (timeElement && description?.innerText?.length > 0 && link.getAttribute("href")?.includes(currentVideoID)) { + const time = GenericUtils.getFormattedTimeToSeconds(timeElement.innerText); + + if (lastSegment) { + lastSegment.segment[1] = time; + chapters.push(lastSegment); + } + + lastSegment = { + segment: [time, null], + category: "chapter" as Category, + actionType: ActionType.Chapter, + description: description.innerText, + source: SponsorSourceType.YouTube, + UUID: null + }; + } + } + + if (lastSegment) { + lastSegment.segment[1] = duration; + chapters.push(lastSegment); + } + } + + return chapters; +} + export function localizeHtmlPage(): void { //Localize by replacing __MSG_***__ meta tags const localizedTitle = getLocalizedMessage(document.title); diff --git a/test/exporter.test.ts b/test/exporter.test.ts new file mode 100644 index 00000000..dd589db1 --- /dev/null +++ b/test/exporter.test.ts @@ -0,0 +1,241 @@ +/** + * @jest-environment jsdom + */ + +import { ActionType, Category, SegmentUUID, SponsorSourceType, SponsorTime } from "../src/types"; +import { exportTimes, importTimes } from "../src/utils/exporter"; + +describe("Export segments", () => { + it("Some segments", () => { + const segments: SponsorTime[] = [{ + segment: [0, 10], + category: "chapter" as Category, + actionType: ActionType.Chapter, + description: "Chapter 1", + source: SponsorSourceType.Server, + UUID: "1" as SegmentUUID + }, { + segment: [20, 20], + category: "poi_highlight" as Category, + actionType: ActionType.Poi, + description: "Highlight", + source: SponsorSourceType.Server, + UUID: "2" as SegmentUUID + }, { + segment: [30, 40], + category: "sponsor" as Category, + actionType: ActionType.Skip, + description: "Sponsor", // Force a description since chrome is not defined + source: SponsorSourceType.Server, + UUID: "3" as SegmentUUID + }, { + segment: [50, 60], + category: "selfpromo" as Category, + actionType: ActionType.Mute, + description: "Selfpromo", + source: SponsorSourceType.Server, + UUID: "4" as SegmentUUID + }, { + segment: [0, 0], + category: "selfpromo" as Category, + actionType: ActionType.Full, + description: "Selfpromo", + source: SponsorSourceType.Server, + UUID: "5" as SegmentUUID + }, { + segment: [80, 90], + category: "interaction" as Category, + actionType: ActionType.Skip, + description: "Interaction", + source: SponsorSourceType.YouTube, + UUID: "6" as SegmentUUID + }]; + + const result = exportTimes(segments); + + expect(result).toBe( + "0:00.000 - 0:10.000 Chapter 1\n" + + "0:20.000 Highlight\n" + + "0:30.000 - 0:40.000 Sponsor" + ); + }); + +}); + +describe("Import segments", () => { + it("1:20 to 1:21 thing", () => { + const input = ` 1:20 to 1:21 thing + 1:25 to 1:28 another thing`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 81], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 88], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("thing 1:20 to 1:21", () => { + const input = ` thing 1:20 to 1:21 + another thing 1:25 to 1:28 ext`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 81], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 88], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("1:20 - 1:21 thing", () => { + const input = ` 1:20 - 1:21 thing + 1:25 - 1:28 another thing`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 81], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 88], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("1:20 1:21 thing", () => { + const input = ` 1:20 1:21 thing + 1:25 1:28 another thing`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 81], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 88], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("1:20 thing", () => { + const input = ` 1:20 thing + 1:25 another thing`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 85], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("1:20: thing", () => { + const input = ` 1:20: thing + 1:25: another thing`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 85], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("1:20 (thing)", () => { + const input = ` 1:20 (thing) + 1:25 (another thing)`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 85], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("thing 1:20", () => { + const input = ` thing 1:20 + another thing 1:25`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 85], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("thing at 1:20", () => { + const input = ` thing at 1:20 + another thing at 1:25`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [80, 85], + description: "thing", + category: "chapter" as Category + }, { + segment: [85, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("thing at 1s", () => { + const input = ` thing at 1s + another thing at 5s`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [1, 5], + description: "thing", + category: "chapter" as Category + }, { + segment: [5, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); + + it("thing at 1 second", () => { + const input = ` thing at 1 second + another thing at 5 seconds`; + + const result = importTimes(input, 120); + expect(result).toMatchObject([{ + segment: [1, 5], + description: "thing", + category: "chapter" as Category + }, { + segment: [5, 120], + description: "another thing", + category: "chapter" as Category + }]); + }); +});
\ No newline at end of file diff --git a/test/previewBar.test.ts b/test/previewBar.test.ts new file mode 100644 index 00000000..b599b3a3 --- /dev/null +++ b/test/previewBar.test.ts @@ -0,0 +1,665 @@ +import PreviewBar, { PreviewBarSegment } from "../src/js-components/previewBar"; + +describe("createChapterRenderGroups", () => { + let previewBar: PreviewBar; + beforeEach(() => { + previewBar = new PreviewBar(null, null, null, null, true); + }) + + it("Two unrelated times", () => { + previewBar.videoDuration = 315; + const groups = previewBar.createChapterRenderGroups([{ + segment: [2, 30], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }, { + segment: [50, 80], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }] as PreviewBarSegment[]); + + expect(groups).toStrictEqual([{ + segment: [0, 2], + originalDuration: 0 + }, { + segment: [2, 30], + originalDuration: 30 - 2 + }, { + segment: [30, 50], + originalDuration: 0 + }, { + segment: [50, 80], + originalDuration: 80 - 50 + }, { + segment: [80, 315], + originalDuration: 0 + }]); + }); + + it("Small time in bigger time", () => { + previewBar.videoDuration = 315; + const groups = previewBar.createChapterRenderGroups([{ + segment: [2.52, 30], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }, { + segment: [20, 25], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }] as PreviewBarSegment[]); + + expect(groups).toStrictEqual([{ + segment: [0, 2.52], + originalDuration: 0 + }, { + segment: [2.52, 20], + originalDuration: 30 - 2.52 + }, { + segment: [20, 25], + originalDuration: 25 - 20 + }, { + segment: [25, 30], + originalDuration: 30 - 2.52 + }, { + segment: [30, 315], + originalDuration: 0 + }]); + }); + + it("Same start time", () => { + previewBar.videoDuration = 315; + const groups = previewBar.createChapterRenderGroups([{ + segment: [2.52, 30], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }, { + segment: [2.52, 40], + category: "sponsor", + unsubmitted: false, + showLarger: false, + description: "" + }] as PreviewBarSegment[]); + + expect(groups).toStrictEqual([{ + segment: [0, 2.52], + originalDuration: 0 + }, { + segment: [2.52, 30], + originalDuration: 30 - 2.52 + }, { + segment: [30, 40], + originalDuration: 40 - 2.52 + }, { + segment: [40, 315], + originalDuration: 0 + }]); + }); + + it("Lots of overlapping segments", () => { + previewBar.videoDuration = 315.061; + const groups = previewBar.createChapterRenderGroups([ + { + "category": "chapter", + "segment": [ + 0, + 49.977 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "b1919787a85cd422af07136a913830eda1364d32e8a9e12104cf5e3bad8f6f45", + "description": "Start of video" + }, + { + "category": "sponsor", + "segment": [ + 2.926, + 5 + ], + "locked": 1, + "votes": 2, + "videoDuration": 316, + "userID": "938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20", + "description": "" + }, + { + "category": "chapter", + "segment": [ + 14.487, + 37.133 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "b1919787a85cd422af07136a913830eda1364d32e8a9e12104cf5e3bad8f6f45", + "description": "Subset of start" + }, + { + "category": "sponsor", + "segment": [ + 23.450537, + 34.486084 + ], + "locked": 0, + "votes": -1, + "videoDuration": 315.061, + "userID": "938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20", + "description": "" + }, + { + "category": "interaction", + "segment": [ + 50.015343, + 56.775314 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "b2a85e8cdfbf21dd504babbcaca7f751b55a5a2df8179c1a83a121d0f5d56c0e", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 62.51888, + 74.33331 + ], + "locked": 0, + "votes": -1, + "videoDuration": 316, + "userID": "938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 88.71328, + 96.05933 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "6c08c092db2b7a31210717cc1f2652e7e97d032e03c82b029a27c81cead1f90c", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 101.50703, + 115.088326 + ], + "votes": 0, + "videoDuration": 315.061, + "userID": "2db207ad4b7a535a548fab293f4567bf97373997e67aadb47df8f91b673f6e53", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 122.211845, + 137.42178 + ], + "locked": 0, + "votes": 1, + "videoDuration": 0, + "userID": "0312cbfa514d9d2dfb737816dc45f52aba7c23f0a3f0911273a6993b2cb57fcc", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 144.08913, + 160.14084 + ], + "locked": 0, + "votes": -1, + "videoDuration": 316, + "userID": "938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 164.22084, + 170.98082 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "845c4377060d5801f5324f8e1be1e8373bfd9addcf6c68fc5a3c038111b506a3", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 180.56674, + 189.16516 + ], + "locked": 0, + "votes": -1, + "videoDuration": 315.061, + "userID": "7c6b015687db7800c05756a0fd226fd8d101f5a1e1bfb1e5d97c440331fd6cb7", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 204.10468, + 211.87865 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "3472e8ee00b5da957377ae32d59ddd3095c2b634c2c0c970dfabfb81d143699f", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 214.92064, + 222.0186 + ], + "locked": 0, + "votes": 0, + "videoDuration": 0, + "userID": "62a00dffb344d27de7adf8ea32801c2fc0580087dc8d282837923e4bda6a1745", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 233.0754, + 244.56734 + ], + "locked": 0, + "votes": -1, + "videoDuration": 315, + "userID": "dcf7fb0a6c071d5a93273ebcbecaee566e0ff98181057a36ed855e9b92bf25ea", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 260.64053, + 269.35938 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 288.686, + 301.96 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298", + "description": "" + }, + { + "category": "sponsor", + "segment": [ + 288.686, + 295 + ], + "locked": 0, + "votes": 0, + "videoDuration": 315.061, + "userID": "e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298", + "description": "" + }] as unknown as PreviewBarSegment[]); + + expect(groups).toStrictEqual([ + { + "segment": [ + 0, + 2.926 + ], + "originalDuration": 49.977 + }, + { + "segment": [ + 2.926, + 5 + ], + "originalDuration": 2.074 + }, + { + "segment": [ + 5, + 14.487 + ], + "originalDuration": 49.977 + }, + { + "segment": [ + 14.487, + 23.450537 + ], + "originalDuration": 22.646 + }, + { + "segment": [ + 23.450537, + 34.486084 + ], + "originalDuration": 11.035546999999998 + }, + { + "segment": [ + 34.486084, + 37.133 + ], + "originalDuration": 22.646 + }, + { + "segment": [ + 37.133, + 49.977 + ], + "originalDuration": 49.977 + }, + { + "segment": [ + 49.977, + 50.015343 + ], + "originalDuration": 0 + }, + { + "segment": [ + 50.015343, + 56.775314 + ], + "originalDuration": 6.759971 + }, + { + "segment": [ + 56.775314, + 62.51888 + ], + "originalDuration": 0 + }, + { + "segment": [ + 62.51888, + 74.33331 + ], + "originalDuration": 11.814429999999994 + }, + { + "segment": [ + 74.33331, + 88.71328 + ], + "originalDuration": 0 + }, + { + "segment": [ + 88.71328, + 96.05933 + ], + "originalDuration": 7.346050000000005 + }, + { + "segment": [ + 96.05933, + 101.50703 + ], + "originalDuration": 0 + }, + { + "segment": [ + 101.50703, + 115.088326 + ], + "originalDuration": 13.581295999999995 + }, + { + "segment": [ + 115.088326, + 122.211845 + ], + "originalDuration": 0 + }, + { + "segment": [ + 122.211845, + 137.42178 + ], + "originalDuration": 15.209935000000016 + }, + { + "segment": [ + 137.42178, + 144.08913 + ], + "originalDuration": 0 + }, + { + "segment": [ + 144.08913, + 160.14084 + ], + "originalDuration": 16.051709999999986 + }, + { + "segment": [ + 160.14084, + 164.22084 + ], + "originalDuration": 0 + }, + { + "segment": [ + 164.22084, + 170.98082 + ], + "originalDuration": 6.759979999999985 + }, + { + "segment": [ + 170.98082, + 180.56674 + ], + "originalDuration": 0 + }, + { + "segment": [ + 180.56674, + 189.16516 + ], + "originalDuration": 8.598419999999976 + }, + { + "segment": [ + 189.16516, + 204.10468 + ], + "originalDuration": 0 + }, + { + "segment": [ + 204.10468, + 211.87865 + ], + "originalDuration": 7.773969999999991 + }, + { + "segment": [ + 211.87865, + 214.92064 + ], + "originalDuration": 0 + }, + { + "segment": [ + 214.92064, + 222.0186 + ], + "originalDuration": 7.0979600000000005 + }, + { + "segment": [ + 222.0186, + 233.0754 + ], + "originalDuration": 0 + }, + { + "segment": [ + 233.0754, + 244.56734 + ], + "originalDuration": 11.49194 + }, + { + "segment": [ + 244.56734, + 260.64053 + ], + "originalDuration": 0 + }, + { + "segment": [ + 260.64053, + 269.35938 + ], + "originalDuration": 8.718849999999975 + }, + { + "segment": [ + 269.35938, + 288.686 + ], + "originalDuration": 0 + }, + { + "segment": [ + 288.686, + 295 + ], + "originalDuration": 6.314000000000021 + }, + { + "segment": [ + 295, + 301.96 + ], + "originalDuration": 13.274000000000001 + }, + { + "segment": [ + 301.96, + 315.061 + ], + "originalDuration": 0 + } + ]); + }) + + it("Multiple overlapping", () => { + previewBar.videoDuration = 3615.161; + const groups = previewBar.createChapterRenderGroups([{ + "segment": [ + 160, + 2797.323 + ], + "category": "chooseACategory", + "unsubmitted": true, + "showLarger": false, + },{ + "segment": [ + 169, + 3432.255 + ], + "category": "chooseACategory", + "unsubmitted": true, + "showLarger": false, + },{ + "segment": [ + 169, + 3412.413 + ], + "category": "chooseACategory", + "unsubmitted": true, + "showLarger": false, + },{ + "segment": [ + 1594.92, + 1674.286 + ], + "category": "sponsor", + "unsubmitted": false, + "showLarger": false, + } + ] as unknown as PreviewBarSegment[]); + + expect(groups).toStrictEqual([ + { + "segment": [ + 0, + 160 + ], + "originalDuration": 0 + }, + { + "segment": [ + 160, + 169 + ], + "originalDuration": 2637.323 + }, + { + "segment": [ + 169, + 1594.92 + ], + "originalDuration": 3243.413 + }, + { + "segment": [ + 1594.92, + 1674.286 + ], + "originalDuration": 79.36599999999999 + }, + { + "segment": [ + 1674.286, + 3412.413 + ], + "originalDuration": 3243.413 + }, + { + "segment": [ + 3412.413, + 3432.255 + ], + "originalDuration": 3263.255 + }, + { + "segment": [ + 3432.255, + 3615.161 + ], + "originalDuration": 0 + } + ]); + }); +})
\ No newline at end of file diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 78015cbf..73c17807 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -31,7 +31,8 @@ module.exports = env => ({ content: path.join(__dirname, srcDir + 'content.ts'), options: path.join(__dirname, srcDir + 'options.ts'), help: path.join(__dirname, srcDir + 'help.ts'), - permissions: path.join(__dirname, srcDir + 'permissions.ts') + permissions: path.join(__dirname, srcDir + 'permissions.ts'), + upsell: path.join(__dirname, srcDir + 'upsell.ts') }, output: { path: path.join(__dirname, '../dist/js'), |