Compare commits

..

No commits in common. "master" and "1.7.0" have entirely different histories.

73 changed files with 3764 additions and 16099 deletions

View file

@ -1,16 +0,0 @@
# 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>"

View file

@ -1,36 +0,0 @@
// 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"
}
}

4
.github/FUNDING.yml vendored
View file

@ -1,4 +0,0 @@
# These are supported funding model platforms
liberapay: Madiator2011
custom: cointr.ee/madiator2011

View file

@ -1,23 +0,0 @@
name: Add contributors
on:
schedule:
- cron: '20 20 * * *'
push:
branches:
- master
jobs:
add-contributors:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: BobAnkh/add-contributors@master
with:
CONTRIBUTOR: '## Contributors'
COLUMN_PER_ROW: '6'
ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
IMG_WIDTH: '100'
FONT_SIZE: '14'
PATH: '/README.md'
COMMIT_MESSAGE: 'docs(README): update contributors'
AVATAR_SHAPE: 'round'

View file

@ -1,37 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [15.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: yarn
- run: npm run build
- run: npm run
- run: npm run build:webext
- name: Archive extension directory
uses: actions/upload-artifact@v2
with:
name: extension
path: |
dist
web-ext-artifacts

4
.gitignore vendored
View file

@ -1,8 +1,4 @@
.cache .cache
dist dist
build
node_modules node_modules
web-ext-artifacts web-ext-artifacts
yarn-error.log
.DS_Store

View file

@ -1,15 +0,0 @@
# 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))

View file

@ -1,17 +1,11 @@
## Looking for contributors :)
![Logo](src/assets/icons/wol/default-transparent.svg)
# Watch on LBRY # Watch on LBRY
A plugin for web browsers that brings more utility for LBRY Protocol by allowing you to find people you watch on YouTube that are availible on LBRY.tv/Odysee/Desktop App and other LBRY Protocol based apps/websites, allows you to easly check your subscribtion list and much more! A plugin for web browsers that automatically checks whether a YouTube video or channel is on LBRY. If it is, it redirects you to LBRY to watch it there.
# Privacy
This plugin is using LBRY Inc YouTube Sync API to check if video fot synchronized with LBRY Platform. For more informations read LBRY Inc Privacy Policy at [here](https://lbry.com/privacypolicy)
## Installation ## Installation
[![Get it on Firefox](doc/img/AMO-button_1.png)](https://addons.mozilla.org/en/firefox/addon/watch-on-lbry/?src=search) [![Get on Firefox](https://addons.cdn.mozilla.net/static/img/addons-buttons/AMO-button_1.png)](https://addons.mozilla.org/en/firefox/addon/watch-on-lbry/?src=search) [![Get on Chrome](https://developer.chrome.com/webstore/images/ChromeWebStore_BadgeWBorder_v2_206x58.png)](https://chrome.google.com/webstore/detail/watch-on-lbry/jjmbbhopnjdjnpceiecihldbhibchgek)
[![Get it on Chrome](doc/img/chrome-small-border.png)](https://chrome.google.com/webstore/detail/watch-on-lbry/jjmbbhopnjdjnpceiecihldbhibchgek)
## Build ## Build
@ -21,7 +15,7 @@ For Production
```bash ```bash
$ npm install $ npm install
$ npm run build $ npm run build
$ npm run build:webext # optional, to create the zip file from the dist directory $ npx web-ext build --source-dir ./dist # optional, to create the zip file from the dist directory
``` ```
For Development For Development
@ -62,87 +56,8 @@ Pull requests are welcome. For major changes, please open an issue first to disc
Please make sure to update tests as appropriate. Please make sure to update tests as appropriate.
## Contributors
<table>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kodxana>
<img src=https://avatars.githubusercontent.com/u/16674412?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=kodxana/>
<br />
<sub style="font-size:14px"><b>kodxana</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/DeepDoge>
<img src=https://avatars.githubusercontent.com/u/44804845?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Shiba/>
<br />
<sub style="font-size:14px"><b>Shiba</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/Aenigma>
<img src=https://avatars.githubusercontent.com/u/409173?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kevin Raoofi/>
<br />
<sub style="font-size:14px"><b>Kevin Raoofi</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/Yamboy1>
<img src=https://avatars.githubusercontent.com/u/37413895?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Yamboy1/>
<br />
<sub style="font-size:14px"><b>Yamboy1</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/clay53>
<img src=https://avatars.githubusercontent.com/u/16981283?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Clayton Hickey/>
<br />
<sub style="font-size:14px"><b>Clayton Hickey</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/adam-dorin>
<img src=https://avatars.githubusercontent.com/u/1072815?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Adam/>
<br />
<sub style="font-size:14px"><b>Adam</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kbakdev>
<img src=https://avatars.githubusercontent.com/u/56700396?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kacper Bąk/>
<br />
<sub style="font-size:14px"><b>Kacper Bąk</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/eggplantbren>
<img src=https://avatars.githubusercontent.com/u/1578298?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Brendon J. Brewer/>
<br />
<sub style="font-size:14px"><b>Brendon J. Brewer</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/FireMasterK>
<img src=https://avatars.githubusercontent.com/u/20838718?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kavin/>
<br />
<sub style="font-size:14px"><b>Kavin</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kauffj>
<img src=https://avatars.githubusercontent.com/u/530774?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jeremy Kauffman/>
<br />
<sub style="font-size:14px"><b>Jeremy Kauffman</b></sub>
</a>
</td>
</tr>
</table>
## License ## License
[GPL-3.0 License](LICENSE) [MIT](https://choosealicense.com/licenses/mit/)
## Support ## Support

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

9
global.d.ts vendored
View file

@ -1,9 +0,0 @@
declare module '*.md' {
var _: string
export default _
}
declare namespace chrome
{
export const action = chrome.browserAction
}

View file

@ -1,56 +0,0 @@
{
"manifest_version": 2,
"name": "Watch on LBRY",
"version": "2.0.1",
"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
}
}

View file

@ -1,59 +0,0 @@
{
"manifest_version": 3,
"name": "Watch on LBRY",
"version": "2.0.1",
"icons": {
"16": "assets/icons/wol/icon16.png",
"48": "assets/icons/wol/icon48.png",
"128": "assets/icons/wol/icon128.png"
},
"permissions": [
"storage",
"tabs"
],
"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"
}
}

6351
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,21 +3,12 @@
"version": "1.5.4", "version": "1.5.4",
"license": "GPL-3.0", "license": "GPL-3.0",
"scripts": { "scripts": {
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/", "build:assets": "cpx \"src/**/*.{json,png}\" dist/",
"watch:assets": "cpx --watch -v \"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/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"", "build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/scripts/*.{js,ts}\" \"src/**/*.html\"",
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"", "watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest", "build": "npm-run-all -l -p build:parcel build:assets",
"clear:dist": "rm -r ./dist ; mkdir ./dist", "watch": "npm-run-all -l -p watch:parcel watch:assets",
"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:chrome": "web-ext run -t chromium --source-dir ./dist",
"start:firefox": "web-ext run --source-dir ./dist", "start:firefox": "web-ext run --source-dir ./dist",
"test": "jest" "test": "jest"
@ -37,9 +28,9 @@
"cpx": "^1.5.0", "cpx": "^1.5.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"jest": "^26.5.3", "jest": "^26.5.3",
"lodash": "^4.17.21", "lodash": "^4.17.20",
"marked": "^2.0.0", "marked": "^1.2.5",
"node-forge": "^0.10.0", "node-forge": ">=0.10.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"parcel-bundler": "^1.12.4", "parcel-bundler": "^1.12.4",

View file

@ -1,38 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 30 KiB

View file

@ -1,151 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="Layer_1"
data-name="Layer 1"
viewBox="0 0 103.3 103.3"
version="1.1"
sodipodi:docname="Logo_Textless_Vector.svg"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
width="103.3"
height="103.3">
<metadata
id="metadata340">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>odysee_</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1025"
id="namedview338"
showgrid="false"
showguides="false"
inkscape:zoom="3.18"
inkscape:cx="191"
inkscape:cy="51.650002"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<defs
id="defs293">
<style
id="style278">.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#fff;}.cls-3{fill:none;}.cls-4{fill:#f9f9f9;}</style>
<linearGradient
id="linear-gradient"
x1="37.900002"
y1="5.54"
x2="110.84"
y2="180.14999"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-9,-8.3499985)">
<stop
offset="0"
stop-color="#ef1970"
id="stop280" />
<stop
offset="0.14"
stop-color="#f23b5c"
id="stop282" />
<stop
offset="0.44"
stop-color="#f77d35"
id="stop284" />
<stop
offset="0.7"
stop-color="#fcad18"
id="stop286" />
<stop
offset="0.89"
stop-color="#fecb07"
id="stop288" />
<stop
offset="1"
stop-color="#ffd600"
id="stop290" />
</linearGradient>
</defs>
<title
id="title295">odysee_</title>
<circle
class="cls-1"
cx="51.650002"
cy="51.650002"
r="51.650002"
id="circle297"
style="fill:url(#linear-gradient)" />
<path
class="cls-2"
d="m 11.92,38.220002 a 0.95,0.95 0 1 0 -0.3,1.31 0.95,0.95 0 0 0 0.3,-1.31"
id="path299" />
<path
class="cls-2"
d="m 67.44,13.370002 a 0.95,0.95 0 1 0 -0.3,1.31 0.95,0.95 0 0 0 0.3,-1.31"
id="path301" />
<path
class="cls-2"
d="m 78.91,50.650002 a 1.11,1.11 0 1 0 1.33,-0.84 1.11,1.11 0 0 0 -1.33,0.84"
id="path303" />
<path
class="cls-2"
d="m 62.35,87.650002 a 0.86,0.86 0 1 0 1,-0.65 0.86,0.86 0 0 0 -1,0.65"
id="path305" />
<path
class="cls-2"
d="m 19.18,21.160002 a 0.52,0.52 0 1 0 0.63,-0.39 0.52,0.52 0 0 0 -0.63,0.39"
id="path307" />
<path
class="cls-2"
d="m 21.86,69.960002 a 0.73,0.73 0 1 0 -0.59,0.85 0.73,0.73 0 0 0 0.59,-0.85"
id="path309" />
<path
class="cls-3"
d="m 43.75,10.140002 c 0,0 -8.16,2.24 -7.53,10.89 0.56,7.67 4.65,11.85 13.14,8.65 8.49,-3.2 9.93,-5.45 7.85,-11.85 -2.08,-6.4 -4.49,-10.7300005 -13.46,-7.69 z"
id="path311" />
<path
class="cls-2"
d="m 91.45,83.650002 c -0.32,-0.6 -6.45,-10 -7.21,-17.9 -0.56,-5.47 -7.71,-11.54 -12,-14.73 a 3.11,3.11 0 0 1 -0.24,-4.75 c 4.23,-4 11.69,-11.8 14.05,-15.92 a 31.3,31.3 0 0 0 3.44,-13.89 51.89,51.89 0 0 0 -9.82,-8.2200005 c -3.48,1.72 -4.42,7.0700005 -5.95,13.3000005 -2.08,8.49 -7,7.53 -9,7.53 -2,0 -0.8,-3 -5.45,-16.34 C 54.62,-0.60999847 42.53,2.7300015 33.34,8.2300015 21.66,15.230002 26.87,30.160002 29.76,39.780002 c -1.64,1.58 -7.81,2.81 -13.42,5.83 -6.95,3.74 -14.06,9.75 -15.91,12.51 a 51.33,51.33 0 0 0 2.62,11 5.89,5.89 0 0 0 1.38,0.95 c 3.29,1.53 8.13,-1.09 12.71,-5.84 a 23.33,23.33 0 0 1 4.57,-3.53 48.94,48.94 0 0 1 11.77,-5.53 c 0,0 4.49,6.89 8.65,15.06 4.16,8.17 -4.49,10.89 -5.45,10.89 -0.96,0 -14.59,-1.27 -11.55,10.26 3.04,11.529998 19.7,7.37 28.19,1.76 8.49,-5.61 6.41,-23.87 6.41,-23.87 8.33,-1.28 10.89,7.53 11.69,12 0.8,4.47 -1,12.33 7.37,12.5 a 10.48,10.48 0 0 0 3.47,-0.54 51.94,51.94 0 0 0 8.74,-8.17 2.88,2.88 0 0 0 0.45,-1.41 z m -42.09,-54 c -8.49,3.2 -12.58,-1 -13.14,-8.65 -0.63,-8.65 7.53,-10.89 7.53,-10.89 9,-3.0000005 11.37,1.28 13.46,7.69 2.09,6.41 0.64,8.68 -7.85,11.85 z"
id="path313" />
<polygon
class="cls-2"
points="97.44,50.39 96.27,48.07 93.72,47.54 96.04,46.37 96.56,43.82 97.74,46.14 100.29,46.66 97.97,47.84 "
id="polygon315"
transform="translate(-9,-8.3499985)" />
<path
class="cls-4"
d="m 54.25,19.360002 a 5.41,5.41 0 0 1 0.38,3.6"
id="path329" />
<path
class="cls-2"
d="m 54.63,24.060002 h -0.21 a 1.09,1.09 0 0 1 -0.86,-1.27 4.36,4.36 0 0 0 -0.31,-3 1.09,1.09 0 0 1 2,-0.84 6.46,6.46 0 0 1 0.44,4.23 1.09,1.09 0 0 1 -1.06,0.88 z"
id="path331" />
<path
class="cls-4"
d="m 51.56,13.330002 a 6.14,6.14 0 0 1 0.81,1.24"
id="path333" />
<path
class="cls-2"
d="m 52.36,15.650002 a 1.09,1.09 0 0 1 -1,-0.56 6.71,6.71 0 0 0 -0.64,-1 1.1,1.1 0 0 1 0,-1.52 1.07,1.07 0 0 1 1.49,0 6.8,6.8 0 0 1 1,1.49 1.09,1.09 0 0 1 -0.85,1.59 z"
id="path335" />
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -1 +0,0 @@
<svg data-v-423bf9ae="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 422.10743801652893 60" class="font"><!----><!----><!----><g data-v-423bf9ae="" id="c68be61a-7a4c-407d-aedb-150928d6ca02" fill="black" transform="matrix(6.347107410430908,0,0,6.347107410430908,0.2727353870868683,0.0743802934885025)" style="--darkreader-inline-fill:#000000;" data-darkreader-inline-fill=""><path d="M4.02 0.41C3.98 0.18 3.75 0 3.53 0L3.22 0C3.00 0 2.77 0.18 2.73 0.41L2.00 4.66L1.36 0.42C1.32 0.18 1.09 0 0.85 0L0.45 0C0.13 0-0.08 0.27-0.04 0.57L1.33 9.02C1.37 9.24 1.58 9.44 1.82 9.44L2.13 9.44C2.35 9.44 2.58 9.25 2.62 9.03L3.37 4.56L4.12 9.03C4.16 9.25 4.38 9.44 4.61 9.44L4.93 9.44C5.17 9.44 5.39 9.24 5.43 9.02L6.78 0.57C6.82 0.27 6.58 0 6.27 0L5.87 0C5.63 0 5.39 0.18 5.36 0.42L4.73 4.63ZM11.66 9.04C11.72 9.25 11.94 9.44 12.15 9.44L12.56 9.44C12.89 9.44 13.12 9.14 13.05 8.82L11.12 0.39C11.07 0.18 10.84 0 10.63 0L10.32 0C10.11 0 9.88 0.18 9.83 0.39L8.55 5.87L7.88 8.83C7.81 9.16 8.04 9.44 8.37 9.44L8.78 9.44C8.99 9.44 9.21 9.25 9.27 9.04L9.76 6.86L11.19 6.86ZM10.47 3.23C10.74 4.42 10.71 4.80 10.86 5.47L10.08 5.47ZM12.60 0.88C12.60 1.16 12.82 1.39 13.10 1.39L14.24 1.39L14.24 8.92C14.24 9.20 14.46 9.42 14.74 9.42L15.12 9.42C15.40 9.42 15.62 9.20 15.62 8.92L15.62 1.39L16.77 1.39C17.05 1.39 17.28 1.16 17.28 0.88L17.28 0.49C17.28 0.21 17.05-0.01 16.77-0.01L13.10-0.01C12.82-0.01 12.60 0.21 12.60 0.49ZM17.58 6.69C17.58 7.80 17.89 8.44 18.33 8.88C18.79 9.32 19.36 9.44 19.89 9.44C20.40 9.44 20.99 9.32 21.45 8.88C21.88 8.44 22.18 7.80 22.18 6.69L22.18 6.50C22.18 6.22 21.95 5.99 21.69 5.99L21.28 5.99C21 5.99 20.78 6.22 20.78 6.50L20.78 6.69C20.78 7.20 20.71 7.57 20.54 7.80C20.41 7.94 20.16 8.05 19.89 8.05C19.29 8.05 19.00 7.63 19.00 6.69L19.00 2.73C19.00 1.79 19.31 1.39 19.89 1.39C20.47 1.39 20.78 1.79 20.78 2.73L20.78 2.93C20.78 3.21 21 3.43 21.28 3.43L21.69 3.43C21.95 3.43 22.18 3.21 22.18 2.93L22.18 2.73C22.18 1.67 21.88 0.97 21.43 0.55C20.97 0.13 20.41 0 19.89 0C19.36 0 18.80 0.13 18.34 0.55C17.89 0.97 17.58 1.67 17.58 2.73ZM24.53 4.03L24.53 0.50C24.53 0.22 24.30 0 24.02 0L23.63 0C23.35 0 23.13 0.22 23.13 0.50L23.13 8.93C23.13 9.21 23.35 9.44 23.63 9.44L24.02 9.44C24.30 9.44 24.53 9.21 24.53 8.93L24.53 5.42L26.08 5.42L26.08 8.93C26.08 9.21 26.31 9.44 26.59 9.44L26.98 9.44C27.26 9.44 27.48 9.21 27.48 8.93L27.48 0.50C27.48 0.22 27.26 0 26.98 0L26.59 0C26.31 0 26.08 0.22 26.08 0.50L26.08 4.03ZM31.58 6.69C31.58 7.80 31.89 8.44 32.33 8.88C32.79 9.32 33.38 9.44 33.88 9.44C34.40 9.44 34.99 9.32 35.45 8.88C35.88 8.44 36.18 7.80 36.18 6.69L36.18 2.73C36.18 0.95 35.42 0 33.88 0C32.35 0 31.58 0.97 31.58 2.73ZM32.98 2.73C32.98 1.82 33.26 1.39 33.88 1.39C34.48 1.39 34.78 1.79 34.78 2.73L34.78 6.69C34.78 7.64 34.47 8.05 33.88 8.05C33.28 8.05 32.98 7.62 32.98 6.69ZM41.40 9.44C41.68 9.44 41.90 9.21 41.90 8.93L41.90 0.50C41.90 0.22 41.68 0 41.40 0L41.01 0C40.73 0 40.50 0.22 40.50 0.50L40.50 5.12L38.56 0.31C38.50 0.15 38.28 0 38.09 0L37.76 0C37.49 0 37.25 0.22 37.25 0.50L37.25 8.93C37.25 9.21 37.49 9.44 37.76 9.44L38.16 9.44C38.43 9.44 38.67 9.21 38.67 8.93L38.67 4.31L40.61 9.13C40.67 9.28 40.91 9.44 41.08 9.44ZM49.84 8.55C49.84 8.27 49.62 8.05 49.34 8.05L47.25 8.05L47.25 0.50C47.25 0.22 47.03 0 46.76 0L46.35 0C46.07 0 45.85 0.22 45.85 0.50L45.85 8.93C45.85 9.21 46.07 9.44 46.35 9.44L49.34 9.44C49.62 9.44 49.84 9.21 49.84 8.93ZM52.61 9.44C54.25 9.44 54.98 8.54 54.98 6.73C54.98 5.88 54.74 5.17 54.38 4.72L54.38 4.72C54.38 4.72 54.98 4.00 54.98 2.70C54.98 0.90 54.25 0 52.61 0L51.23 0C50.95 0 50.72 0.22 50.72 0.50L50.72 8.93C50.72 9.21 50.95 9.44 51.23 9.44ZM52.61 1.39C53.30 1.39 53.58 1.76 53.58 2.70C53.58 3.64 53.30 4.02 52.61 4.02L52.12 4.02L52.12 1.39ZM52.61 5.40C53.30 5.40 53.58 5.78 53.58 6.73C53.58 7.67 53.30 8.05 52.61 8.05L52.12 8.05L52.12 5.40ZM60.55 6.58C60.55 5.85 60.35 5.28 59.95 4.84C59.99 4.80 60.02 4.77 60.06 4.73C60.49 4.30 60.65 3.60 60.65 2.73C60.65 0.85 60.05-0.01 58.32-0.01L56.56-0.01C56.28-0.01 56.06 0.21 56.06 0.49L56.06 8.92C56.06 9.20 56.28 9.42 56.56 9.42L56.95 9.42C57.22 9.42 57.44 9.20 57.44 8.92L57.44 5.49L58.07 5.49C58.76 5.49 59.15 5.88 59.15 6.58L59.15 8.92C59.15 9.20 59.37 9.42 59.65 9.42L60.05 9.42C60.33 9.42 60.55 9.20 60.55 8.92ZM57.44 1.39L58.32 1.39C59.05 1.39 59.26 1.78 59.26 2.73C59.26 3.68 58.86 4.07 58.07 4.07L57.44 4.07ZM62.78 0.29C62.72 0.15 62.48 0 62.31 0L61.87 0C61.47 0 61.25 0.35 61.40 0.70C62.01 2.10 62.62 3.49 63.22 4.89L63.22 8.93C63.22 9.21 63.45 9.44 63.71 9.44L64.11 9.44C64.39 9.44 64.61 9.21 64.61 8.93L64.61 4.90L66.42 0.70C66.56 0.35 66.33 0 65.95 0L65.49 0C65.34 0 65.10 0.15 65.03 0.29L63.91 2.87Z"></path></g><!----><!----></svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -1 +0,0 @@
<svg data-v-423bf9ae="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 422.10743801652893 60" class="font"><!----><!----><!----><g data-v-423bf9ae="" id="7ded7196-cb33-41a9-a3d2-8b6b37ca65cf" fill="white" transform="matrix(6.347107410430908,0,0,6.347107410430908,0.2727353870868683,0.0743802934885025)" style="--darkreader-inline-fill:#181a1b;" data-darkreader-inline-fill=""><path d="M4.02 0.41C3.98 0.18 3.75 0 3.53 0L3.22 0C3.00 0 2.77 0.18 2.73 0.41L2.00 4.66L1.36 0.42C1.32 0.18 1.09 0 0.85 0L0.45 0C0.13 0-0.08 0.27-0.04 0.57L1.33 9.02C1.37 9.24 1.58 9.44 1.82 9.44L2.13 9.44C2.35 9.44 2.58 9.25 2.62 9.03L3.37 4.56L4.12 9.03C4.16 9.25 4.38 9.44 4.61 9.44L4.93 9.44C5.17 9.44 5.39 9.24 5.43 9.02L6.78 0.57C6.82 0.27 6.58 0 6.27 0L5.87 0C5.63 0 5.39 0.18 5.36 0.42L4.73 4.63ZM11.66 9.04C11.72 9.25 11.94 9.44 12.15 9.44L12.56 9.44C12.89 9.44 13.12 9.14 13.05 8.82L11.12 0.39C11.07 0.18 10.84 0 10.63 0L10.32 0C10.11 0 9.88 0.18 9.83 0.39L8.55 5.87L7.88 8.83C7.81 9.16 8.04 9.44 8.37 9.44L8.78 9.44C8.99 9.44 9.21 9.25 9.27 9.04L9.76 6.86L11.19 6.86ZM10.47 3.23C10.74 4.42 10.71 4.80 10.86 5.47L10.08 5.47ZM12.60 0.88C12.60 1.16 12.82 1.39 13.10 1.39L14.24 1.39L14.24 8.92C14.24 9.20 14.46 9.42 14.74 9.42L15.12 9.42C15.40 9.42 15.62 9.20 15.62 8.92L15.62 1.39L16.77 1.39C17.05 1.39 17.28 1.16 17.28 0.88L17.28 0.49C17.28 0.21 17.05-0.01 16.77-0.01L13.10-0.01C12.82-0.01 12.60 0.21 12.60 0.49ZM17.58 6.69C17.58 7.80 17.89 8.44 18.33 8.88C18.79 9.32 19.36 9.44 19.89 9.44C20.40 9.44 20.99 9.32 21.45 8.88C21.88 8.44 22.18 7.80 22.18 6.69L22.18 6.50C22.18 6.22 21.95 5.99 21.69 5.99L21.28 5.99C21 5.99 20.78 6.22 20.78 6.50L20.78 6.69C20.78 7.20 20.71 7.57 20.54 7.80C20.41 7.94 20.16 8.05 19.89 8.05C19.29 8.05 19.00 7.63 19.00 6.69L19.00 2.73C19.00 1.79 19.31 1.39 19.89 1.39C20.47 1.39 20.78 1.79 20.78 2.73L20.78 2.93C20.78 3.21 21 3.43 21.28 3.43L21.69 3.43C21.95 3.43 22.18 3.21 22.18 2.93L22.18 2.73C22.18 1.67 21.88 0.97 21.43 0.55C20.97 0.13 20.41 0 19.89 0C19.36 0 18.80 0.13 18.34 0.55C17.89 0.97 17.58 1.67 17.58 2.73ZM24.53 4.03L24.53 0.50C24.53 0.22 24.30 0 24.02 0L23.63 0C23.35 0 23.13 0.22 23.13 0.50L23.13 8.93C23.13 9.21 23.35 9.44 23.63 9.44L24.02 9.44C24.30 9.44 24.53 9.21 24.53 8.93L24.53 5.42L26.08 5.42L26.08 8.93C26.08 9.21 26.31 9.44 26.59 9.44L26.98 9.44C27.26 9.44 27.48 9.21 27.48 8.93L27.48 0.50C27.48 0.22 27.26 0 26.98 0L26.59 0C26.31 0 26.08 0.22 26.08 0.50L26.08 4.03ZM31.58 6.69C31.58 7.80 31.89 8.44 32.33 8.88C32.79 9.32 33.38 9.44 33.88 9.44C34.40 9.44 34.99 9.32 35.45 8.88C35.88 8.44 36.18 7.80 36.18 6.69L36.18 2.73C36.18 0.95 35.42 0 33.88 0C32.35 0 31.58 0.97 31.58 2.73ZM32.98 2.73C32.98 1.82 33.26 1.39 33.88 1.39C34.48 1.39 34.78 1.79 34.78 2.73L34.78 6.69C34.78 7.64 34.47 8.05 33.88 8.05C33.28 8.05 32.98 7.62 32.98 6.69ZM41.40 9.44C41.68 9.44 41.90 9.21 41.90 8.93L41.90 0.50C41.90 0.22 41.68 0 41.40 0L41.01 0C40.73 0 40.50 0.22 40.50 0.50L40.50 5.12L38.56 0.31C38.50 0.15 38.28 0 38.09 0L37.76 0C37.49 0 37.25 0.22 37.25 0.50L37.25 8.93C37.25 9.21 37.49 9.44 37.76 9.44L38.16 9.44C38.43 9.44 38.67 9.21 38.67 8.93L38.67 4.31L40.61 9.13C40.67 9.28 40.91 9.44 41.08 9.44ZM49.84 8.55C49.84 8.27 49.62 8.05 49.34 8.05L47.25 8.05L47.25 0.50C47.25 0.22 47.03 0 46.76 0L46.35 0C46.07 0 45.85 0.22 45.85 0.50L45.85 8.93C45.85 9.21 46.07 9.44 46.35 9.44L49.34 9.44C49.62 9.44 49.84 9.21 49.84 8.93ZM52.61 9.44C54.25 9.44 54.98 8.54 54.98 6.73C54.98 5.88 54.74 5.17 54.38 4.72L54.38 4.72C54.38 4.72 54.98 4.00 54.98 2.70C54.98 0.90 54.25 0 52.61 0L51.23 0C50.95 0 50.72 0.22 50.72 0.50L50.72 8.93C50.72 9.21 50.95 9.44 51.23 9.44ZM52.61 1.39C53.30 1.39 53.58 1.76 53.58 2.70C53.58 3.64 53.30 4.02 52.61 4.02L52.12 4.02L52.12 1.39ZM52.61 5.40C53.30 5.40 53.58 5.78 53.58 6.73C53.58 7.67 53.30 8.05 52.61 8.05L52.12 8.05L52.12 5.40ZM60.55 6.58C60.55 5.85 60.35 5.28 59.95 4.84C59.99 4.80 60.02 4.77 60.06 4.73C60.49 4.30 60.65 3.60 60.65 2.73C60.65 0.85 60.05-0.01 58.32-0.01L56.56-0.01C56.28-0.01 56.06 0.21 56.06 0.49L56.06 8.92C56.06 9.20 56.28 9.42 56.56 9.42L56.95 9.42C57.22 9.42 57.44 9.20 57.44 8.92L57.44 5.49L58.07 5.49C58.76 5.49 59.15 5.88 59.15 6.58L59.15 8.92C59.15 9.20 59.37 9.42 59.65 9.42L60.05 9.42C60.33 9.42 60.55 9.20 60.55 8.92ZM57.44 1.39L58.32 1.39C59.05 1.39 59.26 1.78 59.26 2.73C59.26 3.68 58.86 4.07 58.07 4.07L57.44 4.07ZM62.78 0.29C62.72 0.15 62.48 0 62.31 0L61.87 0C61.47 0 61.25 0.35 61.40 0.70C62.01 2.10 62.62 3.49 63.22 4.89L63.22 8.93C63.22 9.21 63.45 9.44 63.71 9.44L64.11 9.44C64.39 9.44 64.61 9.21 64.61 8.93L64.61 4.90L66.42 0.70C66.56 0.35 66.33 0 65.95 0L65.49 0C65.34 0 65.10 0.15 65.03 0.29L63.91 2.87Z"></path></g><!----><!----></svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -1,158 +0,0 @@
:root {
--color-master: #499375;
--color-slave: #43889d;
--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;
}

View file

@ -0,0 +1,16 @@
@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

View file

@ -0,0 +1,31 @@
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>;
}

View file

@ -0,0 +1,29 @@
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);
});
});

