Merge branch 'watch-on-odysee'
16
.devcontainer/Dockerfile
Normal file
|
@ -0,0 +1,16 @@
|
|||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster
|
||||
ARG VARIANT="16-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
# ARG EXTRA_NODE_VERSION=10
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node packages
|
||||
# RUN su node -c "npm install -g <your-package-list -here>"
|
36
.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "14-bullseye"
|
||||
}
|
||||
},
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||
"javascript.format.placeOpenBraceOnNewLineForFunctions": false,
|
||||
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||
"typescript.format.placeOpenBraceOnNewLineForFunctions": false
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"bierner.folder-source-actions",
|
||||
"jbockle.jbockle-format-files",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install",
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"python": "latest"
|
||||
}
|
||||
}
|
2
.gitignore
vendored
|
@ -1,6 +1,8 @@
|
|||
.cache
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
web-ext-artifacts
|
||||
yarn-error.log
|
||||
|
||||
.DS_Store
|
||||
|
|
15
CHANGELOG.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
# Changelog
|
||||
|
||||
## [1.7.5](https://github.com/LBRYFoundation/Watch-on-LBRY/releases/tag/1.7.5) (2021-12-12)
|
||||
|
||||
**Closed issues:**
|
||||
|
||||
- Subscription Converter feature doesn't seem to work [\#64](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/64)
|
||||
- Redirect with timestamp [\#57](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/57)
|
||||
- Should default to odysee.com instead of lbry.tv [\#53](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/53)
|
||||
- Instead of redirect. Show a popup from the extension icon [\#38](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/38)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Update ytContent.tsx [\#70](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/70) ([Shiba](https://github.com/DeepDoge))
|
||||
- Madiator icon added [\#71](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/71) ([Shiba](https://github.com/DeepDoge))
|
9
global.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
declare module '*.md' {
|
||||
var _: string
|
||||
export default _
|
||||
}
|
||||
|
||||
declare namespace chrome
|
||||
{
|
||||
export const action = chrome.browserAction
|
||||
}
|
56
manifest.v2.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Watch on LBRY",
|
||||
"version": "2.0.0",
|
||||
"icons": {
|
||||
"16": "assets/icons/wol/icon16.png",
|
||||
"48": "assets/icons/wol/icon48.png",
|
||||
"128": "assets/icons/wol/icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"https://www.youtube.com/",
|
||||
"https://yewtu.be/",
|
||||
"https://vid.puffyan.us/",
|
||||
"https://invidio.xamh.de/",
|
||||
"https://invidious.kavin.rocks/",
|
||||
"https://api.odysee.com/",
|
||||
"https://lbry.tv/",
|
||||
"https://odysee.com/",
|
||||
"https://madiator.com/",
|
||||
"https://finder.madiator.com/",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"pages/popup/index.html",
|
||||
"pages/YTtoLBRY/index.html",
|
||||
"pages/import/index.html",
|
||||
"assets/icons/lbry/lbry-logo.svg",
|
||||
"assets/icons/lbry/odysee-logo.svg",
|
||||
"assets/icons/lbry/madiator-logo.svg"
|
||||
],
|
||||
"browser_action": {
|
||||
"default_title": "Watch on LBRY",
|
||||
"default_popup": "pages/popup/index.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://www.youtube.com/*",
|
||||
"https://yewtu.be/*",
|
||||
"https://vid.puffyan.us/*",
|
||||
"https://invidio.xamh.de/*",
|
||||
"https://invidious.kavin.rocks/*"
|
||||
],
|
||||
"js": [
|
||||
"scripts/ytContent.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"service-worker-entry-point.js"
|
||||
],
|
||||
"persistent": true
|
||||
}
|
||||
}
|
59
manifest.v3.json
Normal file
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Watch on LBRY",
|
||||
"version": "2.0.0",
|
||||
"icons": {
|
||||
"16": "assets/icons/wol/icon16.png",
|
||||
"48": "assets/icons/wol/icon48.png",
|
||||
"128": "assets/icons/wol/icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://www.youtube.com/",
|
||||
"https://yewtu.be/",
|
||||
"https://vid.puffyan.us/",
|
||||
"https://invidio.xamh.de/",
|
||||
"https://invidious.kavin.rocks/",
|
||||
"https://api.odysee.com/",
|
||||
"https://lbry.tv/",
|
||||
"https://odysee.com/",
|
||||
"https://madiator.com/",
|
||||
"https://finder.madiator.com/"
|
||||
],
|
||||
"web_accessible_resources": [{
|
||||
"resources": [
|
||||
"pages/popup/index.html",
|
||||
"pages/YTtoLBRY/index.html",
|
||||
"pages/import/index.html",
|
||||
"assets/icons/lbry/lbry-logo.svg",
|
||||
"assets/icons/lbry/odysee-logo.svg",
|
||||
"assets/icons/lbry/madiator-logo.svg"
|
||||
],
|
||||
"matches": ["<all_urls>"],
|
||||
"extension_ids": []
|
||||
}],
|
||||
"action": {
|
||||
"default_title": "Watch on LBRY",
|
||||
"default_popup": "pages/popup/index.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://www.youtube.com/*",
|
||||
"https://yewtu.be/*",
|
||||
"https://vid.puffyan.us/*",
|
||||
"https://invidio.xamh.de/*",
|
||||
"https://invidious.kavin.rocks/*"
|
||||
],
|
||||
"js": [
|
||||
"scripts/ytContent.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "service-worker-entry-point.js"
|
||||
}
|
||||
}
|
17595
package-lock.json
generated
Normal file
26
package.json
|
@ -5,11 +5,29 @@
|
|||
"scripts": {
|
||||
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/",
|
||||
"watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/",
|
||||
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
||||
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
||||
|
||||
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
|
||||
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
|
||||
|
||||
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"build": "npm-run-all -l -p build:parcel build:assets",
|
||||
"watch": "npm-run-all -l -p watch:parcel watch:assets",
|
||||
|
||||
"clear:dist": "rm -r ./dist ; mkdir ./dist",
|
||||
|
||||
"build:base": "npm-run-all -l -p build:parcel build:assets",
|
||||
|
||||
"pick:manifest:v2": "cp -b ./manifest.v2.json ./dist/manifest.json",
|
||||
"pick:manifest:v3": "cp -b ./manifest.v3.json ./dist/manifest.json",
|
||||
|
||||
"build:v2": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v2",
|
||||
"build:v3": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v3",
|
||||
|
||||
"build": "rm -r ./build ; mkdir ./build && npm run build:v2 && zip -r ./build/manifest-v2.zip ./dist && npm run build:v3 && zip -r ./build/manifest-v3.zip ./dist",
|
||||
|
||||
"watch:v2": "npm run clear:dist ; npm run pick:manifest:v2 && npm-run-all -l -p watch:parcel watch:assets",
|
||||
"watch:v3": "npm run clear:dist ; npm run pick:manifest:v3 && npm-run-all -l -p watch:parcel watch:assets",
|
||||
|
||||
"watch": "npm run watch:v3",
|
||||
|
||||
"start:chrome": "web-ext run -t chromium --source-dir ./dist",
|
||||
"start:firefox": "web-ext run --source-dir ./dist",
|
||||
"test": "jest"
|
||||
|
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
38
src/assets/icons/lbry/madiator-logo.svg
Normal file
|
@ -0,0 +1,38 @@
|
|||
<svg width="525" height="136" viewBox="0 0 525 136" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M69.23 19.13L84.46 101.34H72.25L64.3 55.76L42.24 102.8L20.29 55.76L12.34 101.34H0.130005L15.36 19.13L42.24 75.69L69.23 19.13Z" fill="white"/>
|
||||
<path d="M119.13 42.2C123.23 42.2 127.08 42.98 130.7 44.55C134.32 46.12 137.48 48.25 140.2 50.93C142.92 53.62 145.06 56.79 146.63 60.45C148.19 64.11 148.98 67.99 148.98 72.1V101.11H137.44V95.68C134.9 97.6 132.1 99.12 129.04 100.23C125.98 101.34 122.69 101.89 119.18 101.89C115.07 101.89 111.21 101.11 107.59 99.54C103.97 97.97 100.81 95.84 98.13 93.16C95.44 90.47 93.31 87.32 91.75 83.7C90.18 80.08 89.4 76.22 89.4 72.11C89.4 68 90.18 64.12 91.75 60.46C93.31 56.8 95.44 53.63 98.12 50.94C100.8 48.25 103.95 46.12 107.57 44.56C111.17 42.98 115.03 42.2 119.13 42.2ZM119.18 90.36C121.72 90.36 124.09 89.87 126.29 88.9C128.49 87.92 130.41 86.61 132.06 84.96C133.7 83.31 135.01 81.37 135.98 79.16C136.95 76.94 137.44 74.6 137.44 72.12C137.44 69.57 136.95 67.18 135.98 64.97C135.01 62.75 133.7 60.82 132.06 59.17C130.42 57.52 128.49 56.2 126.29 55.23C124.09 54.25 121.72 53.77 119.18 53.77C116.64 53.77 114.27 54.26 112.07 55.23C109.87 56.21 107.94 57.52 106.3 59.17C104.66 60.82 103.35 62.76 102.38 64.97C101.41 67.19 100.92 69.57 100.92 72.12C100.92 74.6 101.4 76.95 102.38 79.16C103.35 81.37 104.66 83.31 106.3 84.96C107.94 86.61 109.86 87.93 112.07 88.9C114.27 89.87 116.64 90.36 119.18 90.36Z" fill="white"/>
|
||||
<path d="M214.72 71.77C214.72 75.88 213.95 79.72 212.42 83.31C210.89 86.89 208.78 90.03 206.09 92.72C203.4 95.41 200.27 97.52 196.68 99.05C193.1 100.58 189.25 101.35 185.14 101.35C181.03 101.35 177.19 100.59 173.6 99.05C170.02 97.52 166.88 95.41 164.19 92.72C161.5 90.03 159.39 86.9 157.86 83.31C156.33 79.73 155.56 75.88 155.56 71.77C155.56 67.66 156.32 63.82 157.86 60.23C159.39 56.65 161.5 53.51 164.19 50.82C166.88 48.13 170.01 46.02 173.6 44.49C177.18 42.96 181.03 42.19 185.14 42.19C188.57 42.19 191.8 42.73 194.83 43.81C197.85 44.89 200.64 46.41 203.17 48.35V2.64999H214.71V71.64V71.77H214.72ZM185.21 89.8C187.67 89.8 189.99 89.33 192.19 88.4C194.39 87.47 196.3 86.18 197.94 84.54C199.58 82.9 200.86 80.98 201.79 78.77C202.72 76.57 203.19 74.23 203.19 71.77V71.66C203.19 69.2 202.72 66.88 201.79 64.72C200.86 62.56 199.58 60.65 197.94 59.01C196.3 57.37 194.39 56.08 192.19 55.15C189.99 54.22 187.67 53.75 185.21 53.75C182.75 53.75 180.43 54.22 178.23 55.15C176.03 56.08 174.12 57.37 172.48 59.01C170.84 60.65 169.54 62.58 168.57 64.78C167.6 66.98 167.12 69.32 167.12 71.78C167.12 74.24 167.6 76.58 168.57 78.78C169.54 80.98 170.84 82.91 172.48 84.55C174.12 86.19 176.03 87.48 178.23 88.41C180.42 89.33 182.75 89.8 185.21 89.8Z" fill="white"/>
|
||||
<path d="M223 36.04V24.5H234.54V36.04H223ZM234.54 42.09V101.23H223V42.09H234.54Z" fill="white"/>
|
||||
<path d="M270.88 42.2C274.98 42.2 278.83 42.98 282.45 44.55C286.07 46.12 289.23 48.25 291.95 50.93C294.67 53.61 296.81 56.79 298.38 60.45C299.94 64.11 300.73 67.99 300.73 72.1V101.11H289.2V95.68C286.66 97.6 283.86 99.12 280.8 100.23C277.74 101.34 274.45 101.89 270.94 101.89C266.83 101.89 262.97 101.11 259.35 99.54C255.73 97.97 252.57 95.84 249.89 93.16C247.2 90.47 245.07 87.32 243.51 83.7C241.94 80.08 241.16 76.22 241.16 72.11C241.16 68 241.94 64.12 243.51 60.46C245.07 56.8 247.2 53.63 249.88 50.94C252.56 48.25 255.71 46.12 259.33 44.56C262.93 42.98 266.79 42.2 270.88 42.2ZM270.94 90.36C273.48 90.36 275.85 89.87 278.05 88.9C280.25 87.92 282.17 86.61 283.82 84.96C285.46 83.31 286.77 81.37 287.74 79.16C288.71 76.94 289.2 74.6 289.2 72.12C289.2 69.57 288.71 67.18 287.74 64.97C286.77 62.75 285.46 60.82 283.82 59.17C282.18 57.52 280.25 56.2 278.05 55.23C275.85 54.25 273.48 53.77 270.94 53.77C268.4 53.77 266.03 54.26 263.83 55.23C261.63 56.21 259.7 57.52 258.06 59.17C256.42 60.82 255.11 62.76 254.14 64.97C253.17 67.19 252.68 69.57 252.68 72.12C252.68 74.6 253.16 76.95 254.14 79.16C255.11 81.37 256.42 83.31 258.06 84.96C259.7 86.61 261.62 87.93 263.83 88.9C266.03 89.87 268.4 90.36 270.94 90.36Z" fill="white"/>
|
||||
<path d="M340.49 37.27H329.74V101.33H318.2V37.27H307.34V25.73H318.2V3.78H329.74V25.73H340.49V37.27Z" fill="white"/>
|
||||
<path d="M376.55 42.09C380.66 42.09 384.5 42.86 388.09 44.39C391.67 45.92 394.81 48.03 397.5 50.72C400.19 53.41 402.3 56.54 403.83 60.13C405.36 63.71 406.13 67.56 406.13 71.67C406.13 75.78 405.36 79.62 403.83 83.21C402.3 86.79 400.19 89.93 397.5 92.62C394.81 95.31 391.68 97.42 388.09 98.95C384.51 100.48 380.66 101.25 376.55 101.25C372.44 101.25 368.6 100.49 365.01 98.95C361.43 97.42 358.29 95.31 355.6 92.62C352.91 89.93 350.8 86.8 349.27 83.21C347.74 79.63 346.97 75.78 346.97 71.67C346.97 67.56 347.73 63.72 349.27 60.13C350.8 56.55 352.91 53.41 355.6 50.72C358.29 48.03 361.42 45.92 365.01 44.39C368.6 42.85 372.45 42.09 376.55 42.09ZM376.61 89.69C379.07 89.69 381.39 89.22 383.59 88.29C385.79 87.36 387.7 86.07 389.34 84.43C390.98 82.79 392.26 80.86 393.19 78.66C394.12 76.46 394.59 74.12 394.59 71.66C394.59 69.2 394.12 66.86 393.19 64.66C392.26 62.46 390.98 60.54 389.34 58.89C387.7 57.25 385.79 55.96 383.59 55.03C381.39 54.1 379.07 53.63 376.61 53.63C374.15 53.63 371.83 54.1 369.63 55.03C367.43 55.96 365.52 57.25 363.88 58.89C362.24 60.53 360.94 62.46 359.97 64.66C359 66.86 358.52 69.2 358.52 71.66C358.52 74.12 359 76.46 359.97 78.66C360.94 80.86 362.24 82.79 363.88 84.43C365.52 86.07 367.43 87.36 369.63 88.29C371.83 89.22 374.15 89.69 376.61 89.69Z" fill="white"/>
|
||||
<path d="M452.38 46.5C454.92 48.16 457.08 50.23 458.88 52.72L452.38 57.88L449.92 59.9C448.65 58.03 446.99 56.54 444.94 55.42C442.89 54.3 440.66 53.74 438.28 53.74C436.34 53.74 434.51 54.11 432.79 54.86C431.07 55.61 429.58 56.62 428.31 57.88C427.04 59.15 426.03 60.64 425.29 62.36C424.54 64.08 424.17 65.91 424.17 67.85V89.13V101.11H412.63V67.78V42.2H424.17V46.5C426.19 45.14 428.39 44.09 430.78 43.33C433.17 42.58 435.67 42.2 438.28 42.2C440.89 42.2 443.39 42.58 445.78 43.33C448.16 44.08 450.36 45.14 452.38 46.5Z" fill="white"/>
|
||||
<path d="M348.69 23.33C349.43 23.33 350.07 23.59 350.59 24.12C351.11 24.64 351.38 25.28 351.38 26.02C351.38 26.76 351.12 27.4 350.59 27.92C350.07 28.44 349.43 28.71 348.69 28.71C347.95 28.71 347.31 28.45 346.79 27.92C346.27 27.4 346 26.76 346 26.02C346 25.28 346.26 24.64 346.79 24.12C347.31 23.59 347.94 23.33 348.69 23.33Z" fill="white"/>
|
||||
<path d="M368.7 23.48C370.02 23.48 371.23 23.19 372.35 22.62C373.47 22.05 374.39 21.29 375.14 20.34L379.24 23.53C378.02 25.1 376.5 26.35 374.68 27.29C372.86 28.23 370.86 28.7 368.7 28.7C366.88 28.7 365.15 28.35 363.53 27.66C361.91 26.97 360.48 26.01 359.26 24.8C358.03 23.58 357.07 22.16 356.37 20.54C355.67 18.92 355.32 17.18 355.32 15.32C355.32 13.5 355.67 11.77 356.37 10.15C357.07 8.52999 358.03 7.11001 359.26 5.89001C360.49 4.67001 361.91 3.72 363.53 3.03C365.15 2.34 366.87 1.98999 368.7 1.98999C370.86 1.98999 372.86 2.45 374.68 3.37C376.5 4.29 378.02 5.55 379.24 7.16L375.14 10.35C374.4 9.37001 373.47 8.60001 372.35 8.04001C371.24 7.48001 370.02 7.20001 368.7 7.20001C367.59 7.20001 366.53 7.40999 365.53 7.82999C364.53 8.24999 363.66 8.83001 362.92 9.57001C362.18 10.31 361.59 11.17 361.17 12.16C360.75 13.15 360.54 14.2 360.54 15.31C360.54 16.42 360.75 17.47 361.17 18.46C361.59 19.45 362.17 20.32 362.92 21.05C363.66 21.79 364.53 22.38 365.53 22.81C366.53 23.26 367.59 23.48 368.7 23.48Z" fill="white"/>
|
||||
<path d="M396.52 1.94C398.38 1.94 400.12 2.29001 401.74 2.98001C403.36 3.67001 404.78 4.63 406 5.84C407.22 7.06 408.17 8.48001 408.86 10.1C409.55 11.72 409.9 13.46 409.9 15.32C409.9 17.18 409.55 18.92 408.86 20.54C408.17 22.16 407.21 23.58 406 24.8C404.78 26.02 403.37 26.97 401.74 27.66C400.12 28.35 398.38 28.7 396.52 28.7C394.66 28.7 392.92 28.35 391.3 27.66C389.68 26.97 388.26 26.01 387.04 24.8C385.82 23.59 384.87 22.16 384.18 20.54C383.49 18.92 383.14 17.18 383.14 15.32C383.14 13.46 383.49 11.72 384.18 10.1C384.87 8.48001 385.83 7.06 387.04 5.84C388.25 4.62 389.67 3.67001 391.3 2.98001C392.92 2.29001 394.66 1.94 396.52 1.94ZM396.55 23.48C397.66 23.48 398.71 23.27 399.71 22.85C400.7 22.43 401.57 21.85 402.31 21.1C403.05 20.36 403.63 19.49 404.05 18.49C404.47 17.49 404.68 16.44 404.68 15.32C404.68 14.2 404.47 13.15 404.05 12.15C403.63 11.15 403.05 10.28 402.31 9.54001C401.57 8.80001 400.7 8.21001 399.71 7.79001C398.72 7.37001 397.66 7.16 396.55 7.16C395.44 7.16 394.39 7.37001 393.39 7.79001C392.4 8.21001 391.53 8.80001 390.79 9.54001C390.05 10.28 389.46 11.15 389.02 12.15C388.58 13.15 388.36 14.2 388.36 15.32C388.36 16.44 388.58 17.49 389.02 18.49C389.46 19.49 390.05 20.36 390.79 21.1C391.53 21.84 392.4 22.43 393.39 22.85C394.38 23.27 395.44 23.48 396.55 23.48Z" fill="white"/>
|
||||
<path d="M450.14 3.76999C451.73 4.81999 452.99 6.18999 453.94 7.89999C454.89 9.60999 455.36 11.47 455.36 13.5V28.75H450.14V23.28V13.5C450.14 12.62 449.97 11.79 449.63 11.02C449.29 10.24 448.83 9.56 448.24 8.97C447.65 8.38 446.96 7.90999 446.19 7.57999C445.41 7.23999 444.59 7.07001 443.71 7.07001C442.83 7.07001 441.99 7.23999 441.2 7.57999C440.41 7.91999 439.71 8.38 439.12 8.97C438.53 9.56 438.06 10.25 437.73 11.02C437.39 11.8 437.22 12.62 437.22 13.5V23.28V28.75H432V23.28V13.5C432 12.62 431.83 11.79 431.49 11.02C431.15 10.24 430.69 9.56 430.1 8.97C429.51 8.38 428.82 7.90999 428.05 7.57999C427.27 7.23999 426.44 7.07001 425.57 7.07001C424.69 7.07001 423.86 7.23999 423.06 7.57999C422.27 7.91999 421.57 8.38 420.98 8.97C420.39 9.56 419.92 10.25 419.59 11.02C419.25 11.8 419.08 12.62 419.08 13.5V23.28V28.75H413.86V13.5V1.84H419.08V3.76999C420.03 3.15999 421.05 2.69001 422.15 2.35001C423.25 2.01001 424.39 1.84 425.57 1.84C426.75 1.84 427.89 2.01001 428.99 2.35001C430.09 2.69001 431.09 3.15999 432 3.76999C432.98 4.40999 433.86 5.22001 434.63 6.20001C435.37 5.25001 436.23 4.43999 437.21 3.76999C438.16 3.15999 439.18 2.69001 440.28 2.35001C441.38 2.01001 442.52 1.84 443.7 1.84C444.88 1.84 446.02 2.01001 447.12 2.35001C448.22 2.69001 449.22 3.15999 450.14 3.76999Z" fill="white"/>
|
||||
<path d="M17.89 119.39C17.91 119.51 17.93 119.63 17.94 119.75C17.95 119.87 17.95 120 17.95 120.14V120.17V120.2C17.95 120.34 17.95 120.47 17.94 120.59C17.93 120.71 17.91 120.83 17.89 120.95V120.98L17.8 121.7C17.78 121.72 17.77 121.76 17.77 121.82C17.75 121.92 17.73 122.01 17.71 122.09C17.69 122.17 17.66 122.26 17.62 122.36C17.34 123.28 16.92 124.1 16.37 124.82C15.82 125.54 15.15 126.15 14.37 126.65C13.71 127.05 12.98 127.36 12.18 127.59C11.38 127.82 10.53 127.93 9.63 127.93H5.7V134.44H2.5V112.4H9.64C11.44 112.4 13.02 112.83 14.38 113.69C15.16 114.19 15.82 114.8 16.38 115.52C16.93 116.24 17.34 117.05 17.63 117.95C17.71 118.17 17.76 118.36 17.78 118.52C17.78 118.58 17.79 118.62 17.81 118.64C17.83 118.76 17.84 118.88 17.85 119C17.86 119.12 17.87 119.23 17.89 119.33V119.39ZM10.03 125.12C10.69 125.12 11.29 124.98 11.84 124.7C12.39 124.42 12.87 124.05 13.28 123.59C13.69 123.13 14.01 122.61 14.24 122.01C14.47 121.42 14.58 120.81 14.58 120.17C14.58 119.53 14.46 118.92 14.24 118.33C14.01 117.74 13.69 117.21 13.28 116.75C12.87 116.29 12.39 115.92 11.84 115.64C11.29 115.36 10.69 115.22 10.03 115.22H5.74V125.12H10.03Z" fill="white"/>
|
||||
<path d="M35.56 118.85C35.56 119.79 35.41 120.59 35.12 121.24C34.83 121.89 34.4 122.57 33.85 123.26L27.19 131.75H35.41V134.42H21.49L28.39 125.3C29.01 124.5 29.57 123.77 30.07 123.11C30.27 122.83 30.47 122.55 30.68 122.28C30.89 122.01 31.07 121.76 31.24 121.53C31.4 121.3 31.53 121.11 31.64 120.97C31.75 120.83 31.81 120.74 31.84 120.7C32.18 120.12 32.35 119.5 32.35 118.84C32.35 118.32 32.25 117.83 32.05 117.37C31.85 116.91 31.57 116.51 31.22 116.17C30.87 115.83 30.46 115.56 30 115.36C29.54 115.16 29.05 115.06 28.52 115.06C27.99 115.06 27.51 115.16 27.04 115.36C26.58 115.56 26.18 115.83 25.84 116.17C25.5 116.51 25.23 116.91 25.03 117.37C24.83 117.83 24.73 118.32 24.73 118.84H21.64C21.64 117.96 21.82 117.13 22.2 116.34C22.57 115.55 23.07 114.86 23.7 114.27C24.33 113.68 25.06 113.22 25.9 112.87C26.74 112.53 27.64 112.36 28.6 112.36C29.56 112.36 30.46 112.53 31.3 112.87C32.14 113.21 32.87 113.68 33.5 114.27C34.13 114.86 34.63 115.55 35 116.34C35.37 117.13 35.56 117.97 35.56 118.85Z" fill="white"/>
|
||||
<path d="M54.49 119.39C54.51 119.51 54.53 119.63 54.54 119.75C54.55 119.87 54.55 120 54.55 120.14V120.17V120.2C54.55 120.34 54.55 120.47 54.54 120.59C54.53 120.71 54.51 120.83 54.49 120.95V120.98L54.4 121.7C54.38 121.72 54.37 121.76 54.37 121.82C54.35 121.92 54.33 122.01 54.31 122.09C54.29 122.17 54.26 122.26 54.22 122.36C53.94 123.28 53.52 124.1 52.97 124.82C52.42 125.54 51.75 126.15 50.97 126.65C50.31 127.05 49.58 127.36 48.78 127.59C47.98 127.82 47.13 127.93 46.23 127.93H42.3V134.44H39.1V112.4H46.24C48.04 112.4 49.62 112.83 50.98 113.69C51.76 114.19 52.42 114.8 52.98 115.52C53.53 116.24 53.94 117.05 54.23 117.95C54.31 118.17 54.36 118.36 54.38 118.52C54.38 118.58 54.39 118.62 54.41 118.64C54.43 118.76 54.44 118.88 54.45 119C54.46 119.12 54.47 119.23 54.49 119.33V119.39V119.39ZM46.63 125.12C47.29 125.12 47.89 124.98 48.44 124.7C48.99 124.42 49.47 124.05 49.88 123.59C50.29 123.13 50.61 122.61 50.84 122.01C51.07 121.42 51.18 120.81 51.18 120.17C51.18 119.53 51.06 118.92 50.84 118.33C50.61 117.74 50.29 117.21 49.88 116.75C49.47 116.29 48.99 115.92 48.44 115.64C47.89 115.36 47.29 115.22 46.63 115.22H42.34V125.12H46.63Z" fill="white"/>
|
||||
<path d="M87.28 119.39C87.3 119.51 87.32 119.63 87.33 119.75C87.34 119.87 87.34 120 87.34 120.14V120.17V120.2C87.34 120.34 87.34 120.47 87.33 120.59C87.32 120.71 87.3 120.83 87.28 120.95V120.98L87.19 121.7C87.17 121.72 87.16 121.76 87.16 121.82C87.14 121.92 87.12 122.01 87.1 122.09C87.08 122.17 87.05 122.26 87.01 122.36C86.73 123.28 86.31 124.1 85.76 124.82C85.21 125.54 84.54 126.15 83.76 126.65C83.1 127.05 82.37 127.36 81.57 127.59C80.77 127.82 79.92 127.93 79.02 127.93H75.09V134.44H71.88V112.4H79.02C80.82 112.4 82.4 112.83 83.76 113.69C84.54 114.19 85.2 114.8 85.76 115.52C86.31 116.24 86.72 117.05 87.01 117.95C87.09 118.17 87.14 118.36 87.16 118.52C87.16 118.58 87.17 118.62 87.19 118.64C87.21 118.76 87.22 118.88 87.23 119C87.24 119.12 87.25 119.23 87.27 119.33V119.39H87.28ZM79.42 125.12C80.08 125.12 80.68 124.98 81.23 124.7C81.78 124.42 82.26 124.05 82.67 123.59C83.08 123.13 83.4 122.61 83.63 122.01C83.86 121.42 83.97 120.81 83.97 120.17C83.97 119.53 83.85 118.92 83.63 118.33C83.4 117.74 83.08 117.21 82.67 116.75C82.26 116.29 81.78 115.92 81.23 115.64C80.68 115.36 80.08 115.22 79.42 115.22H75.13V125.12H79.42Z" fill="white"/>
|
||||
<path d="M98.8 118.58C99.9 118.58 100.93 118.79 101.89 119.2C102.85 119.61 103.69 120.17 104.41 120.89C105.13 121.61 105.7 122.45 106.11 123.41C106.52 124.37 106.72 125.4 106.72 126.5C106.72 127.6 106.51 128.63 106.11 129.59C105.7 130.55 105.13 131.39 104.41 132.11C103.69 132.83 102.85 133.4 101.89 133.8C100.93 134.21 99.9 134.42 98.8 134.42C97.7 134.42 96.67 134.21 95.71 133.8C94.75 133.39 93.91 132.83 93.19 132.11C92.47 131.39 91.9 130.55 91.49 129.59C91.08 128.63 90.88 127.6 90.88 126.5C90.88 125.4 91.08 124.37 91.49 123.41C91.9 122.45 92.47 121.61 93.19 120.89C93.91 120.17 94.75 119.61 95.71 119.2C96.67 118.79 97.7 118.58 98.8 118.58ZM98.81 131.33C99.47 131.33 100.09 131.21 100.68 130.95C101.27 130.69 101.78 130.36 102.22 129.91C102.66 129.47 103 128.95 103.25 128.37C103.5 127.78 103.62 127.16 103.62 126.49C103.62 125.82 103.5 125.2 103.25 124.61C103 124.02 102.66 123.51 102.22 123.07C101.78 122.63 101.27 122.28 100.68 122.03C100.09 121.78 99.47 121.65 98.81 121.65C98.15 121.65 97.53 121.77 96.94 122.03C96.35 122.28 95.84 122.63 95.4 123.07C94.96 123.51 94.61 124.03 94.35 124.61C94.09 125.19 93.96 125.82 93.96 126.49C93.96 127.16 94.09 127.78 94.35 128.37C94.61 128.96 94.96 129.48 95.4 129.91C95.84 130.35 96.35 130.7 96.94 130.95C97.53 131.21 98.15 131.33 98.81 131.33Z" fill="white"/>
|
||||
<path d="M131.2 118.55L125.92 130.64L124.24 134.51L122.53 130.64L120.73 126.53L118.93 130.64L117.25 134.51L115.57 130.64L110.26 118.55H113.65L117.25 126.77L119.05 122.66L120.73 118.79L122.44 122.66L124.24 126.77L127.84 118.55H131.2Z" fill="white"/>
|
||||
<path d="M143.95 127.55H137.92C138.04 128.09 138.25 128.6 138.54 129.06C138.83 129.53 139.19 129.94 139.6 130.28C140.01 130.62 140.49 130.88 141.01 131.07C141.53 131.26 142.08 131.36 142.66 131.36C143.36 131.36 144.01 131.22 144.62 130.94C145.23 130.66 145.77 130.27 146.22 129.77H146.79H149.85C149.73 130.07 149.58 130.35 149.41 130.63C149.24 130.9 149.05 131.17 148.85 131.42C148.13 132.34 147.23 133.08 146.15 133.63C145.07 134.18 143.9 134.46 142.64 134.46C141.54 134.46 140.51 134.25 139.55 133.84C138.59 133.43 137.75 132.87 137.03 132.14C136.31 131.42 135.74 130.58 135.33 129.62C134.92 128.66 134.72 127.63 134.72 126.53C134.72 125.43 134.92 124.4 135.33 123.44C135.74 122.48 136.31 121.64 137.03 120.92C137.75 120.2 138.59 119.63 139.55 119.22C140.51 118.81 141.54 118.61 142.64 118.61C143.9 118.61 145.07 118.88 146.15 119.43C147.23 119.98 148.13 120.73 148.85 121.67C149.51 122.47 149.99 123.41 150.29 124.49C150.47 125.13 150.56 125.81 150.56 126.53C150.56 126.89 150.53 127.23 150.47 127.55H147.35H143.95V127.55ZM142.66 121.7C141.68 121.7 140.8 121.95 140.04 122.46C139.27 122.96 138.69 123.63 138.29 124.46H145.28H147.05C146.95 124.3 146.86 124.14 146.76 123.99C146.67 123.83 146.57 123.68 146.48 123.54C146.02 122.97 145.46 122.52 144.8 122.19C144.14 121.86 143.42 121.7 142.66 121.7Z" fill="white"/>
|
||||
<path d="M164.77 119.76C165.45 120.2 166.03 120.76 166.51 121.43L164.77 122.81L164.11 123.35C163.77 122.85 163.32 122.45 162.78 122.15C162.23 121.85 161.63 121.7 161 121.7C160.48 121.7 159.99 121.8 159.53 122C159.07 122.2 158.67 122.47 158.33 122.81C157.99 123.15 157.72 123.55 157.52 124.01C157.32 124.47 157.22 124.96 157.22 125.48V131.18V134.39H154.13V125.46V118.61H157.22V119.76C157.76 119.4 158.35 119.11 158.99 118.91C159.63 118.71 160.3 118.61 161 118.61C161.7 118.61 162.37 118.71 163.01 118.91C163.63 119.12 164.23 119.4 164.77 119.76Z" fill="white"/>
|
||||
<path d="M177.55 127.55H171.52C171.64 128.09 171.85 128.6 172.14 129.06C172.43 129.53 172.79 129.94 173.2 130.28C173.61 130.62 174.09 130.88 174.61 131.07C175.13 131.26 175.68 131.36 176.26 131.36C176.96 131.36 177.61 131.22 178.22 130.94C178.83 130.66 179.37 130.27 179.82 129.77H180.39H183.45C183.33 130.07 183.18 130.35 183.01 130.63C182.84 130.9 182.65 131.17 182.45 131.42C181.73 132.34 180.83 133.08 179.75 133.63C178.67 134.18 177.5 134.46 176.24 134.46C175.14 134.46 174.11 134.25 173.15 133.84C172.19 133.43 171.35 132.87 170.63 132.14C169.91 131.42 169.34 130.58 168.93 129.62C168.52 128.66 168.32 127.63 168.32 126.53C168.32 125.43 168.52 124.4 168.93 123.44C169.34 122.48 169.91 121.64 170.63 120.92C171.35 120.2 172.19 119.63 173.15 119.22C174.11 118.81 175.14 118.61 176.24 118.61C177.5 118.61 178.67 118.88 179.75 119.43C180.83 119.98 181.73 120.73 182.45 121.67C183.11 122.47 183.59 123.41 183.89 124.49C184.07 125.13 184.16 125.81 184.16 126.53C184.16 126.89 184.13 127.23 184.07 127.55H180.95H177.55V127.55ZM176.26 121.7C175.28 121.7 174.4 121.95 173.64 122.46C172.87 122.96 172.29 123.63 171.89 124.46H178.88H180.65C180.55 124.3 180.46 124.14 180.36 123.99C180.27 123.83 180.17 123.68 180.08 123.54C179.62 122.97 179.06 122.52 178.4 122.19C177.74 121.86 177.02 121.7 176.26 121.7Z" fill="white"/>
|
||||
<path d="M203.55 126.53C203.55 127.63 203.34 128.66 202.94 129.62C202.53 130.58 201.96 131.42 201.24 132.14C200.52 132.86 199.68 133.43 198.72 133.84C197.76 134.25 196.73 134.46 195.63 134.46C194.53 134.46 193.5 134.25 192.54 133.84C191.58 133.43 190.74 132.87 190.02 132.14C189.3 131.42 188.73 130.58 188.32 129.62C187.91 128.66 187.71 127.63 187.71 126.53C187.71 125.43 187.91 124.4 188.32 123.44C188.73 122.48 189.3 121.64 190.02 120.92C190.74 120.2 191.58 119.63 192.54 119.22C193.5 118.81 194.53 118.61 195.63 118.61C196.55 118.61 197.42 118.75 198.22 119.04C199.03 119.33 199.78 119.74 200.46 120.25V108.01H203.55V126.49V126.53ZM195.65 131.36C196.31 131.36 196.93 131.24 197.52 130.98C198.11 130.72 198.62 130.38 199.06 129.94C199.5 129.5 199.84 128.98 200.09 128.4C200.34 127.81 200.46 127.18 200.46 126.52V126.49C200.46 125.83 200.34 125.21 200.09 124.63C199.84 124.05 199.5 123.54 199.06 123.1C198.62 122.66 198.11 122.31 197.52 122.06C196.93 121.81 196.31 121.68 195.65 121.68C194.99 121.68 194.37 121.8 193.78 122.06C193.19 122.31 192.68 122.65 192.24 123.1C191.8 123.54 191.45 124.06 191.19 124.64C190.93 125.22 190.8 125.85 190.8 126.52C190.8 127.18 190.93 127.81 191.19 128.4C191.45 128.99 191.8 129.5 192.24 129.94C192.68 130.38 193.19 130.73 193.78 130.98C194.37 131.24 194.99 131.36 195.65 131.36Z" fill="white"/>
|
||||
<path d="M224.1 131.3H233.07V134.45H220.89V112.43H224.1V131.3Z" fill="white"/>
|
||||
<path d="M248.67 122.78C249.55 123.26 250.25 123.95 250.78 124.86C251.31 125.77 251.57 126.81 251.57 127.96C251.57 128.84 251.4 129.67 251.06 130.45C250.72 131.23 250.26 131.91 249.68 132.5C249.1 133.09 248.42 133.56 247.64 133.9C246.86 134.24 246.03 134.41 245.15 134.41H236.6V112.42H245C245.8 112.42 246.56 112.58 247.28 112.88C248 113.19 248.63 113.62 249.17 114.16C249.71 114.7 250.13 115.33 250.44 116.05C250.75 116.77 250.91 117.54 250.91 118.36C250.91 118.86 250.84 119.32 250.71 119.75C250.58 120.18 250.41 120.58 250.2 120.94C249.99 121.3 249.75 121.64 249.48 121.94C249.23 122.27 248.95 122.54 248.67 122.78ZM239.82 121.52H244.47C244.91 121.52 245.32 121.44 245.7 121.28C246.08 121.12 246.41 120.9 246.69 120.6C246.97 120.31 247.19 119.98 247.35 119.6C247.51 119.22 247.59 118.82 247.59 118.4C247.59 117.5 247.29 116.75 246.69 116.15C246.09 115.55 245.35 115.25 244.47 115.25H239.82V121.52V121.52ZM244.62 131.6C245.12 131.6 245.59 131.5 246.03 131.3C246.47 131.1 246.86 130.84 247.18 130.51C247.51 130.18 247.77 129.8 247.96 129.35C248.15 128.91 248.24 128.45 248.24 127.97C248.24 127.47 248.15 127 247.96 126.56C247.77 126.12 247.51 125.74 247.18 125.41C246.85 125.08 246.46 124.82 246.03 124.63C245.59 124.44 245.12 124.34 244.62 124.34H239.82V131.6H244.62V131.6Z" fill="white"/>
|
||||
<path d="M270.24 122.39C269.96 123.29 269.54 124.1 268.99 124.82C268.44 125.54 267.76 126.15 266.96 126.65C266.92 126.69 266.87 126.72 266.81 126.74C266.75 126.76 266.7 126.79 266.66 126.83L270.53 134.45H267.14L263.75 127.82C263.51 127.86 263.27 127.89 263.03 127.91C262.79 127.93 262.53 127.94 262.25 127.94H258.32V134.45H255.11V112.4H262.25C263.15 112.4 263.99 112.52 264.78 112.74C265.57 112.96 266.29 113.28 266.95 113.68C268.57 114.7 269.66 116.13 270.22 117.97C270.24 118.07 270.26 118.16 270.28 118.24C270.3 118.32 270.33 118.41 270.37 118.51V118.66C270.41 118.76 270.44 118.87 270.45 118.99C270.46 119.11 270.48 119.23 270.49 119.35V119.38C270.51 119.5 270.52 119.62 270.52 119.74C270.52 119.86 270.52 119.99 270.52 120.13V120.16V120.22C270.52 120.34 270.52 120.46 270.52 120.58C270.52 120.7 270.51 120.82 270.49 120.94V121C270.47 121.12 270.45 121.24 270.45 121.34C270.44 121.45 270.41 121.57 270.37 121.69V121.81C270.31 122.04 270.26 122.23 270.24 122.39ZM262.65 125.12C263.31 125.12 263.91 124.98 264.46 124.7C265.01 124.42 265.49 124.05 265.89 123.59C266.29 123.13 266.61 122.6 266.85 122.01C267.09 121.42 267.21 120.8 267.21 120.16C267.21 119.54 267.09 118.93 266.85 118.32C266.61 117.72 266.29 117.19 265.89 116.74C265.49 116.29 265.01 115.92 264.46 115.64C263.91 115.36 263.31 115.22 262.65 115.22H258.36V125.12H262.65V125.12Z" fill="white"/>
|
||||
<path d="M290.91 112.4V112.43L284.1 127.25V134.45V134.48H280.89V134.45V127.25L274.08 112.43H274.11L274.08 112.4H277.65L282.51 124.16L287.37 112.4H290.91Z" fill="white"/>
|
||||
<path d="M308.25 112.43H311.46V134.42H308.25V112.43Z" fill="white"/>
|
||||
<path d="M325.75 119.63C326.7 120.27 327.45 121.1 328.01 122.1C328.56 123.11 328.84 124.22 328.84 125.42V134.45H325.75V131.18V125.42C325.75 124.9 325.65 124.41 325.45 123.93C325.25 123.46 324.97 123.05 324.62 122.7C324.27 122.35 323.86 122.08 323.4 121.87C322.94 121.67 322.45 121.57 321.92 121.57C321.39 121.57 320.9 121.67 320.43 121.87C319.96 122.07 319.55 122.35 319.21 122.7C318.87 123.05 318.6 123.46 318.4 123.93C318.2 124.4 318.1 124.9 318.1 125.42V131.18V134.45H315V125.42V118.49H318.09L318.11 119.63C318.65 119.27 319.25 118.99 319.9 118.79C320.55 118.59 321.23 118.49 321.93 118.49C322.63 118.49 323.31 118.59 323.96 118.79C324.61 118.99 325.2 119.27 325.75 119.63Z" fill="white"/>
|
||||
<path d="M341.13 127.1C341.41 127.5 341.63 127.94 341.79 128.41C341.95 128.88 342.02 129.37 342 129.89C341.98 130.55 341.83 131.16 341.55 131.72C341.27 132.28 340.9 132.77 340.44 133.19C339.98 133.61 339.44 133.94 338.82 134.17C338.2 134.4 337.55 134.5 336.87 134.48C335.91 134.44 335.04 134.19 334.28 133.73C333.51 133.27 332.92 132.66 332.53 131.9L335.11 130.1C335.21 130.46 335.43 130.77 335.77 131.02C336.11 131.27 336.51 131.4 336.97 131.4C337.49 131.42 337.95 131.28 338.34 130.97C338.73 130.66 338.92 130.27 338.92 129.81C338.92 129.67 338.91 129.57 338.89 129.51V129.45C338.87 129.41 338.86 129.38 338.86 129.35C338.86 129.32 338.85 129.29 338.83 129.24C338.69 128.9 338.43 128.62 338.05 128.4C337.99 128.36 337.9 128.32 337.78 128.28L337.81 128.25C337.53 128.09 337.22 127.93 336.88 127.79C336.54 127.64 336.19 127.48 335.84 127.31C335.49 127.14 335.14 126.96 334.8 126.77C334.46 126.58 334.14 126.35 333.84 126.1V126.07L333.75 125.98C333.33 125.58 333 125.11 332.76 124.58C332.52 124.05 332.41 123.49 332.43 122.89C332.45 122.29 332.59 121.73 332.85 121.21C333.11 120.69 333.45 120.24 333.89 119.86C334.32 119.48 334.82 119.18 335.4 118.96C335.98 118.74 336.6 118.64 337.26 118.66C338.12 118.7 338.89 118.91 339.58 119.29C340.27 119.67 340.82 120.18 341.22 120.82L338.7 122.56C338.58 122.34 338.39 122.15 338.12 121.99C337.85 121.83 337.54 121.75 337.2 121.75C336.74 121.73 336.35 121.84 336.02 122.08C335.69 122.32 335.52 122.61 335.52 122.95C335.52 123.05 335.53 123.12 335.55 123.16C335.59 123.38 335.69 123.57 335.85 123.73C336.01 123.89 336.21 124.02 336.45 124.12C336.83 124.34 337.26 124.57 337.75 124.8C338.24 125.03 338.74 125.28 339.24 125.56C339.6 125.72 339.96 125.95 340.32 126.25L340.47 126.4C340.67 126.56 340.89 126.8 341.13 127.1Z" fill="white"/>
|
||||
<path d="M354.45 117.29H351.57V134.45H348.48V117.29H345.57V114.2H348.48V108.32H351.57V114.2H354.45V117.29Z" fill="white"/>
|
||||
<path d="M365.54 118.61C366.64 118.61 367.67 118.82 368.64 119.24C369.61 119.66 370.46 120.23 371.19 120.95C371.92 121.67 372.49 122.52 372.91 123.5C373.33 124.48 373.54 125.52 373.54 126.62V134.39H370.45V132.94C369.77 133.46 369.02 133.86 368.2 134.16C367.38 134.46 366.5 134.61 365.56 134.61C364.46 134.61 363.43 134.4 362.46 133.98C361.49 133.56 360.64 132.99 359.92 132.27C359.2 131.55 358.63 130.71 358.21 129.73C357.79 128.76 357.58 127.73 357.58 126.63C357.58 125.53 357.79 124.49 358.21 123.51C358.63 122.53 359.2 121.68 359.92 120.96C360.64 120.24 361.48 119.67 362.45 119.25C363.41 118.82 364.44 118.61 365.54 118.61ZM365.55 131.51C366.23 131.51 366.87 131.38 367.46 131.12C368.05 130.86 368.56 130.51 369 130.06C369.44 129.62 369.79 129.1 370.05 128.51C370.31 127.92 370.44 127.29 370.44 126.62C370.44 125.94 370.31 125.3 370.05 124.7C369.79 124.11 369.44 123.59 369 123.14C368.56 122.7 368.04 122.35 367.46 122.08C366.87 121.82 366.23 121.69 365.55 121.69C364.87 121.69 364.23 121.82 363.65 122.08C363.06 122.34 362.54 122.69 362.11 123.14C361.67 123.58 361.32 124.1 361.06 124.7C360.8 125.29 360.67 125.93 360.67 126.62C360.67 127.28 360.8 127.91 361.06 128.51C361.32 129.1 361.67 129.62 362.11 130.06C362.55 130.5 363.06 130.86 363.65 131.12C364.24 131.38 364.87 131.51 365.55 131.51Z" fill="white"/>
|
||||
<path d="M387.84 119.63C388.79 120.27 389.54 121.1 390.1 122.1C390.65 123.11 390.93 124.22 390.93 125.42V134.45H387.84V131.18V125.42C387.84 124.9 387.74 124.41 387.54 123.93C387.34 123.46 387.06 123.05 386.71 122.7C386.36 122.35 385.95 122.08 385.49 121.87C385.03 121.67 384.54 121.57 384.01 121.57C383.48 121.57 382.99 121.67 382.52 121.87C382.05 122.07 381.64 122.35 381.3 122.7C380.96 123.05 380.69 123.46 380.49 123.93C380.29 124.4 380.19 124.9 380.19 125.42V131.18V134.45H377.1V125.42V118.49H380.19L380.21 119.63C380.75 119.27 381.35 118.99 382 118.79C382.65 118.59 383.33 118.49 384.03 118.49C384.73 118.49 385.41 118.59 386.06 118.79C386.71 118.99 387.3 119.27 387.84 119.63Z" fill="white"/>
|
||||
<path d="M402.42 131.33C403.2 131.33 403.92 131.16 404.58 130.82C405.24 130.48 405.79 130.03 406.23 129.47L408.66 131.36C407.94 132.29 407.04 133.03 405.96 133.59C404.88 134.15 403.7 134.42 402.42 134.42C401.34 134.42 400.32 134.21 399.36 133.8C398.4 133.39 397.56 132.83 396.83 132.11C396.1 131.39 395.53 130.55 395.12 129.59C394.71 128.63 394.5 127.6 394.5 126.5C394.5 125.42 394.71 124.4 395.12 123.44C395.53 122.48 396.1 121.64 396.83 120.92C397.56 120.2 398.4 119.63 399.36 119.22C400.32 118.81 401.34 118.61 402.42 118.61C403.7 118.61 404.88 118.88 405.96 119.43C407.04 119.98 407.94 120.72 408.66 121.67L406.23 123.56C405.79 122.98 405.24 122.53 404.58 122.2C403.92 121.87 403.2 121.7 402.42 121.7C401.76 121.7 401.13 121.82 400.54 122.07C399.95 122.32 399.43 122.66 399 123.1C398.56 123.54 398.21 124.05 397.96 124.64C397.71 125.23 397.58 125.85 397.58 126.5C397.58 127.15 397.7 127.78 397.96 128.36C398.21 128.95 398.55 129.46 399 129.9C399.44 130.34 399.96 130.69 400.54 130.94C401.12 131.19 401.76 131.33 402.42 131.33Z" fill="white"/>
|
||||
<path d="M421.38 127.55H415.35C415.47 128.09 415.68 128.6 415.97 129.06C416.26 129.53 416.61 129.94 417.04 130.28C417.46 130.62 417.93 130.88 418.45 131.07C418.97 131.26 419.52 131.36 420.1 131.36C420.8 131.36 421.45 131.22 422.06 130.94C422.67 130.66 423.2 130.27 423.67 129.77H424.24H427.3C427.18 130.07 427.03 130.35 426.86 130.63C426.69 130.9 426.5 131.17 426.31 131.42C425.59 132.34 424.69 133.08 423.61 133.63C422.53 134.18 421.36 134.46 420.1 134.46C419 134.46 417.97 134.25 417.01 133.84C416.05 133.43 415.21 132.87 414.49 132.14C413.77 131.42 413.2 130.58 412.8 129.62C412.39 128.66 412.18 127.63 412.18 126.53C412.18 125.43 412.39 124.4 412.8 123.44C413.21 122.48 413.77 121.64 414.49 120.92C415.21 120.2 416.05 119.63 417.01 119.22C417.97 118.81 419 118.61 420.1 118.61C421.36 118.61 422.53 118.88 423.61 119.43C424.69 119.98 425.59 120.73 426.31 121.67C426.97 122.47 427.45 123.41 427.75 124.49C427.93 125.13 428.02 125.81 428.02 126.53C428.02 126.89 427.99 127.23 427.93 127.55H424.81H421.38V127.55ZM420.09 121.7C419.11 121.7 418.23 121.95 417.47 122.46C416.7 122.96 416.11 123.63 415.72 124.46H422.71H424.48C424.38 124.3 424.28 124.14 424.19 123.99C424.1 123.84 424.01 123.68 423.9 123.54C423.44 122.97 422.88 122.52 422.22 122.19C421.56 121.86 420.85 121.7 420.09 121.7Z" fill="white"/>
|
||||
<path d="M479.01 135.92C476.25 135.92 474.01 133.68 474.01 130.92C474.01 129.91 474.01 127.21 514.54 4.35002C515.41 1.73002 518.24 0.300023 520.85 1.17002C523.47 2.03002 524.9 4.86002 524.03 7.48002C508.55 54.39 485.23 125.78 483.97 131.52C483.68 134 481.57 135.92 479.01 135.92Z" fill="white"/>
|
||||
<path d="M455.35 135.92C452.59 135.92 450.35 133.68 450.35 130.92C450.35 129.91 450.35 127.21 490.88 4.35002C491.75 1.73002 494.57 0.300023 497.19 1.17002C499.81 2.04002 501.24 4.86002 500.37 7.48002C484.9 54.39 461.58 125.78 460.31 131.52C460.02 134 457.91 135.92 455.35 135.92Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
158
src/assets/styles/common.css
Normal file
|
@ -0,0 +1,158 @@
|
|||
:root {
|
||||
--color-master: #cf3352;
|
||||
--color-slave: #f77937;
|
||||
--color-error: rgb(245, 81, 69);
|
||||
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
|
||||
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);
|
||||
--color-dark: #0e1117;
|
||||
--color-light: rgb(235, 237, 241);
|
||||
--gradient-animation: gradient-animation 5s linear infinite alternate;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: .75rem;
|
||||
font-family: Arial, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif;
|
||||
letter-spacing: .2ch;
|
||||
}
|
||||
|
||||
body#page {
|
||||
--root-smallest-font-size: .95rem;
|
||||
--root-font-size-relative-to-screen: 1;
|
||||
font-size: max(var(--root-smallest-font-size), calc(min(1vw, 2vh) * var(--root-font-size-relative-to-screen)));
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
|
||||
background-attachment: fixed;
|
||||
color: var(--color-light);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(19, 19, 19, 0.75);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
|
||||
justify-content: center;
|
||||
gap: .25em;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
padding: .5em 1em;
|
||||
|
||||
background: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
|
||||
border-radius: .5em;
|
||||
border: unset;
|
||||
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.active {
|
||||
background: var(--color-gradient-0);
|
||||
}
|
||||
|
||||
.button.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-gradient-0);
|
||||
filter: blur(.5em);
|
||||
z-index: -1;
|
||||
border-radius: .5em;
|
||||
}
|
||||
|
||||
.button.disabled {
|
||||
filter: saturate(0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filled {
|
||||
background: var(--color-gradient-0);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
font-weight: bold;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: .5em;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.error::before {
|
||||
content: "!";
|
||||
width: 2em;
|
||||
aspect-ratio: 1/1;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
letter-spacing: 0;
|
||||
line-height: 0;
|
||||
|
||||
border: .1em solid currentColor;
|
||||
border-radius: 100000vw;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #0e1117;
|
||||
opacity: .75;
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
@import '../style'
|
||||
|
||||
.ButtonRadio
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.radio-button
|
||||
@extend .button
|
||||
margin: 6px
|
||||
|
||||
.radio-button.checked
|
||||
@extend .button.active
|
||||
|
||||
input[type="radio"]
|
||||
opacity: 0
|
||||
position: absolute
|
|
@ -1,31 +0,0 @@
|
|||
import { h } from 'preact';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import './ButtonRadio.sass';
|
||||
|
||||
export interface SelectionOption {
|
||||
value: string
|
||||
display: string
|
||||
}
|
||||
|
||||
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
|
||||
name?: string;
|
||||
onChange(redirect: string): void;
|
||||
value: T extends SelectionOption ? T['value'] : T;
|
||||
options: T[];
|
||||
}
|
||||
|
||||
const getAttr = (x: string | SelectionOption, key: keyof SelectionOption): string => typeof x === 'string' ? x : x[key];
|
||||
|
||||
export default function ButtonRadio<T extends string | SelectionOption = string>({ name = 'buttonRadio', onChange, options, value }: ButtonRadioProps<T>) {
|
||||
/** If it's a string, return the string, if it's a SelectionOption get the selection option property */
|
||||
return <div className='ButtonRadio'>
|
||||
{options.map(o => ({ o: getAttr(o, 'value'), display: getAttr(o, 'display') })).map(({ o, display }) =>
|
||||
<div key={o} className={classnames('radio-button', { 'checked': value === o })}
|
||||
onClick={() => o !== value && onChange(o)}>
|
||||
<input name={name} value={o} type='radio' checked={value === o} />
|
||||
<label>{display}</label>
|
||||
</div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from './lbry-url';
|
||||
|
||||
describe('web url parsing', () => {
|
||||
const testCases: [string, string | undefined][] = [
|
||||
['https://lbry.tv/@test:7/foo-123:7', 'lbry://@test:7/foo-123:7'],
|
||||
['https://lbry.tv/@test1:c/foo:8', 'lbry://@test1:c/foo:8'],
|
||||
['https://lbry.tv/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', 'lbry://@test1:0/foo-bar-2-baz-7:e'],
|
||||
['https://lbry.tv/@test:7', 'lbry://@test:7'],
|
||||
['https://lbry.tv/@test:c', 'lbry://@test:c'],
|
||||
['https://lbry.tv/$/discover?t=foo%20bar', undefined],
|
||||
['https://lbry.tv/$/signup?redirect=/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', undefined],
|
||||
];
|
||||
|
||||
test.each(testCases)('redirect %s', (url, expected) => {
|
||||
expect(appRedirectUrl(url)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app url parsing', () => {
|
||||
const testCases: Array<[string, string[]]> = [
|
||||
['test', ['test']],
|
||||
['@test', ['@test']],
|
||||
['lbry://@test$1/stuff', ['@test$1', 'stuff']],
|
||||
];
|
||||
|
||||
test.each(testCases)('redirect %s', (url, expected) => {
|
||||
expect(parseProtocolUrl(url)).toEqual(expected);
|
||||
});
|
||||
});
|
|
@ -1,142 +0,0 @@
|
|||
// Port of https://github.com/lbryio/lbry-sdk/blob/master/lbry/schema/url.py
|
||||
|
||||
interface UrlOptions {
|
||||
/**
|
||||
* Whether or not to encodeURIComponent the path segments.
|
||||
* Doing so is a workaround such that browsers interpret it as a valid URL in a way that the desktop app understands.
|
||||
*/
|
||||
encode?: boolean
|
||||
}
|
||||
|
||||
const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source;
|
||||
|
||||
/** Creates a named regex group */
|
||||
const named = (name: string, regex: string) => `(?<${name}>${regex})`;
|
||||
/** Creates a non-capturing group */
|
||||
const group = (regex: string) => `(?:${regex})`;
|
||||
/** Allows for one of the patterns */
|
||||
const oneOf = (...choices: string[]) => group(choices.join('|'));
|
||||
/** Create an lbry url claim */
|
||||
const claim = (name: string, prefix = '') => group(
|
||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
||||
+ oneOf(
|
||||
group(':' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
|
||||
group('\\*' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
||||
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
|
||||
) + '?'
|
||||
);
|
||||
|
||||
/** Create an lbry url claim, but use the old pattern for claims */
|
||||
const legacyClaim = (name: string, prefix = '') => group(
|
||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
||||
+ oneOf(
|
||||
group('#' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
|
||||
group(':' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
||||
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
|
||||
) + '?');
|
||||
|
||||
export const builder = { named, group, oneOf, claim, legacyClaim, invalidNamesRegex };
|
||||
|
||||
/** Creates a pattern to parse lbry protocol URLs. Unused, but I left it here. */
|
||||
function createProtocolUrlRegex(legacy = false) {
|
||||
const claim = legacy ? builder.legacyClaim : builder.claim;
|
||||
return new RegExp('^' + named('scheme', 'lbry://') + '?' + oneOf(
|
||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||
claim('channel', '@'),
|
||||
claim('stream'),
|
||||
) + '$');
|
||||
}
|
||||
|
||||
/** Creates a pattern to match lbry.tv style sites by their pathname */
|
||||
function createWebUrlRegex(legacy = false) {
|
||||
const claim = legacy ? builder.legacyClaim : builder.claim;
|
||||
return new RegExp('^/' + oneOf(
|
||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||
claim('channel', '@'),
|
||||
claim('stream'),
|
||||
) + '$');
|
||||
}
|
||||
|
||||
/** Pattern for lbry.tv style sites */
|
||||
export const URL_REGEX = createWebUrlRegex();
|
||||
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex();
|
||||
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true);
|
||||
|
||||
/**
|
||||
* Encapsulates a lbry url path segment.
|
||||
* Handles `StreamClaimNameAndModifier' and `ChannelClaimNameAndModifier`
|
||||
*/
|
||||
export class PathSegment {
|
||||
constructor(public name: string,
|
||||
public claimID?: string,
|
||||
public sequence?: number,
|
||||
public amountOrder?: number) { }
|
||||
|
||||
static fromMatchGroup(segment: string, groups: Record<string, string>) {
|
||||
return new PathSegment(
|
||||
groups[`${segment}_name`],
|
||||
groups[`${segment}_claim_id`],
|
||||
parseInt(groups[`${segment}_sequence`]),
|
||||
parseInt(groups[`${segment}_amount_order`])
|
||||
);
|
||||
}
|
||||
|
||||
/** Prints the segment */
|
||||
toString() {
|
||||
if (this.claimID) return `${this.name}:${this.claimID}`;
|
||||
if (this.sequence) return `${this.name}*${this.sequence}`;
|
||||
if (this.amountOrder) return `${this.name}$${this.amountOrder}`;
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function
|
||||
*
|
||||
* @param ptn pattern to use; specific to the patterns defined in this file
|
||||
* @param url the url to try to parse
|
||||
* @returns an array of path segments; if invalid, will return an empty array
|
||||
*/
|
||||
function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { encode: false }): string[] {
|
||||
const match = url.match(ptn)?.groups;
|
||||
if (!match) return [];
|
||||
|
||||
const segments = match['channel_name'] ? ['channel']
|
||||
: match['channel_with_stream_name'] ? ['channel_with_stream', 'stream_in_channel']
|
||||
: match['stream_name'] ? ['stream']
|
||||
: null;
|
||||
|
||||
if (!segments) throw new Error(`${url} matched the overall pattern, but could not determine type`);
|
||||
|
||||
return segments.map(s => PathSegment.fromMatchGroup(s, match).toString())
|
||||
.map(s => options.encode ? encodeURIComponent(s) : s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the lbry protocl URL from the frontend URL
|
||||
*
|
||||
* @param url lbry frontend URL
|
||||
* @param options options for the redirect
|
||||
*/
|
||||
export function appRedirectUrl(url: string, options?: UrlOptions): string | undefined {
|
||||
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options);
|
||||
if (segments.length === 0) return;
|
||||
const path = segments.join('/');
|
||||
|
||||
return `lbry://${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a lbry protocol and returns its constituent path segments. Attempts the spec compliant and then the old URL schemes.
|
||||
*
|
||||
* @param url the lbry url
|
||||
* @returns an array of path segments; if invalid, will return an empty array
|
||||
*/
|
||||
export function parseProtocolUrl(url: string, options: UrlOptions = { encode: false }): string[] {
|
||||
for (const ptn of [PROTOCOL_URL_REGEX, PROTOCOL_URL_REGEX_LEGACY]) {
|
||||
const segments = patternSegmenter(ptn, url, options);
|
||||
if (segments.length === 0) continue;
|
||||
return segments;
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
export interface LbrySettings {
|
||||
enabled: boolean
|
||||
redirect: keyof typeof redirectDomains
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, redirect: 'odysee' };
|
||||
|
||||
export const redirectDomains = {
|
||||
'odysee' { prefix: 'https://odysee.com/', display: 'Odysee' },
|
||||
};
|
||||
|
||||
export function getSettingsAsync<K extends Array<keyof LbrySettings>>(...keys: K): Promise<Pick<LbrySettings, K[number]>> {
|
||||
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
$background-color: #231830 !default
|
||||
$text-color: whitesmoke !default
|
||||
|
||||
$btn-color: #38274c !default
|
||||
$btn-select: #c40054 !default
|
||||
|
||||
body
|
||||
width: 400px
|
||||
text-align: center
|
||||
background-color: $background-color
|
||||
color: $text-color
|
||||
font-family: sans-serif
|
||||
|
||||
.container
|
||||
display: block
|
||||
text-align: center
|
||||
margin: 0 32px
|
||||
margin-bottom: 15px
|
||||
|
||||
.button
|
||||
border-radius: 5px
|
||||
background-color: $btn-color
|
||||
border: 4px solid $btn-color
|
||||
color: $text-color
|
||||
font-size: 0.8rem
|
||||
font-weight: 400
|
||||
padding: 4px 15px
|
||||
text-align: center
|
||||
|
||||
&.active
|
||||
border: 4px solid $btn-select
|
||||
|
||||
&:focus
|
||||
outline: none
|
|
@ -1,30 +0,0 @@
|
|||
import { useReducer, useEffect } from 'preact/hooks';
|
||||
import { DEFAULT_SETTINGS } from './settings';
|
||||
|
||||
/**
|
||||
* A hook to read the settings from local storage
|
||||
*
|
||||
* @param initial the default value. Must have all relevant keys present and should not change
|
||||
*/
|
||||
export function useSettings<T extends object>(initial: T) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
|
||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||
useEffect(() => {
|
||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== 'local') return;
|
||||
const changeSet = Object.keys(changes)
|
||||
.filter(k => Object.keys(initial).includes(k))
|
||||
.map(k => [k, changes[k].newValue]);
|
||||
if (changeSet.length === 0) return; // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeSet));
|
||||
};
|
||||
chrome.storage.onChanged.addListener(changeListener);
|
||||
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
|
||||
return () => chrome.storage.onChanged.removeListener(changeListener);
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** A hook to read watch on lbry settings from local storage */
|
||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS);
|
126
src/common/yt.ts
|
@ -1,126 +0,0 @@
|
|||
import chunk from 'lodash/chunk';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
|
||||
const LBRY_API_HOST = 'https://api.odysee.com';
|
||||
const QUERY_CHUNK_SIZE = 300;
|
||||
|
||||
interface YtResolverResponse {
|
||||
success: boolean;
|
||||
error: object | null;
|
||||
data: {
|
||||
videos?: Record<string, string>;
|
||||
channels?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
interface YtSubscription {
|
||||
id: string;
|
||||
etag: string;
|
||||
title: string;
|
||||
snippet: {
|
||||
description: string;
|
||||
resourceId: {
|
||||
channelId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''));
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort();
|
||||
reject(new DOMException(`Could not read ${file.name}`));
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export interface YTDescriptor {
|
||||
id: string
|
||||
type: 'channel' | 'video'
|
||||
}
|
||||
|
||||
export const ytService = {
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml');
|
||||
|
||||
return Array.from(opml.querySelectorAll('outline > outline'))
|
||||
.map(outline => outline.getAttribute('xmlUrl'))
|
||||
.filter((url): url is string => !!url)
|
||||
.map(url => ytService.getChannelId(url))
|
||||
.filter((url): url is string => !!url); // we don't want it if it's empty
|
||||
},
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions JSON file
|
||||
*
|
||||
* @param jsonContents a JSON file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readJson(jsonContents: string): string[] {
|
||||
const subscriptions: YtSubscription[] = JSON.parse(jsonContents);
|
||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the channelID from a YT URL.
|
||||
*
|
||||
* Handles these two types of YT URLs:
|
||||
* * /feeds/videos.xml?channel_id=*
|
||||
* * /channel/*
|
||||
*/
|
||||
getChannelId(channelURL: string) {
|
||||
const match = channelURL.match(/channel\/([^\s?]*)/);
|
||||
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id');
|
||||
},
|
||||
|
||||
/** Extracts the video ID from a YT URL */
|
||||
getVideoId(url: string) {
|
||||
const regex = /watch\/?\?.*v=([^\s&]*)/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null; // match[1] is the videoId
|
||||
},
|
||||
|
||||
getId(url: string): YTDescriptor | null {
|
||||
const videoId = ytService.getVideoId(url);
|
||||
if (videoId) return { id: videoId, type: 'video' };
|
||||
const channelId = ytService.getChannelId(url);
|
||||
if (channelId) return { id: channelId, type: 'channel' };
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param descriptors YT resource IDs to check
|
||||
* @returns a promise with the list of channels that were found on lbry
|
||||
*/
|
||||
async resolveById(...descriptors: YTDescriptor[]): Promise<string[]> {
|
||||
const descChunks = chunk(descriptors, QUERY_CHUNK_SIZE);
|
||||
const responses: (YtResolverResponse | null)[] = await Promise.all(descChunks.map(descChunk => {
|
||||
const groups = groupBy(descChunk, d => d.type);
|
||||
const params = new URLSearchParams(pickBy({
|
||||
video_ids: groups['video']?.map(s => s.id).join(','),
|
||||
channel_ids: groups['channel']?.map(s => s.id).join(','),
|
||||
}));
|
||||
return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`, { cache: 'force-cache' })
|
||||
.then(rsp => rsp.ok ? rsp.json() : null);
|
||||
}));
|
||||
|
||||
return responses.filter((rsp): rsp is YtResolverResponse => !!rsp)
|
||||
.flatMap(rsp => [...Object.values(rsp.data.videos || {}), ...Object.values(rsp.data.channels || {})]) // flatten the results into a 1D array
|
||||
.filter(s => s);
|
||||
},
|
||||
};
|
141
src/components/dialogs.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { h } from 'preact'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
type Message = string
|
||||
type Alert = { type: 'alert' | 'prompt' | 'confirm', message: Message, resolve: (data: string | boolean | null) => void }
|
||||
export type DialogManager = ReturnType<typeof createDialogManager>
|
||||
|
||||
export function createDialogManager() {
|
||||
const [alerts, setAlerts] = useState({} as Record<string, Alert>)
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
function add(alert: Alert) {
|
||||
setAlerts({ ...alerts, alert })
|
||||
}
|
||||
function remove() {
|
||||
delete alerts[id]
|
||||
setAlerts({ ...alerts })
|
||||
}
|
||||
|
||||
return {
|
||||
useAlerts() { return alerts },
|
||||
async alert(message: Message) {
|
||||
return await new Promise<void>((resolve) => add({
|
||||
message, type: 'alert', resolve: () => {
|
||||
resolve()
|
||||
remove()
|
||||
}
|
||||
}))
|
||||
},
|
||||
async prompt(message: Message) {
|
||||
return await new Promise<string | null>((resolve) => add({
|
||||
message, type: 'prompt', resolve: (data) => {
|
||||
resolve(data?.toString() ?? null)
|
||||
remove()
|
||||
}
|
||||
}))
|
||||
},
|
||||
async confirm(message: Message) {
|
||||
return await new Promise<boolean>((resolve) => add({
|
||||
message, type: 'confirm', resolve: (data) => {
|
||||
resolve(!!data)
|
||||
remove()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function Dialogs(params: { manager: ReturnType<typeof createDialogManager> }) {
|
||||
const alerts = params.manager.useAlerts()
|
||||
let currentAlert = Object.values(alerts)[0]
|
||||
if (!currentAlert) return <noscript></noscript>
|
||||
|
||||
const [value, setValue] = useState(null as Parameters<typeof currentAlert['resolve']>[0])
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const dialog = useRef(null as any as HTMLDialogElement)
|
||||
useEffect(() => {
|
||||
if (!dialog.current) return
|
||||
if (!dialog.current.open) dialog.current.showModal()
|
||||
const onClose = () => currentAlert.resolve(null)
|
||||
dialog.current.addEventListener('close', onClose)
|
||||
return dialog.current.removeEventListener('close', onClose)
|
||||
})
|
||||
return <dialog class="alert-dialog" ref={dialog}>
|
||||
<style>
|
||||
{`
|
||||
.alert-dialog
|
||||
{
|
||||
border: none;
|
||||
background: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
padding: 1.5em;
|
||||
}
|
||||
|
||||
.alert-dialog::before {
|
||||
content: "";
|
||||
display: block;
|
||||
background: var(--color-gradient-0);
|
||||
height: 0.1em;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.alert-dialog form {
|
||||
display: grid;
|
||||
gap: 2em
|
||||
}
|
||||
|
||||
.alert-dialog form .fields {
|
||||
display: grid;
|
||||
gap: .5em
|
||||
}
|
||||
|
||||
.alert-dialog form .fields pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.alert-dialog form .actions {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alert-dialog form .actions::before {
|
||||
content: "";
|
||||
flex-grow: 1111111111111;
|
||||
}`}
|
||||
</style>
|
||||
<form method='dialog' onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
currentAlert.resolve(cancelled ? null : currentAlert.type === 'confirm' ? true : value)
|
||||
}}>
|
||||
<div class="fields">
|
||||
<pre>{currentAlert.message}</pre>
|
||||
{currentAlert.type === 'prompt' && <input type='text' onInput={(event) => setValue(event.currentTarget.value)} />}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{/* This is here to capture, return key */}
|
||||
<button style="position:0;opacity:0;pointer-events:none"></button>
|
||||
|
||||
{currentAlert.type !== 'alert' && <button className='button' onClick={() => cancelled = true}>Cancel</button>}
|
||||
<button className='button active'>
|
||||
{
|
||||
currentAlert.type === 'alert' ? 'Ok'
|
||||
: currentAlert.type === 'confirm' ? 'Confirm'
|
||||
: currentAlert.type === 'prompt' ? 'Apply'
|
||||
: 'Ok'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
}
|
4
src/global.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
declare module '*.md' {
|
||||
var _: string;
|
||||
export default _;
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"name": "Watch on Odysee",
|
||||
"version": "1.7.4",
|
||||
"permissions": [
|
||||
"https://www.youtube.com/",
|
||||
"https://api.odysee.com/*",
|
||||
"https://lbry.tv/*",
|
||||
"https://odysee.com/*",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://www.youtube.com/*"
|
||||
],
|
||||
"js": [
|
||||
"scripts/ytContent.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"scripts/storageSetup.js",
|
||||
"scripts/tabOnUpdated.js"
|
||||
],
|
||||
"persistent": false
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "Watch on Odysee",
|
||||
"default_popup": "popup/popup.html"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
"popup.html",
|
||||
"tools/YTtoLBRY.html",
|
||||
"icons/lbry/lbry-logo.svg",
|
||||
"icons/lbry/odysee-logo.svg"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/wol/icon16.png",
|
||||
"48": "icons/wol/icon48.png",
|
||||
"128": "icons/wol/icon128.png"
|
||||
},
|
||||
"manifest_version": 2
|
||||
}
|
204
src/modules/crypto/index.ts
Normal file
|
@ -0,0 +1,204 @@
|
|||
import path from 'path'
|
||||
import type { DialogManager } from '../../components/dialogs'
|
||||
import { getExtensionSettingsAsync, setExtensionSetting, ytUrlResolversSettings } from "../../settings"
|
||||
import { getFileContent } from '../file'
|
||||
|
||||
async function generateKeys() {
|
||||
const keys = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
// Consider using a 4096-bit key for systems that require long-term security
|
||||
modulusLength: 384,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-1",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"]
|
||||
)
|
||||
|
||||
return {
|
||||
publicKey: await exportPublicKey(keys.publicKey),
|
||||
privateKey: await exportPrivateKey(keys.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPrivateKey(key: CryptoKey) {
|
||||
const exported = await crypto.subtle.exportKey(
|
||||
"pkcs8",
|
||||
key
|
||||
)
|
||||
return Buffer.from(exported).toString('base64')
|
||||
}
|
||||
|
||||
const publicKeyPrefix = `MEwwDQYJKoZIhvcNAQEBBQADOwAwOAIxA`
|
||||
const publicKeySuffix = `IDAQAB` //`wIDAQAB` `WIDAQAB`
|
||||
const publicKeyLength = 65
|
||||
async function exportPublicKey(key: CryptoKey) {
|
||||
const exported = await crypto.subtle.exportKey(
|
||||
"spki",
|
||||
key
|
||||
)
|
||||
const publicKey = Buffer.from(exported).toString('base64')
|
||||
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
|
||||
}
|
||||
|
||||
function importPrivateKey(base64: string) {
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(base64, 'base64'),
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-1",
|
||||
},
|
||||
true,
|
||||
["sign"]
|
||||
)
|
||||
}
|
||||
|
||||
export async function sign(data: string, privateKey: string) {
|
||||
return Buffer.from(await crypto.subtle.sign(
|
||||
{ name: "RSASSA-PKCS1-v1_5" },
|
||||
await importPrivateKey(privateKey),
|
||||
await crypto.subtle.digest({ name: 'SHA-1' }, Buffer.from(data))
|
||||
)).toString('base64')
|
||||
}
|
||||
|
||||
export function resetProfileSettings() {
|
||||
setExtensionSetting('publicKey', null)
|
||||
setExtensionSetting('privateKey', null)
|
||||
}
|
||||
|
||||
async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: string, data: T) {
|
||||
const settings = await getExtensionSettingsAsync()
|
||||
/* const urlResolverSettings = ytUrlResolversSettings[settings.urlResolver]
|
||||
if (!urlResolverSettings.signRequest) throw new Error() */
|
||||
|
||||
const url = new URL(ytUrlResolversSettings.madiatorFinder.href/* urlResolverSettings.href */)
|
||||
url.pathname = path.join(url.pathname, pathname)
|
||||
url.searchParams.set('data', JSON.stringify(data))
|
||||
|
||||
if (true/* requiresSignature */) {
|
||||
if (!settings.privateKey || !settings.publicKey)
|
||||
throw new Error('There is no profile.')
|
||||
|
||||
url.searchParams.set('keys', JSON.stringify({
|
||||
signature: await sign(url.searchParams.toString(), settings.privateKey!),
|
||||
publicKey: settings.publicKey
|
||||
}))
|
||||
}
|
||||
|
||||
const respond = await fetch(url.href, { method })
|
||||
|
||||
if (respond.ok) return respond.json()
|
||||
throw new Error((await respond.json()).message)
|
||||
}
|
||||
|
||||
export async function generateProfileAndSetNickname(dialogManager: DialogManager, overwrite = false) {
|
||||
let { publicKey, privateKey } = await getExtensionSettingsAsync()
|
||||
|
||||
let nickname
|
||||
while (true) {
|
||||
nickname = await dialogManager.prompt("Pick a nickname")
|
||||
if (nickname) break
|
||||
if (nickname === null) return
|
||||
await dialogManager.alert("Invalid nickname")
|
||||
}
|
||||
|
||||
try {
|
||||
if (overwrite || !privateKey || !publicKey) {
|
||||
resetProfileSettings()
|
||||
await generateKeys().then((keys) => {
|
||||
publicKey = keys.publicKey
|
||||
privateKey = keys.privateKey
|
||||
})
|
||||
setExtensionSetting('publicKey', publicKey)
|
||||
setExtensionSetting('privateKey', privateKey)
|
||||
}
|
||||
await apiRequest('POST', '/profile', { nickname })
|
||||
await dialogManager.alert(`Your nickname has been set to ${nickname}`)
|
||||
} catch (error: any) {
|
||||
resetProfileSettings()
|
||||
await dialogManager.alert(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeProfile(dialogManager: DialogManager) {
|
||||
try {
|
||||
if (!await dialogManager.confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return
|
||||
await apiRequest('POST', '/profile/purge', {})
|
||||
resetProfileSettings()
|
||||
await dialogManager.alert(`Your profile has been purged`)
|
||||
} catch (error: any) {
|
||||
await dialogManager.alert(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile() {
|
||||
let { publicKey, privateKey } = await getExtensionSettingsAsync()
|
||||
return (await apiRequest('GET', '/profile', { publicKey })) as { nickname: string, score: number, publickKey: string }
|
||||
}
|
||||
|
||||
export function friendlyPublicKey(publicKey: string | null) {
|
||||
// This is copy paste of Madiator Finder's friendly public key
|
||||
return `${publicKey?.substring(0, 32)}...`
|
||||
}
|
||||
|
||||
function download(data: string, filename: string, type: string) {
|
||||
const file = new Blob([data], { type: type })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
// Using callback here because there is no good solution for detecting cancel event
|
||||
export function inputKeyFile(callback: (file: File | null) => void) {
|
||||
const input = document.createElement("input")
|
||||
input.type = 'file'
|
||||
input.accept = '.wol-keys.json'
|
||||
input.click()
|
||||
input.addEventListener("change", () => callback(input.files?.[0] ?? null))
|
||||
}
|
||||
|
||||
interface ExportedProfileKeysFile {
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export async function exportProfileKeysAsFile() {
|
||||
const { publicKey, privateKey } = await getExtensionSettingsAsync()
|
||||
|
||||
const json = JSON.stringify({
|
||||
publicKey,
|
||||
privateKey
|
||||
})
|
||||
|
||||
download(json, `watch-on-lbry-profile-export-${friendlyPublicKey(publicKey)}.wol-keys.json`, 'application/json')
|
||||
}
|
||||
|
||||
export async function importProfileKeysFromFile(dialogManager: DialogManager, file: File) {
|
||||
try {
|
||||
let settings = await getExtensionSettingsAsync()
|
||||
if (settings.publicKey && !await dialogManager.confirm(
|
||||
"This will overwrite your old keypair." +
|
||||
"\nStill wanna continue?\n\n" +
|
||||
"NOTE: Without keypair you can't purge your data online.\n" +
|
||||
"So if you wish to purge, please use purging instead."
|
||||
)) return false
|
||||
const json = await getFileContent(file)
|
||||
if (!json) return false
|
||||
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
|
||||
setExtensionSetting('publicKey', publicKey)
|
||||
setExtensionSetting('privateKey', privateKey)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
await dialogManager.alert(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
15
src/modules/file/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''))
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort()
|
||||
reject(new DOMException(`Could not read ${file.name}`))
|
||||
})
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
83
src/modules/yt/index.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
interface YtExportedJsonSubscription {
|
||||
id: string
|
||||
etag: string
|
||||
title: string
|
||||
snippet: {
|
||||
description: string
|
||||
resourceId: {
|
||||
channelId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml')
|
||||
opmlContents = ''
|
||||
return Array.from(opml.querySelectorAll('outline > outline'))
|
||||
.map(outline => outline.getAttribute('xmlUrl'))
|
||||
.filter((url): url is string => !!url)
|
||||
.map(url => getChannelId(url))
|
||||
.filter((url): url is string => !!url) // we don't want it if it's empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions JSON file
|
||||
*
|
||||
* @param jsonContents a JSON file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromJson(jsonContents: string): string[] {
|
||||
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents)
|
||||
jsonContents = ''
|
||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions CSV file
|
||||
*
|
||||
* @param csvContent a CSV file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromCsv(csvContent: string): string[] {
|
||||
const rows = csvContent.split('\n')
|
||||
csvContent = ''
|
||||
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the channelID from a YT URL.
|
||||
*
|
||||
* Handles these two types of YT URLs:
|
||||
* * /feeds/videos.xml?channel_id=*
|
||||
* * /channel/*
|
||||
*/
|
||||
export function getChannelId(channelURL: string) {
|
||||
const match = channelURL.match(/channel\/([^\s?]*)/)
|
||||
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id')
|
||||
}
|
||||
|
||||
export function parseYouTubeURLTimeString(timeString: string) {
|
||||
const signs = timeString.replace(/[0-9]/g, '')
|
||||
if (signs.length === 0) return null
|
||||
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
|
||||
let total = 0
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
let t = parseInt(numbers[i])
|
||||
switch (signs[i]) {
|
||||
case 'd': t *= 24
|
||||
case 'h': t *= 60
|
||||
case 'm': t *= 60
|
||||
case 's': break
|
||||
default: return null
|
||||
}
|
||||
total += t
|
||||
}
|
||||
return total
|
||||
}
|
87
src/modules/yt/urlCache.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
// This should only work in background
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
if (typeof chrome.extension === 'undefined') throw new Error("YT urlCache can only be accessed from extension windows and service-workers.")
|
||||
|
||||
if (typeof self.indexedDB !== 'undefined') {
|
||||
const openRequest = indexedDB.open("yt-url-resolver-cache")
|
||||
openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt"))
|
||||
|
||||
// Delete Expired
|
||||
openRequest.addEventListener('success', () => {
|
||||
db = openRequest.result
|
||||
clearExpired()
|
||||
})
|
||||
}
|
||||
else console.warn(`IndexedDB not supported`)
|
||||
|
||||
async function clearExpired() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db) throw new Error(`IDBDatabase not defined.`)
|
||||
const transaction = db.transaction("store", "readwrite")
|
||||
const range = IDBKeyRange.upperBound(new Date())
|
||||
|
||||
const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range)
|
||||
expireAtCursorRequest.addEventListener('error', () => reject(expireAtCursorRequest.error))
|
||||
expireAtCursorRequest.addEventListener('success', () => {
|
||||
try {
|
||||
const expireCursor = expireAtCursorRequest.result
|
||||
if (!expireCursor) return
|
||||
expireCursor.delete()
|
||||
expireCursor.continue()
|
||||
resolve()
|
||||
}
|
||||
catch (ex) {
|
||||
reject(ex)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const store = db?.transaction("store", "readwrite").objectStore("store")
|
||||
if (!store) return resolve()
|
||||
const request = store.clear()
|
||||
request.addEventListener('success', () => resolve())
|
||||
request.addEventListener('error', () => reject(request.error))
|
||||
})
|
||||
}
|
||||
|
||||
async function put(url: string | null, id: string): Promise<void> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const store = db?.transaction("store", "readwrite").objectStore("store")
|
||||
if (!store) return resolve()
|
||||
const expireAt = !url ? new Date(Date.now() + 1 * 60 * 60 * 1000) : new Date(Date.now() + 15 * 24 * 60 * 60 * 1000)
|
||||
const request = store.put({ value: url, expireAt }, id)
|
||||
console.log('caching', id, url, 'until:', expireAt)
|
||||
request.addEventListener('success', () => resolve())
|
||||
request.addEventListener('error', () => reject(request.error))
|
||||
})
|
||||
}
|
||||
|
||||
// string means there is cache of lbrypathname
|
||||
// null means there is cache of that id has no lbrypathname
|
||||
// undefined means there is no cache
|
||||
async function get(id: string): Promise<string | null | undefined> {
|
||||
const response = (await new Promise((resolve, reject) => {
|
||||
const store = db?.transaction("store", "readonly").objectStore("store")
|
||||
if (!store) return reject(`Can't find object store.`)
|
||||
|
||||
const request = store.get(id)
|
||||
request.addEventListener('success', () => resolve(request.result))
|
||||
request.addEventListener('error', () => reject(request.error))
|
||||
}) as { value: string | null, expireAt: Date } | undefined)
|
||||
|
||||
if (response === undefined) return undefined
|
||||
if (response.expireAt <= new Date()) {
|
||||
await clearExpired()
|
||||
return undefined
|
||||
}
|
||||
console.log('cache found', id, response.value)
|
||||
return response.value
|
||||
}
|
||||
|
||||
export const lbryUrlCache = { put, get, clearAll }
|
||||
|
76
src/modules/yt/urlResolve.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { chunk } from "lodash"
|
||||
import path from "path"
|
||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../../settings"
|
||||
import { lbryUrlCache } from "./urlCache"
|
||||
|
||||
const QUERY_CHUNK_SIZE = 100
|
||||
|
||||
export type YtUrlResolveItem = { type: 'video' | 'channel', id: string }
|
||||
type Results = Record<string, YtUrlResolveItem>
|
||||
type Paramaters = YtUrlResolveItem[]
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
channels?: Record<string, string>
|
||||
videos?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
|
||||
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync()
|
||||
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||
|
||||
async function requestChunk(params: Paramaters) {
|
||||
const results: Results = {}
|
||||
|
||||
// Check for cache first, add them to the results if there are any cache
|
||||
// And remove them from the params, so we dont request for them
|
||||
params = (await Promise.all(params.map(async (item) => {
|
||||
const cachedLbryUrl = await lbryUrlCache.get(item.id)
|
||||
|
||||
// Cache can be null, if there is no lbry url yet
|
||||
if (cachedLbryUrl !== undefined) {
|
||||
// Null values shouldn't be in the results
|
||||
if (cachedLbryUrl !== null) results[item.id] = { id: cachedLbryUrl, type: item.type }
|
||||
return null
|
||||
}
|
||||
|
||||
// No cache found
|
||||
return item
|
||||
}))).filter((o) => o) as Paramaters
|
||||
|
||||
if (params.length === 0) return results
|
||||
|
||||
const url = new URL(`${urlResolverSetting.href}`)
|
||||
url.pathname = path.join(url.pathname, '/resolve')
|
||||
url.searchParams.set('video_ids', params.filter((item) => item.type === 'video').map((item) => item.id).join(','))
|
||||
url.searchParams.set('channel_ids', params.filter((item) => item.type === 'channel').map((item) => item.id).join(','))
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||
if (apiResponse.ok) {
|
||||
const response: ApiResponse = await apiResponse.json()
|
||||
for (const item of params) {
|
||||
const lbryUrl = (item.type === 'channel' ? response.data.channels : response.data.videos)?.[item.id]?.replaceAll('#', ':') ?? null
|
||||
// we cache it no matter if its null or not
|
||||
await lbryUrlCache.put(lbryUrl, item.id)
|
||||
|
||||
if (lbryUrl) results[item.id] = { id: lbryUrl, type: item.type }
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
const results: Results = {}
|
||||
const chunks = chunk(params, QUERY_CHUNK_SIZE)
|
||||
|
||||
let i = 0
|
||||
if (progressCallback) progressCallback(0)
|
||||
for (const chunk of chunks) {
|
||||
if (progressCallback) progressCallback(++i / (chunks.length + 1))
|
||||
Object.assign(results, await requestChunk(chunk))
|
||||
}
|
||||
|
||||
if (progressCallback) progressCallback(1)
|
||||
return results
|
||||
}
|
|
@ -3,4 +3,4 @@
|
|||
1. Go to https://takeout.google.com/settings/takeout
|
||||
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
|
||||
3. Go through the process and create the export
|
||||
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
||||
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
17
src/pages/YTtoLBRY/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<script src="main.tsx" charset="utf-8" defer></script>
|
||||
</head>
|
||||
|
||||
<body id="page">
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
80
src/pages/YTtoLBRY/main.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { getFileContent } from '../../modules/file'
|
||||
import { getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../../modules/yt'
|
||||
import { resolveById } from '../../modules/yt/urlResolve'
|
||||
import { targetPlatformSettings, useExtensionSettings } from '../../settings'
|
||||
import readme from './README.md'
|
||||
|
||||
async function getSubscribedChannelIdsFromFile(file: File) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
const content = await getFileContent(file)
|
||||
switch (ext) {
|
||||
case 'xml':
|
||||
case 'opml':
|
||||
return getSubsFromOpml(content)
|
||||
case 'csv':
|
||||
return getSubsFromCsv(content)
|
||||
default:
|
||||
return getSubsFromJson(content)
|
||||
}
|
||||
}
|
||||
|
||||
async function findChannels(channelIds: string[], progressCallback: Parameters<typeof resolveById>['1']) {
|
||||
const resultItems = await resolveById(channelIds.map((channelId) => ({ id: channelId, type: 'channel' })), progressCallback)
|
||||
return Object.values(resultItems).map((item) => item.id)
|
||||
}
|
||||
|
||||
function Conversion() {
|
||||
const [file, setFile] = useState(null as File | null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [lbryChannelIds, setLbryChannels] = useState([] as Awaited<ReturnType<typeof findChannels>>)
|
||||
const settings = useExtensionSettings()
|
||||
|
||||
let loading = progress > 0 && progress !== 1
|
||||
|
||||
return <div className='conversion'>
|
||||
<form onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (file) setLbryChannels(await findChannels(await getSubscribedChannelIdsFromFile(file), (progress) => setProgress(progress)))
|
||||
}}
|
||||
>
|
||||
<div class="fields">
|
||||
<label for="conversion-file">Select YouTube Subscriptions</label>
|
||||
<input id="conversion-file" type='file' onChange={event => setFile(event.currentTarget.files?.length ? event.currentTarget.files[0] : null)} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button className={`button ${!file || progress > 0 ? '' : 'active'}`} disabled={!file || loading}>
|
||||
{loading ? `${(progress * 100).toFixed(1)}%` : 'Start Conversion!'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{
|
||||
progress === 1 &&
|
||||
<div class="results">
|
||||
<b>Results:</b>
|
||||
{
|
||||
lbryChannelIds.length > 0
|
||||
? lbryChannelIds.map((lbryChannelId) =>
|
||||
<article class="result-item">
|
||||
<a href={`${targetPlatformSettings[settings.targetPlatform].domainPrefix}${lbryChannelId}`}>{lbryChannelId}</a>
|
||||
</article>)
|
||||
: <span class="error">No Result</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
function YTtoLBRY() {
|
||||
return <main>
|
||||
<Conversion />
|
||||
<aside class="help">
|
||||
<iframe allowFullScreen
|
||||
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
|
||||
<section dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</aside>
|
||||
</main>
|
||||
}
|
||||
|
||||
render(<YTtoLBRY />, document.getElementById('root')!)
|
86
src/pages/YTtoLBRY/style.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
main {
|
||||
display: flex;
|
||||
flex-wrap: wrap-reverse;
|
||||
min-height: 100vh;
|
||||
gap: 2em;
|
||||
padding: 1em;
|
||||
overflow-wrap: break-word;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.conversion,
|
||||
.help {
|
||||
flex: 1;
|
||||
min-width: 40em;
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.conversion>*,
|
||||
.help>* {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: .5em;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.help iframe {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.conversion {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.conversion form {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.conversion form .fields {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions::after {
|
||||
content: "";
|
||||
flex-grow: 100000000000000000;
|
||||
}
|
||||
|
||||
.conversion form button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversion form button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conversion .results {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.help a,
|
||||
.conversion .results a {
|
||||
background: var(--color-gradient-0);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.conversion .results .result-item {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
14
src/pages/import/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="main.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
62
src/pages/import/main.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { createDialogManager, Dialogs } from '../../components/dialogs'
|
||||
import { importProfileKeysFromFile, inputKeyFile } from '../../modules/crypto'
|
||||
|
||||
export async function openImportPopup() {
|
||||
const importPopupWindow = open(
|
||||
'/pages/import/index.html',
|
||||
'Import Profile',
|
||||
[
|
||||
`height=${Math.max(document.body.clientHeight, screen.height * .5)}`,
|
||||
`width=${document.body.clientWidth}`,
|
||||
`toolbar=0,menubar=0,location=0`,
|
||||
`top=${screenY}`,
|
||||
`left=${screenX}`
|
||||
].join(','))
|
||||
close()
|
||||
importPopupWindow?.focus()
|
||||
}
|
||||
|
||||
function ImportPage() {
|
||||
const [loading, updateLoading] = useState(() => false)
|
||||
|
||||
async function loads<T>(operation: Promise<T>) {
|
||||
try {
|
||||
updateLoading(true)
|
||||
await operation
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
updateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function importProfile() {
|
||||
inputKeyFile(async (file) => file && await loads(
|
||||
importProfileKeysFromFile(dialogManager, file)
|
||||
.then((success) => success && (location.pathname = '/pages/popup/index.html'))
|
||||
))
|
||||
}
|
||||
|
||||
const dialogManager = createDialogManager()
|
||||
|
||||
return <div id='popup'>
|
||||
<Dialogs manager={dialogManager} />
|
||||
<main>
|
||||
<section>
|
||||
<label>Import your profile</label>
|
||||
<p>Import your unique keypair.</p>
|
||||
<div className='options'>
|
||||
<a onClick={() => importProfile()} className={`button`}>Import</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{loading && <div class="overlay">
|
||||
<span>Loading...</span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
render(<ImportPage />, document.getElementById('root')!)
|
35
src/pages/import/style.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
header {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
padding: .75em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(19, 19, 19, 0.5);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
padding: 1.5em 0.5em;
|
||||
}
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: .75em;
|
||||
}
|
||||
|
||||
section label {
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#popup {
|
||||
width: 35em;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin: auto;
|
||||
}
|
14
src/pages/popup/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="main.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
72
src/pages/popup/main.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { createDialogManager, Dialogs } from '../../components/dialogs'
|
||||
import { lbryUrlCache } from '../../modules/yt/urlCache'
|
||||
import { setExtensionSetting, targetPlatformSettings, useExtensionSettings } from '../../settings'
|
||||
|
||||
function WatchOnLbryPopup(params: {}) {
|
||||
const { redirect } = useExtensionSettings()
|
||||
let [loading, updateLoading] = useState(() => false)
|
||||
|
||||
const dialogManager = createDialogManager()
|
||||
|
||||
|
||||
async function loads<T>(operation: Promise<T>) {
|
||||
try {
|
||||
updateLoading(true)
|
||||
await operation
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
updateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <div id='popup'>
|
||||
<Dialogs manager={dialogManager} />
|
||||
{
|
||||
<header>
|
||||
<section>
|
||||
<img id="logo" src={targetPlatformSettings.odysee.button.icon}></img>
|
||||
<label>Watch on Odysee</label>
|
||||
</section>
|
||||
</header>
|
||||
}
|
||||
{
|
||||
<main>
|
||||
<section>
|
||||
<label>Pick a mode:</label>
|
||||
<div className='options'>
|
||||
<a onClick={() => setExtensionSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
||||
Redirect
|
||||
</a>
|
||||
<a onClick={() => setExtensionSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
||||
Show a button
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<a onClick={() => loads(lbryUrlCache.clearAll().then(() => dialogManager.alert("Cleared Cache!")))} className={`button active`}>
|
||||
Clear Resolver Cache
|
||||
</a>
|
||||
</section>
|
||||
<section>
|
||||
<label>Tools</label>
|
||||
<a target='_blank' href='/pages/YTtoLBRY/index.html' className={`filled`}>
|
||||
Subscription Converter
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
{loading && <div class="overlay">
|
||||
<span>Loading...</span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function renderPopup() {
|
||||
render(<WatchOnLbryPopup />, document.getElementById('root')!)
|
||||
}
|
||||
|
||||
renderPopup()
|
48
src/pages/popup/style.css
Normal file
|
@ -0,0 +1,48 @@
|
|||
header {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
padding: .75em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(19, 19, 19, 0.5);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
#logo {
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
padding: 1.5em 0.5em;
|
||||
}
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: .75em;
|
||||
}
|
||||
|
||||
section>label {
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
section>.options {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
||||
#popup {
|
||||
width: 35em;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.purge-aaaaaaa {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<script src="popup.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="root" />
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,4 +0,0 @@
|
|||
.radio-label
|
||||
font-size: 1.1rem
|
||||
margin: 15px auto
|
||||
display: block
|
|
@ -1,30 +0,0 @@
|
|||
import { h, render } from 'preact';
|
||||
|
||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio';
|
||||
import { redirectDomains } from '../common/settings';
|
||||
import { useLbrySettings } from '../common/useSettings';
|
||||
|
||||
import './popup.sass';
|
||||
|
||||
/** Utilty to set a setting in the browser */
|
||||
const setSetting = (setting: string, value: any) => chrome.storage.local.set({ [setting]: value });
|
||||
|
||||
/** Gets all the options for redirect destinations as selection options */
|
||||
const redirectOptions: SelectionOption[] = Object.entries(redirectDomains)
|
||||
.map(([value, { display }]) => ({ value, display }));
|
||||
|
||||
function WatchOnLbryPopup() {
|
||||
const { enabled, redirect } = useLbrySettings();
|
||||
|
||||
return <div className='container'>
|
||||
<label className='radio-label'>Enable Redirection:</label>
|
||||
<ButtonRadio value={enabled ? 'YES' : 'NO'} options={['YES', 'NO']}
|
||||
onChange={enabled => setSetting('enabled', enabled.toLowerCase() === 'yes')} />
|
||||
<label className='radio-label'>Other useful tools:</label>
|
||||
<a href='/tools/YTtoLBRY.html' target='_blank'>
|
||||
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
|
||||
render(<WatchOnLbryPopup />, document.getElementById('root')!);
|
28
src/scripts/background.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { resolveById } from "../modules/yt/urlResolve"
|
||||
|
||||
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}
|
||||
|
||||
chrome.runtime.onMessage.addListener(({ json }, sender, sendResponse) => {
|
||||
function resolve(result: Awaited<ReturnType<typeof resolveById>>) {
|
||||
sendResponse(JSON.stringify(result))
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const params: Parameters<typeof resolveById> = JSON.parse(json)
|
||||
// Don't create a new Promise for same ID until on going one is over.
|
||||
const promise = onGoingLbryPathnameRequest[json] ?? (onGoingLbryPathnameRequest[json] = resolveById(...params))
|
||||
console.log('lbrypathname request', params, await promise)
|
||||
resolve(await promise)
|
||||
} catch (error) {
|
||||
sendResponse('error')
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
delete onGoingLbryPathnameRequest[json]
|
||||
}
|
||||
})()
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => changeInfo.status === 'complete' && chrome.tabs.sendMessage(tabId, { message: 'url-changed' }))
|
|
@ -1,28 +0,0 @@
|
|||
import { DEFAULT_SETTINGS, LbrySettings, getSettingsAsync } from '../common/settings';
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
const settings = await getSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof LbrySettings>);
|
||||
|
||||
// get all the values that aren't set and use them as a change set
|
||||
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof LbrySettings, LbrySettings[keyof LbrySettings]]>)
|
||||
.filter(([k]) => settings[k] === null || settings[k] === undefined);
|
||||
|
||||
// fix our local var and set it in storage for later
|
||||
if (invalidEntries.length > 0) {
|
||||
const changeSet = Object.fromEntries(invalidEntries);
|
||||
Object.assign(settings, changeSet);
|
||||
chrome.storage.local.set(changeSet);
|
||||
}
|
||||
|
||||
chrome.browserAction.setBadgeText({ text: settings.enabled ? 'ON' : 'OFF' });
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.enabled) return;
|
||||
chrome.browserAction.setBadgeText({ text: changes.enabled.newValue ? 'ON' : 'OFF' });
|
||||
});
|
||||
|
||||
|
||||
chrome.runtime.onStartup.addListener(initSettings);
|
||||
chrome.runtime.onInstalled.addListener(initSettings);
|
|
@ -1,67 +0,0 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url';
|
||||
import { getSettingsAsync, LbrySettings } from '../common/settings';
|
||||
import { YTDescriptor, ytService } from '../common/yt';
|
||||
export interface UpdateContext {
|
||||
descriptor: YTDescriptor
|
||||
/** LBRY URL fragment */
|
||||
url: string
|
||||
enabled: boolean
|
||||
redirect: LbrySettings['redirect']
|
||||
}
|
||||
|
||||
async function resolveYT(descriptor: YTDescriptor) {
|
||||
const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]);
|
||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
||||
if (segments.length === 0) return;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
const urlCache: Record<string, string | undefined> = {};
|
||||
|
||||
async function ctxFromURL(url: string): Promise<UpdateContext | void> {
|
||||
if (!url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
||||
url = new URL(url).href;
|
||||
const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect');
|
||||
const descriptor = ytService.getId(url);
|
||||
if (!descriptor) return; // couldn't get the ID, so we're done
|
||||
|
||||
const res = url in urlCache ? urlCache[url] : await resolveYT(descriptor);
|
||||
urlCache[url] = res;
|
||||
if (!res) return; // couldn't find it on lbry, so we're done
|
||||
|
||||
return { descriptor, url: res, enabled, redirect };
|
||||
}
|
||||
|
||||
// handles lbry.tv -> lbry app redirect
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
|
||||
const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect');
|
||||
if (!enabled || redirect !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://lbry.tv/')) return;
|
||||
|
||||
const url = appRedirectUrl(tabUrl, { encode: true });
|
||||
if (!url) return;
|
||||
chrome.tabs.update(tabId, { url });
|
||||
alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed
|
||||
chrome.tabs.executeScript(tabId, {
|
||||
code: `if (window.history.length === 1) {
|
||||
window.close();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
document.querySelectorAll('video').forEach(v => v.pause());
|
||||
`
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResponse) => {
|
||||
ctxFromURL(url).then(ctx => {
|
||||
sendResponse(ctx);
|
||||
})
|
||||
return true;
|
||||
})
|
||||
|
||||
// relay youtube link changes to the content script
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
|
||||
if (!changeInfo.url || !url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
||||
|
||||
ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
|
||||
});
|
|
@ -1,123 +1,182 @@
|
|||
import { h, JSX, render } from 'preact';
|
||||
import { h, render } from 'preact'
|
||||
import { parseYouTubeURLTimeString } from '../modules/yt'
|
||||
import type { resolveById } from '../modules/yt/urlResolve'
|
||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../settings'
|
||||
|
||||
import { parseProtocolUrl } from '../common/lbry-url';
|
||||
import { LbrySettings, redirectDomains } from '../common/settings';
|
||||
import { YTDescriptor, ytService } from '../common/yt';
|
||||
import { UpdateContext } from './tabOnUpdated';
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
||||
|
||||
interface UpdaterOptions {
|
||||
/** invoked if a redirect should be performed */
|
||||
onRedirect?(ctx: UpdateContext): void
|
||||
/** invoked if a URL is found */
|
||||
onURL?(ctx: UpdateContext): void
|
||||
interface WatchOnLbryButtonParameters {
|
||||
targetPlatform?: TargetPlatform
|
||||
lbryPathname?: string
|
||||
time?: number
|
||||
}
|
||||
|
||||
interface ButtonSettings {
|
||||
text: string
|
||||
icon: string
|
||||
style?: JSX.CSSProperties
|
||||
interface Target {
|
||||
platfrom: TargetPlatform
|
||||
lbryPathname: string
|
||||
time: number | null
|
||||
}
|
||||
|
||||
const buttonSettings: Record<LbrySettings['redirect'], ButtonSettings> = {
|
||||
app: { text: 'Watch on LBRY', icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') },
|
||||
'lbry.tv': { text: 'Watch on LBRY', icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') },
|
||||
'odysee' : {
|
||||
text: 'Watch on Odysee', icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg'),
|
||||
style: { backgroundColor: '#1e013b' },
|
||||
},
|
||||
};
|
||||
function WatchOnLbryButton({ targetPlatform, lbryPathname, time }: WatchOnLbryButtonParameters) {
|
||||
if (!lbryPathname || !targetPlatform) return null
|
||||
|
||||
function pauseVideo() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
|
||||
const url = new URL(`${targetPlatform.domainPrefix}${lbryPathname}`)
|
||||
if (time) url.searchParams.set('t', time.toFixed(0))
|
||||
|
||||
function openApp(url: string) {
|
||||
pauseVideo();
|
||||
location.assign(url);
|
||||
}
|
||||
|
||||
async function resolveYT(descriptor: YTDescriptor) {
|
||||
const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]);
|
||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
||||
if (segments.length === 0) return;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
||||
async function handleURLChange(ctx: UpdateContext, { onRedirect, onURL }: UpdaterOptions): Promise<void> {
|
||||
if (onURL) onURL(ctx);
|
||||
if (ctx.enabled && onRedirect) onRedirect(ctx);
|
||||
}
|
||||
|
||||
/** Returns a mount point for the button */
|
||||
async function findMountPoint(): Promise<HTMLDivElement | void> {
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t));
|
||||
let ownerBar = document.querySelector('ytd-video-owner-renderer');
|
||||
for (let i = 0; !ownerBar && i < 50; i++) {
|
||||
await sleep(200);
|
||||
ownerBar = document.querySelector('ytd-video-owner-renderer');
|
||||
}
|
||||
|
||||
if (!ownerBar) return;
|
||||
const div = document.createElement('div');
|
||||
div.style.display = 'flex';
|
||||
ownerBar.insertAdjacentElement('afterend', div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function WatchOnLbryButton({ redirect = 'app', url }: { redirect?: LbrySettings['redirect'], url?: string }) {
|
||||
if (!url) return null;
|
||||
const domain = redirectDomains[redirect];
|
||||
const buttonSetting = buttonSettings[redirect];
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<a href={domain.prefix + url} onClick={pauseVideo} role='button'
|
||||
children={<div>
|
||||
<img src={buttonSetting.icon} height={10} width={14}
|
||||
style={{ marginRight: 12, transform: 'scale(1.75)' }} />
|
||||
{buttonSetting.text}
|
||||
</div>}
|
||||
<a href={`${url.toString()}`} role='button'
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: '#075656',
|
||||
backgroundColor: targetPlatform.theme,
|
||||
backgroundImage: targetPlatform.theme,
|
||||
fontWeight: 'bold',
|
||||
border: '0',
|
||||
color: 'whitesmoke',
|
||||
padding: '10px 16px',
|
||||
marginRight: '5px',
|
||||
marginRight: '4px',
|
||||
fontSize: '14px',
|
||||
textDecoration: 'none',
|
||||
...buttonSetting.style,
|
||||
}} />
|
||||
</div>;
|
||||
...targetPlatform.button.style?.button,
|
||||
}}>
|
||||
<img src={targetPlatform.button.icon} height={16}
|
||||
style={{ transform: 'scale(1.5)', ...targetPlatform.button.style?.icon }} />
|
||||
<span>{targetPlatform.button.text}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
function updateButton(mountPoint: HTMLDivElement, target: Target | null): void {
|
||||
if (!target) return render(<WatchOnLbryButton />, mountPoint)
|
||||
render(<WatchOnLbryButton targetPlatform={target.platfrom} lbryPathname={target.lbryPathname} time={target.time ?? undefined} />, mountPoint)
|
||||
}
|
||||
|
||||
const mountPointPromise = findMountPoint();
|
||||
/** Returns a mount point for the button */
|
||||
async function findButtonMountPoint(): Promise<HTMLDivElement> {
|
||||
const id = 'watch-on-lbry-button-container'
|
||||
let mountBefore: HTMLDivElement | null = null
|
||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||
const exits: HTMLDivElement | null = document.querySelector(`#${id}`)
|
||||
if (exits) return exits
|
||||
while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200)
|
||||
|
||||
const handle = (ctx: UpdateContext) => handleURLChange(ctx, {
|
||||
async onURL({ descriptor: { type }, url, redirect }) {
|
||||
const mountPoint = await mountPointPromise;
|
||||
if (type !== 'video' || !mountPoint) return;
|
||||
render(<WatchOnLbryButton url={url} redirect={redirect} />, mountPoint);
|
||||
},
|
||||
onRedirect({ redirect, url }) {
|
||||
const domain = redirectDomains[redirect];
|
||||
if (redirect === 'app') return openApp(domain.prefix + url);
|
||||
location.replace(domain.prefix + url);
|
||||
},
|
||||
});
|
||||
const div = document.createElement('div')
|
||||
div.id = id
|
||||
div.style.display = 'flex'
|
||||
div.style.alignItems = 'center'
|
||||
mountBefore.parentElement?.insertBefore(div, mountBefore)
|
||||
|
||||
// handle the location on load of the page
|
||||
chrome.runtime.sendMessage({ url: location.href }, async (ctx: UpdateContext) => handle(ctx));
|
||||
return div
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
|
||||
* history.pushState changes from a content script
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => {
|
||||
mountPointPromise.then(mountPoint => mountPoint && render(<WatchOnLbryButton />, mountPoint))
|
||||
if (!ctx.url) return;
|
||||
handle(ctx);
|
||||
});
|
||||
async function findVideoElement() {
|
||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||
let videoElement: HTMLVideoElement | null = null
|
||||
while (!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200)
|
||||
return videoElement
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.redirect) return;
|
||||
chrome.runtime.sendMessage({ url: location.href }, async (ctx: UpdateContext) => handle(ctx));
|
||||
});
|
||||
// We should get this from background, so the caching works and we don't get errors in the future if yt decides to impliment CORS
|
||||
async function requestResolveById(...params: Parameters<typeof resolveById>): ReturnType<typeof resolveById> {
|
||||
const json = await new Promise<string | null | 'error'>((resolve) => chrome.runtime.sendMessage({ json: JSON.stringify(params) }, resolve))
|
||||
if (json === 'error') throw new Error("Background error.")
|
||||
return json ? JSON.parse(json) : null
|
||||
}
|
||||
|
||||
// Start
|
||||
(async () => {
|
||||
const settings = await getExtensionSettingsAsync()
|
||||
let updater: (() => Promise<void>)
|
||||
|
||||
// Listen Settings Change
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== 'local') return
|
||||
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])))
|
||||
if (changes.redirect) await onModeChange()
|
||||
await updater()
|
||||
})
|
||||
|
||||
/*
|
||||
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
|
||||
* history.pushState changes from a content script
|
||||
*/
|
||||
// Listen URL Change
|
||||
chrome.runtime.onMessage.addListener(({ message }, sender) => message === 'url-changed' && updater())
|
||||
|
||||
async function getTargetByURL(url: URL) {
|
||||
if (url.pathname === '/watch' && url.searchParams.has('v')) {
|
||||
const videoId = url.searchParams.get('v')!
|
||||
const result = await requestResolveById([{ id: videoId, type: 'video' }])
|
||||
const target: Target | null = result?.[videoId] ? { lbryPathname: result[videoId].id, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
|
||||
|
||||
return target
|
||||
}
|
||||
else if (url.pathname.startsWith('/channel/')) {
|
||||
await requestResolveById([{ id: url.pathname.substring("/channel/".length), type: 'channel' }])
|
||||
}
|
||||
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/')) {
|
||||
// We have to download the page content again because these parts of the page are not responsive
|
||||
// yt front end sucks anyway
|
||||
const content = await (await fetch(location.href)).text()
|
||||
const prefix = `https://www.youtube.com/feeds/videos.xml?channel_id=`
|
||||
const suffix = `"`
|
||||
const startsAt = content.indexOf(prefix) + prefix.length
|
||||
const endsAt = content.indexOf(suffix, startsAt)
|
||||
await requestResolveById([{ id: content.substring(startsAt, endsAt), type: 'channel' }])
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function redirectTo({ lbryPathname, platfrom, time }: Target) {
|
||||
const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`)
|
||||
|
||||
if (time) url.searchParams.set('t', time.toFixed(0))
|
||||
|
||||
findVideoElement().then((videoElement) => {
|
||||
videoElement.addEventListener('play', () => videoElement.pause(), { once: true })
|
||||
videoElement.pause()
|
||||
})
|
||||
|
||||
location.replace(url.toString())
|
||||
}
|
||||
|
||||
let removeVideoTimeUpdateListener: (() => void) | null = null
|
||||
async function onModeChange() {
|
||||
let target: Target | null = null
|
||||
if (settings.redirect)
|
||||
updater = async () => {
|
||||
const url = new URL(location.href)
|
||||
target = await getTargetByURL(url)
|
||||
if (!target) return
|
||||
target.time = url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null
|
||||
redirectTo(target)
|
||||
}
|
||||
else {
|
||||
const mountPoint = await findButtonMountPoint()
|
||||
const videoElement = await findVideoElement()
|
||||
|
||||
const getTime = () => videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null
|
||||
|
||||
const onTimeUpdate = () => target && updateButton(mountPoint, Object.assign(target, { time: getTime() }))
|
||||
removeVideoTimeUpdateListener?.call(null)
|
||||
videoElement.addEventListener('timeupdate', onTimeUpdate)
|
||||
removeVideoTimeUpdateListener = () => videoElement.removeEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
updater = async () => {
|
||||
const url = new URL(location.href)
|
||||
target = await getTargetByURL(url)
|
||||
if (target) target.time = getTime()
|
||||
updateButton(mountPoint, target)
|
||||
}
|
||||
}
|
||||
await updater()
|
||||
}
|
||||
|
||||
await onModeChange()
|
||||
})()
|
2
src/service-worker-entry-point.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import './settings/background'
|
||||
import './scripts/background'
|
33
src/settings/background.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '../settings'
|
||||
|
||||
// This is for manifest v2 and v3
|
||||
const chromeAction = chrome.action ?? chrome.browserAction
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
let settings = await getExtensionSettingsAsync()
|
||||
|
||||
// get all the values that aren't set and use them as a change set
|
||||
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof ExtensionSettings, ExtensionSettings[keyof ExtensionSettings]]>)
|
||||
.filter(([k]) => settings[k] === undefined || settings[k] === null)
|
||||
|
||||
// fix our local var and set it in storage for later
|
||||
if (invalidEntries.length > 0) {
|
||||
const changeSet = Object.fromEntries(invalidEntries)
|
||||
chrome.storage.local.set(changeSet)
|
||||
settings = await getExtensionSettingsAsync()
|
||||
}
|
||||
|
||||
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
||||
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
||||
|
||||
chromeAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.redirect) return
|
||||
chromeAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' })
|
||||
})
|
||||
|
||||
chrome.runtime.onStartup.addListener(initSettings)
|
||||
chrome.runtime.onInstalled.addListener(initSettings)
|
132
src/settings/index.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import type { JSX } from "preact"
|
||||
import { useEffect, useReducer } from "preact/hooks"
|
||||
|
||||
export interface ExtensionSettings {
|
||||
redirect: boolean
|
||||
targetPlatform: TargetPlatformName
|
||||
urlResolver: YTUrlResolverName
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ExtensionSettings = {
|
||||
redirect: true,
|
||||
targetPlatform: 'odysee',
|
||||
urlResolver: 'odyseeApi'
|
||||
}
|
||||
|
||||
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
||||
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
||||
}
|
||||
|
||||
/** Utilty to set a setting in the browser */
|
||||
export const setExtensionSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
|
||||
|
||||
|
||||
/**
|
||||
* A hook to read the settings from local storage
|
||||
*
|
||||
* @param defaultSettings the default value. Must have all relevant keys present and should not change
|
||||
*/
|
||||
function useSettings(defaultSettings: ExtensionSettings) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<ExtensionSettings>) => ({ ...state, ...nstate }), defaultSettings)
|
||||
const settingsKeys = Object.keys(defaultSettings)
|
||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||
useEffect(() => {
|
||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== 'local') return
|
||||
const changeEntries = Object.keys(changes).filter((key) => settingsKeys.includes(key)).map((key) => [key, changes[key].newValue])
|
||||
if (changeEntries.length === 0) return // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeEntries))
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener(changeListener)
|
||||
chrome.storage.local.get(settingsKeys, async (settings) => dispatch(settings))
|
||||
|
||||
return () => chrome.storage.onChanged.removeListener(changeListener)
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/** A hook to read watch on lbry settings from local storage */
|
||||
export const useExtensionSettings = () => useSettings(DEFAULT_SETTINGS)
|
||||
|
||||
const targetPlatform = (o: {
|
||||
domainPrefix: string
|
||||
displayName: string
|
||||
theme: string
|
||||
button: {
|
||||
text: string
|
||||
icon: string
|
||||
style?:
|
||||
{
|
||||
icon?: JSX.CSSProperties
|
||||
button?: JSX.CSSProperties
|
||||
}
|
||||
}
|
||||
}) => o
|
||||
export type TargetPlatform = ReturnType<typeof targetPlatform>
|
||||
export type TargetPlatformName = Extract<keyof typeof targetPlatformSettings, string>
|
||||
export const getTargetPlatfromSettingsEntiries = () => {
|
||||
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
|
||||
}
|
||||
export const targetPlatformSettings = {
|
||||
odysee: targetPlatform({
|
||||
domainPrefix: 'https://odysee.com/',
|
||||
displayName: 'Odysee',
|
||||
theme: 'linear-gradient(130deg, #c63d59, #f77937)',
|
||||
button: {
|
||||
text: 'Watch on Odysee',
|
||||
icon: chrome.runtime.getURL('assets/icons/lbry/odysee-logo.svg')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const sourcePlatform = (o: {
|
||||
hostnames: string[]
|
||||
htmlQueries: {
|
||||
mountButtonBefore: string,
|
||||
videoPlayer: string
|
||||
}
|
||||
}) => o
|
||||
export type SourcePlatform = ReturnType<typeof sourcePlatform>
|
||||
export type SourcePlatformName = Extract<keyof typeof sourcePlatfromSettings, string>
|
||||
export function getSourcePlatfromSettingsFromHostname(hostname: string) {
|
||||
const values = Object.values(sourcePlatfromSettings)
|
||||
for (const settings of values)
|
||||
if (settings.hostnames.includes(hostname)) return settings
|
||||
return null
|
||||
}
|
||||
export const sourcePlatfromSettings = {
|
||||
"youtube.com": sourcePlatform({
|
||||
hostnames: ['www.youtube.com'],
|
||||
htmlQueries: {
|
||||
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
|
||||
videoPlayer: '#ytd-player video'
|
||||
}
|
||||
}),
|
||||
"yewtu.be": sourcePlatform({
|
||||
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
|
||||
htmlQueries: {
|
||||
mountButtonBefore: '#watch-on-youtube',
|
||||
videoPlayer: '#player-container video'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ytUrlResolver = (o: {
|
||||
name: string
|
||||
href: string
|
||||
signRequest: boolean
|
||||
}) => o
|
||||
export type YTUrlResolver = ReturnType<typeof ytUrlResolver>
|
||||
export type YTUrlResolverName = Extract<keyof typeof ytUrlResolversSettings, string>
|
||||
export const getYtUrlResolversSettingsEntiries = () => Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
|
||||
export const ytUrlResolversSettings = {
|
||||
odyseeApi: ytUrlResolver({
|
||||
name: "Odysee",
|
||||
href: "https://api.odysee.com/yt",
|
||||
signRequest: false
|
||||
})
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
body {
|
||||
--color-text: whitesmoke;
|
||||
--color-backround: rgb(20, 14, 27);
|
||||
--color-card: #231830;
|
||||
--color-primary: rgb(239, 25, 112);
|
||||
|
||||
background-color: var(--color-backround);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 50%;
|
||||
margin-bottom: 15px;
|
||||
border: 3px;
|
||||
padding: 10px;
|
||||
|
||||
}
|
||||
|
||||
.YTtoLBRY {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.Conversion {
|
||||
margin: 1rem;
|
||||
width: 45em;
|
||||
}
|
||||
|
||||
.ConversionHelp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ConversionCard {
|
||||
box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 1px 1px rgba(16,22,26,.2), 0 2px 6px rgba(16,22,26,.2);
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border: 0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.btn.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background-color: rgba(200, 200, 200, .5);
|
||||
color: rgba(90, 90, 90, .6);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.YTtoLBRY {
|
||||
flex-direction: column;
|
||||
}
|
||||
.Conversion {
|
||||
width: auto;
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
<link rel="stylesheet" href="YTtoLBRY.css" />
|
||||
<script src="YTtoLBRY.tsx" charset="utf-8" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
</html>
|
|
@ -1,62 +0,0 @@
|
|||
import { Fragment, h, JSX, render } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
import { getSettingsAsync, redirectDomains } from '../common/settings';
|
||||
import { getFileContent, ytService } from '../common/yt';
|
||||
|
||||
import readme from './README.md';
|
||||
|
||||
/**
|
||||
* Parses the subscription file and queries the API for lbry channels
|
||||
*
|
||||
* @param file to read
|
||||
* @returns a promise with the list of channels that were found on lbry
|
||||
*/
|
||||
async function lbryChannelsFromFile(file: File) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const content = await getFileContent(file);
|
||||
|
||||
const ids = new Set((ext === 'xml' || ext == 'opml' ? ytService.readOpml(content) : ytService.readJson(content)))
|
||||
const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const)));
|
||||
const { redirect } = await getSettingsAsync('redirect');
|
||||
const urlPrefix = redirectDomains[redirect].prefix;
|
||||
return lbryUrls.map(channel => urlPrefix + channel);
|
||||
}
|
||||
|
||||
function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | void }) {
|
||||
const [file, setFile] = useState(null as File | null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
return <div className='ConversionCard'>
|
||||
<h2>Select YouTube Subscriptions</h2>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input type='file' onChange={e => setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
|
||||
</div>
|
||||
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
await onSelect(file);
|
||||
setLoading(false);
|
||||
}} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function YTtoLBRY() {
|
||||
const [lbryChannels, setLbryChannels] = useState([] as string[]);
|
||||
|
||||
return <div className='YTtoLBRY'>
|
||||
<div className='Conversion'>
|
||||
<ConversionCard onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
|
||||
<ul>
|
||||
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='ConversionHelp'>
|
||||
<iframe width='712px' height='400px' allowFullScreen
|
||||
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
|
||||
<section dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
render(<YTtoLBRY />, document.getElementById('root')!);
|
|
@ -41,6 +41,7 @@
|
|||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"importsNotUsedAsValues": "error", /* Import types always with `import type` */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
|
@ -53,7 +54,6 @@
|
|||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
|
|
84
yarn.lock
|
@ -2171,15 +2171,15 @@ browserify-zlib@^0.2.0:
|
|||
pako "~1.0.5"
|
||||
|
||||
browserslist@^4.0.0, browserslist@^4.1.0, browserslist@^4.14.5, browserslist@^4.16.1:
|
||||
version "4.16.1"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.1.tgz#bf757a2da376b3447b800a16f0f1c96358138766"
|
||||
integrity sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA==
|
||||
version "4.16.6"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"
|
||||
integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001173"
|
||||
colorette "^1.2.1"
|
||||
electron-to-chromium "^1.3.634"
|
||||
caniuse-lite "^1.0.30001219"
|
||||
colorette "^1.2.2"
|
||||
electron-to-chromium "^1.3.723"
|
||||
escalade "^3.1.1"
|
||||
node-releases "^1.1.69"
|
||||
node-releases "^1.1.71"
|
||||
|
||||
bser@2.1.1:
|
||||
version "2.1.1"
|
||||
|
@ -2348,10 +2348,10 @@ caniuse-api@^3.0.0:
|
|||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001173:
|
||||
version "1.0.30001179"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001179.tgz#b0803883b4471a6c62066fb1752756f8afc699c8"
|
||||
integrity sha512-blMmO0QQujuUWZKyVrD1msR4WNDAqb/UPO1Sw2WWsQ7deoM5bJiicKnWJ1Y0NS/aGINSnKPIWBMw5luX+NDUCA==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219:
|
||||
version "1.0.30001230"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz#8135c57459854b2240b57a4a6786044bdc5a9f71"
|
||||
integrity sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==
|
||||
|
||||
capture-exit@^2.0.0:
|
||||
version "2.0.0"
|
||||
|
@ -2631,9 +2631,9 @@ color-name@^1.0.0, color-name@~1.1.4:
|
|||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
color-string@^1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6"
|
||||
integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
|
||||
integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
|
||||
dependencies:
|
||||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
@ -2646,10 +2646,10 @@ color@^3.0.0:
|
|||
color-convert "^1.9.1"
|
||||
color-string "^1.5.4"
|
||||
|
||||
colorette@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
|
||||
integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
|
||||
colorette@^1.2.1, colorette@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
|
||||
integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
|
||||
|
||||
colors@0.5.x:
|
||||
version "0.5.1"
|
||||
|
@ -3466,10 +3466,10 @@ ee-first@1.1.1:
|
|||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
||||
|
||||
electron-to-chromium@^1.3.634:
|
||||
version "1.3.642"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz#8b884f50296c2ae2a9997f024d0e3e57facc2b94"
|
||||
integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ==
|
||||
electron-to-chromium@^1.3.723:
|
||||
version "1.3.739"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz#f07756aa92cabd5a6eec6f491525a64fe62f98b9"
|
||||
integrity sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==
|
||||
|
||||
elliptic@^6.5.3:
|
||||
version "6.5.4"
|
||||
|
@ -4594,9 +4594,9 @@ hmac-drbg@^1.0.1:
|
|||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
hosted-git-info@^2.1.4:
|
||||
version "2.8.8"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
|
||||
integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
|
||||
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
|
||||
|
||||
hsl-regex@^1.0.0:
|
||||
version "1.0.0"
|
||||
|
@ -6102,10 +6102,10 @@ lodash.uniq@^4.5.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
||||
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
|
||||
|
||||
lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@~4.17.10, lodash@~4.17.2:
|
||||
version "4.17.20"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
|
||||
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
|
||||
lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.4, lodash@~4.17.10, lodash@~4.17.2:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
log-symbols@^2.2.0:
|
||||
version "2.2.0"
|
||||
|
@ -6596,10 +6596,10 @@ node-notifier@8.0.1, node-notifier@^8.0.0:
|
|||
uuid "^8.3.0"
|
||||
which "^2.0.2"
|
||||
|
||||
node-releases@^1.1.69:
|
||||
version "1.1.70"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.70.tgz#66e0ed0273aa65666d7fe78febe7634875426a08"
|
||||
integrity sha512-Slf2s69+2/uAD79pVVQo8uSiC34+g8GWY8UH2Qtqv34ZfhYrxpYpfzs9Js9d6O0mbDmALuxaTlplnBTnSELcrw==
|
||||
node-releases@^1.1.71:
|
||||
version "1.1.72"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe"
|
||||
integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==
|
||||
|
||||
node-sass@^4.14.1:
|
||||
version "4.14.1"
|
||||
|
@ -7197,9 +7197,9 @@ path-key@^3.0.0, path-key@^3.1.0:
|
|||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
|
||||
path-parse@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
|
||||
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
path-type@^1.0.0:
|
||||
version "1.1.0"
|
||||
|
@ -9239,9 +9239,9 @@ tmp@0.2.1:
|
|||
rimraf "^3.0.0"
|
||||
|
||||
tmpl@1.0.x:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
||||
integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==
|
||||
|
||||
to-arraybuffer@^1.0.0:
|
||||
version "1.0.1"
|
||||
|
@ -9924,9 +9924,9 @@ ws@7.4.2, ws@^7.2.3:
|
|||
integrity sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==
|
||||
|
||||
ws@^5.1.1:
|
||||
version "5.2.2"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"
|
||||
integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==
|
||||
version "5.2.3"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.3.tgz#05541053414921bc29c63bee14b8b0dd50b07b3d"
|
||||
integrity sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==
|
||||
dependencies:
|
||||
async-limiter "~1.0.0"
|
||||
|
||||
|
|