142
src/common/lbry-url.ts Normal file
View file

@ -0,0 +1,142 @@
// 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 [];
}

16
src/common/settings.ts Normal file
View file

@ -0,0 +1,16 @@
export interface LbrySettings {
enabled: boolean
redirect: keyof typeof redirectDomains
}
export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, redirect: 'lbry.tv' };
export const redirectDomains = {
'lbry.tv': { prefix: 'https://lbry.tv/', display: 'lbry.tv' },
odysee: { prefix: 'https://odysee.com/', display: 'odysee' },
app: { prefix: 'lbry://', display: 'App' },
};
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)));
}

34
src/common/style.sass Normal file
View file

@ -0,0 +1,34 @@
$background-color: #191a1c !default
$text-color: whitesmoke !default
$btn-color: #075656 !default
$btn-select: teal !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

30
src/common/useSettings.ts Normal file
View file

@ -0,0 +1,30 @@
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 Normal file
View file

@ -0,0 +1,126 @@
import chunk from 'lodash/chunk';
import groupBy from 'lodash/groupBy';
import pickBy from 'lodash/pickBy';
const LBRY_API_HOST = 'https://api.lbry.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);
},
};

View file

@ -1,142 +0,0 @@
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
{
position: fixed;
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 Normal file
View file

@ -0,0 +1,4 @@
declare module '*.md' {
var _: string;
export default _;
}

BIN
src/icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
src/icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

45
src/manifest.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "Watch on LBRY",
"version": "1.7.0",
"permissions": [
"https://www.youtube.com/",
"https://invidio.us/channel/*",
"https://invidio.us/watch?v=*",
"https://api.lbry.com/*",
"https://lbry.tv/*",
"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 LBRY",
"default_popup": "popup/popup.html"
},
"web_accessible_resources": [
"popup.html",
"tools/YTtoLBRY.html",
"icons/lbry-logo.svg"
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"manifest_version": 2
}

View file

@ -1,200 +0,0 @@
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 url = new URL(ytUrlResolversSettings.madiatorFinder.href)
url.pathname = path.join(url.pathname, pathname)
url.searchParams.set('data', JSON.stringify(data))
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
}
}

View file

@ -1,15 +0,0 @@
/**
* @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)
})
}

View file

@ -1,83 +0,0 @@
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
}

View file

@ -1,82 +0,0 @@
// This should only work in background
if (typeof chrome.extension === 'undefined') throw new Error("YT urlCache can only be accessed from extension windows and service-workers.")
let db = new Promise<IDBDatabase>((resolve, reject) => {
if (typeof self.indexedDB !== 'undefined') {
const openRequest = indexedDB.open("yt-url-resolver-cache")
openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt"))
openRequest.addEventListener('success', () => {
resolve(openRequest.result)
clearExpired()
}, { once: true })
}
else reject(`IndexedDB not supported`)
})
async function clearExpired() {
return new Promise<void>(async (resolve, reject) => {
const transaction = (await 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>(async (resolve, reject) => {
const store = (await 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(async (resolve, reject) => {
const store = (await 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(async (resolve, reject) => {
const store = (await 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
}
return response.value
}
export const lbryUrlCache = { put, get, clearAll }

View file

@ -1,88 +0,0 @@
import { chunk } from "lodash"
import path from "path"
import { getExtensionSettingsAsync, SourcePlatform, ytUrlResolversSettings } from "../../settings"
import { sign } from "../crypto"
import { lbryUrlCache } from "./urlCache"
const QUERY_CHUNK_SIZE = 100
export type ResolveUrlTypes = 'video' | 'channel'
export type YtUrlResolveItem = { type: ResolveUrlTypes, 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, privateKey, publicKey } = 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(','))
if (urlResolverSetting.signRequest && publicKey && privateKey)
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), privateKey),
publicKey
}))
const controller = new AbortController()
// 5 second timeout:
const timeoutId = setTimeout(() => controller.abort(), 15000)
const apiResponse = await fetch(url.toString(), { cache: 'no-store', signal: controller.signal })
clearTimeout(timeoutId)
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
}

View file

@ -1,17 +0,0 @@
<!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>

View file

@ -1,86 +0,0 @@
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'
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://odysee.com/$/embed/convert-subscriptions-from-YouTube-to-LBRY/36f3a010295afe1c55e91b63bcb2eabc028ec86c?r=8bgP4hEdbd9jwBJmhEaqP3dD75LzsUob' />
<section><h1 id="getting-your-subscription-data">Getting your subscription data</h1>
<ol>
<li>Go to <a href="https://takeout.google.com/settings/takeout" target='_blank'>https://takeout.google.com/settings/takeout</a></li>
<li>Deselect everything except <code>YouTube and YouTube Music</code> and within that only select <code>subscriptions</code></li>
<li>Go through the process and create the export</li>
<li>Once it's exported, open the archive and find <code>YouTube and YouTube Music/subscriptions/subscriptions.(json/csv/opml)</code> and upload it to the extension</li>
</ol>
</section>
</aside>
</main>
}
render(<YTtoLBRY />, document.getElementById('root')!)

View file

@ -1,86 +0,0 @@
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;
}

View file

@ -1,14 +0,0 @@
<!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>

View file

@ -1,62 +0,0 @@
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')!)

View file

@ -1,35 +0,0 @@
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;
}

View file

@ -1,14 +0,0 @@
<!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>

View file

@ -1,236 +0,0 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { createDialogManager, Dialogs } from '../../components/dialogs'
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, purgeProfile, resetProfileSettings } from '../../modules/crypto'
import { lbryUrlCache } from '../../modules/yt/urlCache'
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, setExtensionSetting, useExtensionSettings } from '../../settings'
import { openImportPopup } from '../import/main'
/** Gets all the options for redirect destinations as selection options */
const targetPlatforms = getTargetPlatfromSettingsEntiries()
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
const { targetPlatform, urlResolver, redirectChannel, redirectVideo, redirectVideoPlaylist, buttonVideoSub, buttonChannelSub, buttonVideoPlayer, privateKey, publicKey } = useExtensionSettings()
let [loading, updateLoading] = useState(() => false)
let [route, updateRoute] = useState<string>(() => '')
const dialogManager = createDialogManager()
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
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>
{
publicKey &&
<section>
<label>{nickname}</label>
<p>{friendlyPublicKey(publicKey)}</p>
<span><b>Score: {params.profile?.score ?? '...'}</b> - <a target='_blank' href="https://finder.madiator.com/leaderboard" class="filled">🔗Leaderboard</a></span>
{urlResolver !== 'madiatorFinder' && <span class="error">You need to use Madiator Finder API for scoring to work</span>}
</section>
}
{
route !== ''
?
<section>
<a onClick={() => updateRoute('')} className="filled"> Back</a>
</section>
:
<section>
<a className='filled' onClick={() => updateRoute('profile')}>Profile Settings</a>
</section>
}
</header>
}
{
route === 'profile' ?
publicKey ?
<main>
<section>
<div className='options'>
<a onClick={() => loads(generateProfileAndSetNickname(dialogManager)).then(() => renderPopup())} className={`button active`}>
Change Nickname
</a>
<a onClick={async () => {
if (!await dialogManager.confirm("This will delete your keypair from this device."
+ "\nStill wanna continue?"
+ "\n\nNOTE: Without keypair you can't purge your data online."
+ "\nSo if you wish to purge, please use purging instead.")) return
resetProfileSettings()
renderPopup()
}}
className={`button`}
>
Forget/Logout
</a>
</div>
</section>
<section>
<label>Backup your account</label>
<p>Import and export your unique keypair.</p>
<div className='options'>
<a onClick={() => exportProfileKeysAsFile()} className={`button active`}>
Export
</a>
<a onClick={() => openImportPopup()}
className={`button`}
>
Import
</a>
</div>
</section>
<section>
<label>Purge your profile and data!</label>
<p>Purge your profile data online and offline.</p>
<div className='options'>
<div className="center">
<span className='filled'>(°° </span>
</div>
<a onClick={() => loads(purgeProfile(dialogManager)).then(() => renderPopup())} className={`button`}>
Purge Everything!!
</a>
</div>
</section>
<section>
<label>Generate new profile</label>
<p>Generate a new keypair.</p>
<div className='options'>
<a onClick={async () => await dialogManager.confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.")
&& loads(generateProfileAndSetNickname(dialogManager, true)).then(() => renderPopup())
}
className={`button`}
>
Generate New Account
</a>
</div>
</section>
</main>
:
<main>
<section>
<label>You don't have a profile.</label>
<p>You can either import keypair for an existing profile or generate a new profile keypair.</p>
<div className='options'>
<a onClick={() => openImportPopup()} className={`button`}>
Import
</a>
<a onClick={() => loads(generateProfileAndSetNickname(dialogManager)).then(() => renderPopup())} className={`button active`}>
Generate
</a>
</div>
</section>
</main>
:
route === 'advanced' ?
<main>
<section>
<label>Which platform you would like to redirect to?</label>
<div className='options'>
{targetPlatforms.map(([name, value]) =>
<a onClick={() => setExtensionSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
{value.displayName}
</a>
)}
</div>
</section>
<section>
<label>Which resolver API you want to use?</label>
<div className='options'>
{ytUrlResolverOptions.map(([name, value]) =>
<a onClick={() => setExtensionSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
{value.name}
</a>
)}
</div>
<a onClick={() => loads(lbryUrlCache.clearAll().then(() => dialogManager.alert("Cleared Cache!")))} className={`button active`}>
Clear Resolver Cache
</a>
</section>
</main>
:
<main>
<section>
<label>Auto redirect when:</label>
<div className='options'>
<div class="toggle-option">
<span>Playing a video</span>
<a onClick={() => setExtensionSetting('redirectVideo', !redirectVideo)} className={`button ${redirectVideo ? 'active' : ''}`}>
{redirectVideo ? 'Active' : 'Deactive'}
</a>
</div>
<div class="toggle-option">
<span>Playing a playlist</span>
<a onClick={() => setExtensionSetting('redirectVideoPlaylist', !redirectVideoPlaylist)} className={`button ${redirectVideoPlaylist ? 'active' : ''}`}>
{redirectVideoPlaylist ? 'Active' : 'Deactive'}
</a>
</div>
<div class="toggle-option">
<span>Viewing a channel</span>
<a onClick={() => setExtensionSetting('redirectChannel', !redirectChannel)} className={`button ${redirectChannel ? 'active' : ''}`}>
{redirectChannel ? 'Active' : 'Deactive'}
</a>
</div>
</div>
</section>
<section>
<label>Show redirect button on:</label>
<div className='options'>
<div className="toggle-option">
<span>Video Page</span>
<a onClick={() => setExtensionSetting('buttonVideoSub', !buttonVideoSub)} className={`button ${buttonVideoSub ? 'active' : ''}`}>
{buttonVideoSub ? 'Active' : 'Deactive'}
</a>
</div>
<div className="toggle-option">
<span>Channel Page</span>
<a onClick={() => setExtensionSetting('buttonChannelSub', !buttonChannelSub)} className={`button ${buttonChannelSub ? 'active' : ''}`}>
{buttonChannelSub ? 'Active' : 'Deactive'}
</a>
</div>
<div className="toggle-option">
<span>Video Player</span>
<a onClick={() => setExtensionSetting('buttonVideoPlayer', !buttonVideoPlayer)} className={`button ${buttonVideoPlayer ? 'active' : ''}`}>
{buttonVideoPlayer ? 'Active' : 'Deactive'}
</a>
</div>
</div>
</section>
<section>
<label>Tools</label>
<a target='_blank' href='/pages/YTtoLBRY/index.html' className={`filled`}>
Subscription Converter
</a>
<a className='filled' onClick={() => updateRoute('advanced')}>Advanced Settings</a>
</section>
</main>
}
{loading && <div class="overlay">
<span>Loading...</span>
</div>}
</div>
}
function renderPopup() {
render(<WatchOnLbryPopup profile={null} />, document.getElementById('root')!)
getProfile().then((profile) => render(<WatchOnLbryPopup profile={profile} />, document.getElementById('root')!))
}
renderPopup()

View file

@ -1,55 +0,0 @@
#popup {
width: 40em;
overflow: hidden;
margin: auto;
}
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;
}
label {
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
section>* {
padding: 0 1rem;
}
.center {
display: grid;
place-items: center;
}
.left {
display: grid;
justify-items: start;
align-items: center;
text-align: left;
}
.toggle-option {
display: grid;
gap: .5em;
}

14
src/popup/popup.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="popup.tsx" defer></script>
</head>
<body>
<div class="container">
<div id="root" />
</div>
</body>
</html>

4
src/popup/popup.sass Normal file
View file

@ -0,0 +1,4 @@
.radio-label
font-size: 1.1rem
margin: 15px auto
display: block

33
src/popup/popup.tsx Normal file
View file

@ -0,0 +1,33 @@
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'>Where would you like to redirect?</label>
<ButtonRadio value={redirect as string} options={redirectOptions}
onChange={redirect => setSetting('redirect', redirect)} />
<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')!);

View file

@ -1,36 +0,0 @@
import { resolveById } from "../modules/yt/urlResolve"
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}
chrome.runtime.onMessage.addListener(({ method, data }, sender, sendResponse) => {
function resolve(result: Awaited<ReturnType<typeof resolveById>>) {
sendResponse(JSON.stringify(result))
}
(async () => {
switch (method) {
case 'openTab':
{
const { href }: { href: string } = JSON.parse(data)
chrome.tabs.create({ url: href, active: sender.tab?.active, index: sender.tab ? sender.tab.index + 1 : undefined })
}
break
case 'resolveUrl':
try {
const params: Parameters<typeof resolveById> = JSON.parse(data)
// Don't create a new Promise for same ID until on going one is over.
const promise = onGoingLbryPathnameRequest[data] ?? (onGoingLbryPathnameRequest[data] = resolveById(...params))
resolve(await promise)
} catch (error) {
sendResponse(`error: ${(error as any).toString()}`)
console.error(error)
}
finally {
delete onGoingLbryPathnameRequest[data]
}
break
}
})()
return true
})

View file

@ -0,0 +1,28 @@
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);

View file

@ -0,0 +1,29 @@
import { appRedirectUrl } from '../common/lbry-url';
import { getSettingsAsync } from '../common/settings';
// 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());
`
});
});
// 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;
chrome.tabs.sendMessage(tabId, { url });
});

View file

@ -1,363 +1,118 @@
import { h, render } from 'preact' import { h, render } from 'preact';
import { parseYouTubeURLTimeString } from '../modules/yt'
import type { resolveById, ResolveUrlTypes } from '../modules/yt/urlResolve'
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, getTargetPlatfromSettingsEntiries, SourcePlatform, sourcePlatfromSettings, TargetPlatform, targetPlatformSettings } from '../settings';
(async () => { import { parseProtocolUrl } from '../common/lbry-url';
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)) import { getSettingsAsync, LbrySettings, redirectDomains } from '../common/settings';
import { YTDescriptor, ytService } from '../common/yt';
interface Target { interface UpdaterOptions {
platform: TargetPlatform /** invoked if a redirect should be performed */
lbryPathname: string onRedirect?(ctx: UpdateContext): void
type: ResolveUrlTypes /** invoked if a URL is found */
time: number | null onURL?(ctx: UpdateContext): void
}
interface UpdateContext {
descriptor: YTDescriptor
url: string
enabled: boolean
redirect: LbrySettings['redirect']
}
function pauseVideo() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
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(url: URL | Location, { onRedirect, onURL }: UpdaterOptions): Promise<void> {
const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect');
const urlPrefix = redirectDomains[redirect].prefix;
const descriptor = ytService.getId(url.href);
if (!descriptor) return; // couldn't get the ID, so we're done
const res = await resolveYT(descriptor);
if (!res) return; // couldn't find it on lbry, so we're done
const ctx = { descriptor, url: urlPrefix + res, enabled, redirect };
if (onURL) onURL(ctx);
if (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');
} }
interface Source { if (!ownerBar) return;
platform: SourcePlatform const div = document.createElement('div');
id: string div.style.display = 'flex';
type: ResolveUrlTypes ownerBar.insertAdjacentElement('afterend', div);
url: URL return div;
time: number | null }
}
const targetPlatforms = getTargetPlatfromSettingsEntiries() function WatchOnLbryButton({ url }: { url?: string }) {
const settings = await getExtensionSettingsAsync() if (!url) return null;
// Listen Settings Change return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
chrome.storage.onChanged.addListener(async (changes, areaName) => { <a href={url} onClick={pauseVideo} role='button'
if (areaName !== 'local') return children={<div>
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue]))) <img src={chrome.runtime.getURL('icons/lbry-logo.svg')} height={10} width={14}
}) style={{ marginRight: 12, transform: 'scale(1.75)' }} />
Watch on LBRY
const buttonMountPoint = document.createElement('div') </div>}
buttonMountPoint.style.display = 'inline-flex'
const playerButtonMountPoint = document.createElement('div')
playerButtonMountPoint.style.display = 'inline-flex'
function WatchOnLbryButton({ source, target }: { source?: Source, target?: Target }) {
if (!target || !source) return null
const url = getLbryUrlByTarget(target)
return <div
style={{ style={{
display: 'grid', borderRadius: '2px',
gridTemplateRows: '36px', backgroundColor: '#075656',
gridAutoColumns: 'auto', border: '0',
alignContent: 'center' color: 'whitesmoke',
}} padding: '10px 16px',
> marginRight: '5px',
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button' fontSize: '14px',
style={{ textDecoration: 'none',
display: 'flex', }} />
alignItems: 'center', </div>
gap: '7px', }
borderRadius: '2px',
padding: '0 16px',
margin: '0 4px',
fontWeight: 'bold',
border: '0',
color: 'whitesmoke',
fontSize: '14px',
textDecoration: 'none',
backgroundColor: target.platform.theme,
backgroundImage: target.platform.theme,
...target.platform.button.style?.button,
}}
onClick={() => findVideoElementAwait(source).then((videoElement) => {
videoElement.pause()
})}
>
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
</a>
</div>
}
function WatchOnLbryPlayerButton({ source, target }: { source?: Source, target?: Target }) {
if (!target || !source) return null
const url = getLbryUrlByTarget(target)
return <div
style={{
display: 'grid',
gridTemplateRows: '36px',
gridAutoColumns: 'auto',
alignContent: 'center'
}}
>
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button'
style={{
display: 'flex',
alignItems: 'center',
gap: '7px',
borderRadius: '2px',
paddingRight: '10px',
fontWeight: 'bold',
border: '0',
color: 'whitesmoke',
fontSize: '14px',
textDecoration: 'none',
...target.platform.button.style?.button,
}}
onClick={() => findVideoElementAwait(source).then((videoElement) => {
videoElement.pause()
})}
>
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
</a>
</div>
}
function updateButtons(params: { source: Source, target: Target } | null): void {
if (!params) {
render(<WatchOnLbryButton />, buttonMountPoint)
render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
return
}
{
const mountPlayerButtonBefore = settings.buttonVideoPlayer ?
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountPlayerButtonBefore) :
null
if (!mountPlayerButtonBefore) render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
else {
if (playerButtonMountPoint.getAttribute('data-id') !== params.source.id) {
mountPlayerButtonBefore.parentElement?.insertBefore(playerButtonMountPoint, mountPlayerButtonBefore)
playerButtonMountPoint.setAttribute('data-id', params.source.id)
}
render(<WatchOnLbryPlayerButton target={params.target} source={params.source} />, playerButtonMountPoint)
}
}
{
const mountButtonBefore = settings[(`button${params.source.type[0].toUpperCase() + params.source.type.substring(1)}Sub`) as 'buttonVideoSub' | 'buttonChannelSub'] ?
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountButtonBefore[params.source.type]) :
null
if (!mountButtonBefore) render(<WatchOnLbryButton />, buttonMountPoint)
else {
if (buttonMountPoint.getAttribute('data-id') !== params.source.id) {
mountButtonBefore.parentElement?.insertBefore(buttonMountPoint, mountButtonBefore)
buttonMountPoint.setAttribute('data-id', params.source.id)
}
render(<WatchOnLbryButton target={params.target} source={params.source} />, buttonMountPoint)
}
}
}
async function findVideoElementAwait(source: Source) {
let videoElement: HTMLVideoElement | null = null
while (!(videoElement = document.querySelector(source.platform.htmlQueries.videoPlayer))) await sleep(200)
return videoElement
}
async function getSourceByUrl(url: URL): Promise<Source | null> {
const platform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
if (!platform) return null
if (url.pathname === '/watch' && url.searchParams.has('v')) {
return {
id: url.searchParams.get('v')!,
platform,
time: url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null,
type: 'video',
url
}
}
else if (url.pathname.startsWith('/channel/')) {
return {
id: url.pathname.substring("/channel/".length),
platform,
time: null,
type: 'channel',
url
}
}
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)
const id = content.substring(startsAt, endsAt)
return {
id,
platform,
time: null,
type: 'channel',
url
}
}
return null
}
async function getTargetsBySources(...sources: Source[]) {
const params: Parameters<typeof requestResolveById>[0] = sources.map((source) => ({ id: source.id, type: source.type }))
const platform = targetPlatformSettings[settings.targetPlatform]
const results = await requestResolveById(params) ?? []
const targets: Record<string, Target | null> = Object.fromEntries(
sources.map((source) => {
const result = results[source.id]
if (!result) return [
source.id,
null
]
return [
source.id,
{
type: result.type,
lbryPathname: result.id,
platform,
time: source.time
}
]
})
)
return targets
}
// 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 response = await new Promise<string | null | 'error'>((resolve) => chrome.runtime.sendMessage({ method: 'resolveUrl', data: JSON.stringify(params) }, resolve))
if (response?.startsWith('error:')) {
console.error("Background error on:", params)
throw new Error(`Background error. ${response ?? ''}`)
}
return response ? JSON.parse(response) : null
}
// Request new tab
async function openNewTab(url: URL) {
chrome.runtime.sendMessage({ method: 'openTab', data: JSON.stringify({ href: url.href }) })
}
function findTargetFromSourcePage(source: Source): Target | null {
const linksContainer =
source.type === 'video' ?
document.querySelector(source.platform.htmlQueries.videoDescription) :
source.platform.htmlQueries.channelLinks ? document.querySelector(source.platform.htmlQueries.channelLinks) : null
if (linksContainer) {
const anchors = Array.from(linksContainer.querySelectorAll<HTMLAnchorElement>('a'))
for (const anchor of anchors) {
if (!anchor.href) continue
const url = new URL(anchor.href)
let lbryURL: URL | null = null
// Extract real link from youtube's redirect link
if (source.platform === sourcePlatfromSettings['youtube.com']) {
if (!targetPlatforms.some(([key, platform]) => url.searchParams.get('q')?.startsWith(platform.domainPrefix))) continue
lbryURL = new URL(url.searchParams.get('q')!)
}
// Just directly use the link itself on other platforms
else {
if (!targetPlatforms.some(([key, platform]) => url.href.startsWith(platform.domainPrefix))) continue
lbryURL = new URL(url.href)
}
if (lbryURL) {
return {
lbryPathname: lbryURL.pathname.substring(1),
time: null,
type: lbryURL.pathname.substring(1).includes('/') ? 'video' : 'channel',
platform: targetPlatformSettings[settings.targetPlatform]
}
}
}
}
return null
}
function getLbryUrlByTarget(target: Target) {
const url = new URL(`${target.platform.domainPrefix}${target.lbryPathname}`)
if (target.time) url.searchParams.set('t', target.time.toFixed(0))
return url
}
// Master Loop const mountPointPromise = findMountPoint();
for (
let url = new URL(location.href),
urlHrefCache: string | null = null;
;
urlHrefCache = url.href,
url = new URL(location.href)
) {
await sleep(500)
try {
const source = await getSourceByUrl(new URL(location.href))
if (!source) {
updateButtons(null)
continue
}
const target = (await getTargetsBySources(source))[source.id] ?? findTargetFromSourcePage(source)
if (!target) {
updateButtons(null)
continue
}
// Update Buttons const handle = (url: URL | Location) => handleURLChange(url, {
if (urlHrefCache !== url.href) updateButtons(null) async onURL({ descriptor: { type }, url }) {
// If target is a video target add timestampt to it const mountPoint = await mountPointPromise;
if (target.type === 'video') { if (type !== 'video' || !mountPoint) return;
const videoElement = document.querySelector<HTMLVideoElement>(source.platform.htmlQueries.videoPlayer) render(<WatchOnLbryButton url={url} />, mountPoint)
if (videoElement) target.time = videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null },
} onRedirect({ redirect, url }) {
updateButtons({ target, source }) if (redirect === 'app') return openApp(url);
location.replace(url);
},
});
// Redirect // handle the location on load of the page
if ( handle(location);
source.type === target.type &&
(
(
settings.redirectVideo &&
source.type === 'video' && !source.url.searchParams.has('list')
) ||
(
settings.redirectVideoPlaylist &&
source.type === 'video' && source.url.searchParams.has('list')
) ||
(
settings.redirectChannel &&
source.type === 'channel'
)
)
) {
if (url.href === urlHrefCache) continue
const lbryURL = getLbryUrlByTarget(target) /*
* 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 (req: { url: string }) => {
mountPointPromise.then(mountPoint => mountPoint && render(<WatchOnLbryButton />, mountPoint))
if (!req.url) return;
handle(new URL(req.url));
});
if (source.type === 'video') { chrome.storage.onChanged.addListener((changes, areaName) => {
findVideoElementAwait(source).then((videoElement) => videoElement.pause()) if (areaName !== 'local' || !changes.redirect) return;
} handle(new URL(location.href))
});
if (target.platform === targetPlatformSettings.app) {
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
// Replace is being used so browser doesnt start an empty window
// Its not gonna be able to replace anyway, since its a LBRY Uri
location.replace(lbryURL)
}
else {
openNewTab(lbryURL)
if (window.history.length === 1)
window.close()
else
window.history.back()
}
}
} catch (error) {
console.error(error)
}
}
})()

View file

@ -1,2 +0,0 @@
import './settings/background'
import './scripts/background'

View file

@ -1,33 +0,0 @@
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)

View file

@ -1,201 +0,0 @@
import type { JSX } from "preact"
import { useEffect, useReducer } from "preact/hooks"
import type { ResolveUrlTypes } from "../modules/yt/urlResolve"
export interface ExtensionSettings extends Record<string, string | number | boolean | null | undefined>{
targetPlatform: TargetPlatformName
urlResolver: YTUrlResolverName,
redirectVideo: boolean,
redirectChannel: boolean,
redirectVideoPlaylist: boolean,
buttonVideoSub: boolean
buttonVideoPlayer: boolean
buttonChannelSub: boolean
publicKey: string | null,
privateKey: string | null
}
export const DEFAULT_SETTINGS: ExtensionSettings = {
targetPlatform: 'odysee',
urlResolver: 'odyseeApi',
redirectVideo: false,
redirectChannel: false,
redirectVideoPlaylist: false,
buttonVideoSub: true,
buttonVideoPlayer: true,
buttonChannelSub: true,
privateKey: null,
publicKey: null
}
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: {
platformNameText: 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 = {
'madiator.com': targetPlatform({
domainPrefix: 'https://madiator.com/',
displayName: 'Madiator.com',
theme: 'linear-gradient(130deg, #499375, #43889d)',
button: {
platformNameText: '',
icon: chrome.runtime.getURL('assets/icons/lbry/madiator-logo.svg'),
style: {
button: { flexDirection: 'row-reverse' },
icon: { }
}
}
}),
odysee: targetPlatform({
domainPrefix: 'https://odysee.com/',
displayName: 'Odysee',
theme: 'linear-gradient(130deg, #c63d59, #f77937)',
button: {
platformNameText: 'Odysee',
icon: chrome.runtime.getURL('assets/icons/lbry/odysee-logo.svg')
}
}),
app: targetPlatform({
domainPrefix: 'lbry://',
displayName: 'LBRY App',
theme: 'linear-gradient(130deg, #499375, #43889d)',
button: {
platformNameText: 'LBRY',
icon: chrome.runtime.getURL('assets/icons/lbry/lbry-logo.svg')
}
}),
}
const sourcePlatform = (o: {
hostnames: string[]
htmlQueries: {
mountPoints: {
mountButtonBefore: Record<ResolveUrlTypes, string>,
mountPlayerButtonBefore: string,
}
videoPlayer: string,
videoDescription: string
channelLinks: 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: {
mountPoints: {
mountButtonBefore: {
video: 'ytd-video-owner-renderer~#subscribe-button',
channel: '#channel-header-container #buttons #subscribe-button'
},
mountPlayerButtonBefore: 'ytd-watch-flexy ytd-player .ytp-right-controls',
},
videoPlayer: '#ytd-player video',
videoDescription: 'ytd-video-secondary-info-renderer #description',
channelLinks: '#channel-header #links-holder'
}
}),
"yewtu.be": sourcePlatform({
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
htmlQueries: {
mountPoints: {
mountButtonBefore:
{
video: '#subscribe',
channel: '#subscribe'
},
mountPlayerButtonBefore: '#player-container ~ .h-box > h1 > a',
},
videoPlayer: '#player-container video',
videoDescription: '#descriptionWrapper',
channelLinks: '#descriptionWrapper'
}
})
}
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
}),
madiatorFinder: ytUrlResolver({
name: "Madiator Finder",
href: "https://finder.madiator.com/api/v1",
signRequest: true
}),
/* madiatorFinderLocal: ytUrlResolver({
name: "Madiator Finder Local",
href: "http://127.0.0.1:3001/api/v1",
signRequest: true
}) */
}

6
src/tools/README.md Normal file
View file

@ -0,0 +1,6 @@
# Getting your subscription data
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

88
src/tools/YTtoLBRY.css Normal file
View file

@ -0,0 +1,88 @@
body {
--color-text: whitesmoke;
--color-backround: rgb(28, 31, 34);
--color-card: #2a2e32;
--color-primary: rgb(43, 187, 144);
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;
}
}

13
src/tools/YTtoLBRY.html Normal file
View file

@ -0,0 +1,13 @@
<!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>

62
src/tools/YTtoLBRY.tsx Normal file
View file

@ -0,0 +1,62 @@
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')!);

View file

@ -41,7 +41,6 @@
// "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
"importsNotUsedAsValues": "error", /* Import types always with `import type` */
/* Module Resolution Options */ /* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
@ -54,6 +53,7 @@
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ "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. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */ /* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "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. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */

10083
yarn.lock

File diff suppressed because it is too large Load diff