Compare commits
175 commits
Author | SHA1 | Date | |
---|---|---|---|
|
60735f9507 | ||
|
8c26ae0872 | ||
|
6ba1a373a9 | ||
|
c1b53d90a1 | ||
|
529931c43b | ||
|
351ca5c6c3 | ||
|
ca65a0288d | ||
|
309806605f | ||
|
7fa57b9e88 | ||
|
21f6ffc85b | ||
|
1ee722b6ff | ||
|
28ef256c40 | ||
|
a41b51e454 | ||
|
5c9910dff1 | ||
|
59a5e0dee2 | ||
|
e4fdc4e831 | ||
|
9c26d553f5 | ||
|
f9c70dd90d | ||
|
9ed962df96 | ||
|
a95b46bf0c | ||
|
913282290e | ||
|
6af354de0c | ||
|
15f03b3bcc | ||
|
64570b83bf | ||
|
4295dbe1fe | ||
|
619d66d6aa | ||
|
b26d540a4a | ||
|
32290e5f63 | ||
|
c089de3f0f | ||
|
fcdc5cb77a | ||
|
e0d514916e | ||
|
376c7b185a | ||
|
73147fdbcd | ||
|
9edd318a35 | ||
|
f5c0fc450b | ||
|
259582cd3e | ||
|
38f2d42404 | ||
|
7b585b27a9 | ||
|
6721966e5b | ||
|
f087d0d1e5 | ||
|
de43b990f2 | ||
|
8f88dbebe7 | ||
|
0aa64bb8b2 | ||
|
f4dd0306fc | ||
|
29c9b2829a | ||
|
a6472799a1 | ||
|
1f29fc7c29 | ||
|
ca4c486ea7 | ||
|
4102177212 | ||
|
da8dee83f9 | ||
|
ce11d4fdf3 | ||
|
1eb21eb51f | ||
|
a0577b4530 | ||
|
c895d53253 | ||
|
66cb8ccccf | ||
|
f072c95051 | ||
|
0ba4efd43e | ||
|
066c400cce | ||
|
63a251c0ae | ||
|
be310e6cb1 | ||
|
cebdf480fe | ||
|
7076a657fc | ||
|
dfa1fd03a8 | ||
|
cddd415e22 | ||
|
6aa507c53d | ||
|
0bcf69e99b | ||
|
ccf6d2d990 | ||
|
c4231c29d6 | ||
|
cf85b39d6a | ||
|
5d9d0416a3 | ||
|
e2e7426b5b | ||
|
6b8f193bfd | ||
|
c2f894ec61 | ||
|
ce40824a0c | ||
|
f86affc093 | ||
|
deed10423a | ||
|
caa0d196ed | ||
|
a27d5a285b | ||
|
e8d7a349f4 | ||
|
fe5c38bfed | ||
|
53eb4a0c8e | ||
|
f1f4c335e9 | ||
|
3ee7e530d6 | ||
|
81f1742289 | ||
|
a0f66bc062 | ||
|
450ca8cef6 | ||
|
651ca5b4dc | ||
|
2a3771e45a | ||
|
365173d316 | ||
|
3e60ed295f | ||
|
8c5e2d68e0 | ||
|
222a71948b | ||
|
bd33aa833f | ||
|
3edc78c36a | ||
|
27207e0f9c | ||
|
3692ccba89 | ||
|
bb0dc3c4f4 | ||
|
b17f2f4e2e | ||
|
4a23670e4d | ||
|
92fbdd727d | ||
|
6ef5459d7a | ||
|
e501a8b828 | ||
|
324d3800de | ||
|
e01a3990c8 | ||
|
79ba91a1f1 | ||
|
7f3f8919f0 | ||
|
c94ec17fa3 | ||
|
585a232021 | ||
|
2b38c809ca | ||
|
093deaa8fd | ||
|
fde3d7b897 | ||
|
bccbce8db1 | ||
|
766b6cf990 | ||
|
889c64c0a6 | ||
|
89ce1aeb31 | ||
|
3a07d7f82b | ||
|
4653f77be4 | ||
|
94cda17b99 | ||
|
2c75082af9 | ||
|
8f75c67601 | ||
|
5bcd33890d | ||
|
d32958852a | ||
|
5396936586 | ||
|
06524954e7 | ||
|
86879183b2 | ||
|
30b65454ff | ||
|
30f077ba38 | ||
|
610b47d1e4 | ||
|
2b91436900 | ||
|
4f8e807a65 | ||
|
cb4b4f4b2e | ||
|
75cb9cf01d | ||
|
7727d04157 | ||
|
719ff06caf | ||
|
4cdcc4c9a4 | ||
|
205a8fd151 | ||
|
1e7293826a | ||
|
b750c86b88 | ||
|
475c38ba0c | ||
|
ff83dfc62d | ||
|
685f9615ca | ||
|
73508ff532 | ||
|
70ad28ba22 | ||
|
a0b6182a96 | ||
|
6597acd0a5 | ||
|
c2aacaf307 | ||
|
c24fd17013 | ||
|
a7b66660ea | ||
|
7c50daf7fc | ||
|
a671df51a0 | ||
|
668018d89d | ||
|
2b3d43e0dc | ||
|
19967c5ecc | ||
|
2352237bf5 | ||
|
00f2bb82f8 | ||
|
77d95ab61c | ||
|
75274005ee | ||
|
682af767c9 | ||
|
9b68487ecc | ||
|
336e3050f0 | ||
|
6b4a377058 | ||
|
279f7faff7 | ||
|
8f30825edd | ||
|
4ab8fdce5e | ||
|
58e6c119f4 | ||
|
ba5beca60a | ||
|
dd16ce1041 | ||
|
63d91a2ee1 | ||
|
4220ea11c7 | ||
|
47d27b0671 | ||
|
b17a7020fd | ||
|
3bab9d15ee | ||
|
adab9addf5 | ||
|
f3cf3526f5 | ||
|
3b4bc7910f |
16
.devcontainer/Dockerfile
Normal file
|
@ -0,0 +1,16 @@
|
|||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster
|
||||
ARG VARIANT="16-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
# ARG EXTRA_NODE_VERSION=10
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node packages
|
||||
# RUN su node -c "npm install -g <your-package-list -here>"
|
36
.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "14-bullseye"
|
||||
}
|
||||
},
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||
"javascript.format.placeOpenBraceOnNewLineForFunctions": false,
|
||||
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||
"typescript.format.placeOpenBraceOnNewLineForFunctions": false
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"bierner.folder-source-actions",
|
||||
"jbockle.jbockle-format-files",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install",
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"python": "latest"
|
||||
}
|
||||
}
|
23
.github/workflows/contributors.yml
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
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'
|
2
.gitignore
vendored
|
@ -1,8 +1,8 @@
|
|||
.cache
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
web-ext-artifacts
|
||||
yarn-error.log
|
||||
.devcontainer
|
||||
|
||||
.DS_Store
|
||||
|
|
15
CHANGELOG.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
# Changelog
|
||||
|
||||
## [1.7.5](https://github.com/LBRYFoundation/Watch-on-LBRY/releases/tag/1.7.5) (2021-12-12)
|
||||
|
||||
**Closed issues:**
|
||||
|
||||
- Subscription Converter feature doesn't seem to work [\#64](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/64)
|
||||
- Redirect with timestamp [\#57](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/57)
|
||||
- Should default to odysee.com instead of lbry.tv [\#53](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/53)
|
||||
- Instead of redirect. Show a popup from the extension icon [\#38](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/38)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Update ytContent.tsx [\#70](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/70) ([Shiba](https://github.com/DeepDoge))
|
||||
- Madiator icon added [\#71](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/71) ([Shiba](https://github.com/DeepDoge))
|
81
README.md
|
@ -1,5 +1,5 @@
|
|||
## Looking for contributors :)
|
||||

|
||||

|
||||
# 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!
|
||||
|
@ -62,6 +62,85 @@ Pull requests are welcome. For major changes, please open an issue first to disc
|
|||
|
||||
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
|
||||
[GPL-3.0 License](LICENSE)
|
||||
|
||||
|
|
9
global.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
declare module '*.md' {
|
||||
var _: string
|
||||
export default _
|
||||
}
|
||||
|
||||
declare namespace chrome
|
||||
{
|
||||
export const action = chrome.browserAction
|
||||
}
|
56
manifest.v2.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Watch on LBRY",
|
||||
"version": "2.0.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
|
||||
}
|
||||
}
|
59
manifest.v3.json
Normal file
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
17498
package-lock.json
generated
Normal file
16
package.json
|
@ -5,11 +5,19 @@
|
|||
"scripts": {
|
||||
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/",
|
||||
"watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/",
|
||||
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
||||
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
||||
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
|
||||
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
|
||||
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"build": "npm-run-all -l -p build:parcel build:assets",
|
||||
"watch": "npm-run-all -l -p watch:parcel watch:assets",
|
||||
"clear:dist": "rm -r ./dist ; mkdir ./dist",
|
||||
"build:base": "npm-run-all -l -p build:parcel build:assets",
|
||||
"pick:manifest:v2": "cp -b ./manifest.v2.json ./dist/manifest.json",
|
||||
"pick:manifest:v3": "cp -b ./manifest.v3.json ./dist/manifest.json",
|
||||
"build:v2": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v2",
|
||||
"build:v3": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v3",
|
||||
"build": "rm -r ./build ; mkdir ./build && npm run build:v2 && zip -r ./build/manifest-v2.zip ./dist && npm run build:v3 && zip -r ./build/manifest-v3.zip ./dist",
|
||||
"watch:v2": "npm run clear:dist ; npm run pick:manifest:v2 && npm-run-all -l -p watch:parcel watch:assets",
|
||||
"watch:v3": "npm run clear:dist ; npm run pick:manifest:v3 && npm-run-all -l -p watch:parcel watch:assets",
|
||||
"watch": "npm run watch:v3",
|
||||
"start:chrome": "web-ext run -t chromium --source-dir ./dist",
|
||||
"start:firefox": "web-ext run --source-dir ./dist",
|
||||
"test": "jest"
|
||||
|
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 544 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
158
src/assets/styles/common.css
Normal file
|
@ -0,0 +1,158 @@
|
|||
:root {
|
||||
--color-master: #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;
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
@import '../style'
|
||||
|
||||
.ButtonRadio
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.radio-button
|
||||
@extend .button
|
||||
margin: 6px
|
||||
|
||||
.radio-button.checked
|
||||
@extend .button.active
|
||||
|
||||
input[type="radio"]
|
||||
opacity: 0
|
||||
position: absolute
|
|
@ -1,31 +0,0 @@
|
|||
import { h } from 'preact';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import './ButtonRadio.sass';
|
||||
|
||||
export interface SelectionOption {
|
||||
value: string
|
||||
display: string
|
||||
}
|
||||
|
||||
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
|
||||
name?: string;
|
||||
onChange(redirect: string): void;
|
||||
value: T extends SelectionOption ? T['value'] : T;
|
||||
options: T[];
|
||||
}
|
||||
|
||||
const getAttr = (x: string | SelectionOption, key: keyof SelectionOption): string => typeof x === 'string' ? x : x[key];
|
||||
|
||||
export default function ButtonRadio<T extends string | SelectionOption = string>({ name = 'buttonRadio', onChange, options, value }: ButtonRadioProps<T>) {
|
||||
/** If it's a string, return the string, if it's a SelectionOption get the selection option property */
|
||||
return <div className='ButtonRadio'>
|
||||
{options.map(o => ({ o: getAttr(o, 'value'), display: getAttr(o, 'display') })).map(({ o, display }) =>
|
||||
<div key={o} className={classnames('radio-button', { 'checked': value === o })}
|
||||
onClick={() => o !== value && onChange(o)}>
|
||||
<input name={name} value={o} type='radio' checked={value === o} />
|
||||
<label>{display}</label>
|
||||
</div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from './lbry-url';
|
||||
|
||||
describe('web url parsing', () => {
|
||||
const testCases: [string, string | undefined][] = [
|
||||
['https://lbry.tv/@test:7/foo-123:7', 'lbry://@test:7/foo-123:7'],
|
||||
['https://lbry.tv/@test1:c/foo:8', 'lbry://@test1:c/foo:8'],
|
||||
['https://lbry.tv/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', 'lbry://@test1:0/foo-bar-2-baz-7:e'],
|
||||
['https://lbry.tv/@test:7', 'lbry://@test:7'],
|
||||
['https://lbry.tv/@test:c', 'lbry://@test:c'],
|
||||
['https://lbry.tv/$/discover?t=foo%20bar', undefined],
|
||||
['https://lbry.tv/$/signup?redirect=/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', undefined],
|
||||
];
|
||||
|
||||
test.each(testCases)('redirect %s', (url, expected) => {
|
||||
expect(appRedirectUrl(url)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app url parsing', () => {
|
||||
const testCases: Array<[string, string[]]> = [
|
||||
['test', ['test']],
|
||||
['@test', ['@test']],
|
||||
['lbry://@test$1/stuff', ['@test$1', 'stuff']],
|
||||
];
|
||||
|
||||
test.each(testCases)('redirect %s', (url, expected) => {
|
||||
expect(parseProtocolUrl(url)).toEqual(expected);
|
||||
});
|
||||
});
|
|
@ -1,142 +0,0 @@
|
|||
// Port of https://github.com/lbryio/lbry-sdk/blob/master/lbry/schema/url.py
|
||||
|
||||
interface UrlOptions {
|
||||
/**
|
||||
* Whether or not to encodeURIComponent the path segments.
|
||||
* Doing so is a workaround such that browsers interpret it as a valid URL in a way that the desktop app understands.
|
||||
*/
|
||||
encode?: boolean
|
||||
}
|
||||
|
||||
const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source;
|
||||
|
||||
/** Creates a named regex group */
|
||||
const named = (name: string, regex: string) => `(?<${name}>${regex})`;
|
||||
/** Creates a non-capturing group */
|
||||
const group = (regex: string) => `(?:${regex})`;
|
||||
/** Allows for one of the patterns */
|
||||
const oneOf = (...choices: string[]) => group(choices.join('|'));
|
||||
/** Create an lbry url claim */
|
||||
const claim = (name: string, prefix = '') => group(
|
||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
||||
+ oneOf(
|
||||
group(':' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
|
||||
group('\\*' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
||||
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
|
||||
) + '?'
|
||||
);
|
||||
|
||||
/** Create an lbry url claim, but use the old pattern for claims */
|
||||
const legacyClaim = (name: string, prefix = '') => group(
|
||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
||||
+ oneOf(
|
||||
group('#' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
|
||||
group(':' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
||||
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
|
||||
) + '?');
|
||||
|
||||
export const builder = { named, group, oneOf, claim, legacyClaim, invalidNamesRegex };
|
||||
|
||||
/** Creates a pattern to parse lbry protocol URLs. Unused, but I left it here. */
|
||||
function createProtocolUrlRegex(legacy = false) {
|
||||
const claim = legacy ? builder.legacyClaim : builder.claim;
|
||||
return new RegExp('^' + named('scheme', 'lbry://') + '?' + oneOf(
|
||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||
claim('channel', '@'),
|
||||
claim('stream'),
|
||||
) + '$');
|
||||
}
|
||||
|
||||
/** Creates a pattern to match lbry.tv style sites by their pathname */
|
||||
function createWebUrlRegex(legacy = false) {
|
||||
const claim = legacy ? builder.legacyClaim : builder.claim;
|
||||
return new RegExp('^/' + oneOf(
|
||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||
claim('channel', '@'),
|
||||
claim('stream'),
|
||||
) + '$');
|
||||
}
|
||||
|
||||
/** Pattern for lbry.tv style sites */
|
||||
export const URL_REGEX = createWebUrlRegex();
|
||||
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex();
|
||||
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true);
|
||||
|
||||
/**
|
||||
* Encapsulates a lbry url path segment.
|
||||
* Handles `StreamClaimNameAndModifier' and `ChannelClaimNameAndModifier`
|
||||
*/
|
||||
export class PathSegment {
|
||||
constructor(public name: string,
|
||||
public claimID?: string,
|
||||
public sequence?: number,
|
||||
public amountOrder?: number) { }
|
||||
|
||||
static fromMatchGroup(segment: string, groups: Record<string, string>) {
|
||||
return new PathSegment(
|
||||
groups[`${segment}_name`],
|
||||
groups[`${segment}_claim_id`],
|
||||
parseInt(groups[`${segment}_sequence`]),
|
||||
parseInt(groups[`${segment}_amount_order`])
|
||||
);
|
||||
}
|
||||
|
||||
/** Prints the segment */
|
||||
toString() {
|
||||
if (this.claimID) return `${this.name}:${this.claimID}`;
|
||||
if (this.sequence) return `${this.name}*${this.sequence}`;
|
||||
if (this.amountOrder) return `${this.name}$${this.amountOrder}`;
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function
|
||||
*
|
||||
* @param ptn pattern to use; specific to the patterns defined in this file
|
||||
* @param url the url to try to parse
|
||||
* @returns an array of path segments; if invalid, will return an empty array
|
||||
*/
|
||||
function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { encode: false }): string[] {
|
||||
const match = url.match(ptn)?.groups;
|
||||
if (!match) return [];
|
||||
|
||||
const segments = match['channel_name'] ? ['channel']
|
||||
: match['channel_with_stream_name'] ? ['channel_with_stream', 'stream_in_channel']
|
||||
: match['stream_name'] ? ['stream']
|
||||
: null;
|
||||
|
||||
if (!segments) throw new Error(`${url} matched the overall pattern, but could not determine type`);
|
||||
|
||||
return segments.map(s => PathSegment.fromMatchGroup(s, match).toString())
|
||||
.map(s => options.encode ? encodeURIComponent(s) : s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the lbry protocl URL from the frontend URL
|
||||
*
|
||||
* @param url lbry frontend URL
|
||||
* @param options options for the redirect
|
||||
*/
|
||||
export function appRedirectUrl(url: string, options?: UrlOptions): string | undefined {
|
||||
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options);
|
||||
if (segments.length === 0) return;
|
||||
const path = segments.join('/');
|
||||
|
||||
return `lbry://${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a lbry protocol and returns its constituent path segments. Attempts the spec compliant and then the old URL schemes.
|
||||
*
|
||||
* @param url the lbry url
|
||||
* @returns an array of path segments; if invalid, will return an empty array
|
||||
*/
|
||||
export function parseProtocolUrl(url: string, options: UrlOptions = { encode: false }): string[] {
|
||||
for (const ptn of [PROTOCOL_URL_REGEX, PROTOCOL_URL_REGEX_LEGACY]) {
|
||||
const segments = patternSegmenter(ptn, url, options);
|
||||
if (segments.length === 0) continue;
|
||||
return segments;
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
export type PlatformName = 'madiator.com' | 'odysee' | 'app'
|
||||
|
||||
export interface PlatformSettings
|
||||
{
|
||||
domainPrefix: string
|
||||
display: string
|
||||
theme: string
|
||||
}
|
||||
|
||||
export const platformSettings: Record<PlatformName, PlatformSettings> = {
|
||||
'madiator.com': { domainPrefix: 'https://madiator.com/', display: 'Madiator.com', theme: '#075656' },
|
||||
odysee: { domainPrefix: 'https://odysee.com/', display: 'Odysee', theme: '#1e013b' },
|
||||
app: { domainPrefix: 'lbry://', display: 'App', theme: '#075656' },
|
||||
};
|
||||
|
||||
export const getPlatfromSettingsEntiries = () => {
|
||||
return Object.entries(platformSettings) as any as [Extract<keyof typeof platformSettings, string>, PlatformSettings][]
|
||||
}
|
||||
|
||||
export interface LbrySettings {
|
||||
enabled: boolean
|
||||
platform: PlatformName
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, platform: 'odysee' };
|
||||
|
||||
export function getSettingsAsync<K extends Array<keyof LbrySettings>>(...keys: K): Promise<Pick<LbrySettings, K[number]>> {
|
||||
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
$background-color: #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
|
|
@ -1,30 +0,0 @@
|
|||
import { useReducer, useEffect } from 'preact/hooks';
|
||||
import { DEFAULT_SETTINGS } from './settings';
|
||||
|
||||
/**
|
||||
* A hook to read the settings from local storage
|
||||
*
|
||||
* @param initial the default value. Must have all relevant keys present and should not change
|
||||
*/
|
||||
export function useSettings<T extends object>(initial: T) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
|
||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||
useEffect(() => {
|
||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== 'local') return;
|
||||
const changeSet = Object.keys(changes)
|
||||
.filter(k => Object.keys(initial).includes(k))
|
||||
.map(k => [k, changes[k].newValue]);
|
||||
if (changeSet.length === 0) return; // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeSet));
|
||||
};
|
||||
chrome.storage.onChanged.addListener(changeListener);
|
||||
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
|
||||
return () => chrome.storage.onChanged.removeListener(changeListener);
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** A hook to read watch on lbry settings from local storage */
|
||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS);
|
139
src/common/yt.ts
|
@ -1,139 +0,0 @@
|
|||
import chunk from 'lodash/chunk';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
|
||||
const LBRY_API_HOST = 'https://api.odysee.com';
|
||||
const QUERY_CHUNK_SIZE = 300;
|
||||
|
||||
interface YtResolverResponse {
|
||||
success: boolean;
|
||||
error: object | null;
|
||||
data: {
|
||||
videos?: Record<string, string>;
|
||||
channels?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
interface YtSubscription {
|
||||
id: string;
|
||||
etag: string;
|
||||
title: string;
|
||||
snippet: {
|
||||
description: string;
|
||||
resourceId: {
|
||||
channelId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''));
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort();
|
||||
reject(new DOMException(`Could not read ${file.name}`));
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export interface YTDescriptor {
|
||||
id: string
|
||||
type: 'channel' | 'video'
|
||||
}
|
||||
|
||||
export const ytService = {
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml');
|
||||
opmlContents = ''
|
||||
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);
|
||||
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
|
||||
*/
|
||||
readCsv(csvContent: string): string[] {
|
||||
const rows = csvContent.split('\n')
|
||||
csvContent = ''
|
||||
return rows.map((row) => row.substr(0, row.indexOf(',')))
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
142
src/components/dialogs.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
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
|
@ -1,4 +0,0 @@
|
|||
declare module '*.md' {
|
||||
var _: string;
|
||||
export default _;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"name": "Watch on LBRY",
|
||||
"version": "1.7.5",
|
||||
"permissions": [
|
||||
"https://www.youtube.com/",
|
||||
"https://invidio.us/channel/*",
|
||||
"https://invidio.us/watch?v=*",
|
||||
"https://api.odysee.com/*",
|
||||
"https://lbry.tv/*",
|
||||
"https://odysee.com/*",
|
||||
"https://madiator.com/*",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://www.youtube.com/*"
|
||||
],
|
||||
"js": [
|
||||
"scripts/ytContent.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"scripts/storageSetup.js",
|
||||
"scripts/tabOnUpdated.js"
|
||||
],
|
||||
"persistent": false
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "Watch on LBRY",
|
||||
"default_popup": "popup/popup.html"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
"popup.html",
|
||||
"tools/YTtoLBRY.html",
|
||||
"icons/lbry/lbry-logo.svg",
|
||||
"icons/lbry/odysee-logo.svg",
|
||||
"icons/lbry/madiator-logo.svg"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/wol/icon16.png",
|
||||
"48": "icons/wol/icon48.png",
|
||||
"128": "icons/wol/icon128.png"
|
||||
},
|
||||
"manifest_version": 2
|
||||
}
|
200
src/modules/crypto/index.ts
Normal file
|
@ -0,0 +1,200 @@
|
|||
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
|
||||
}
|
||||
}
|
15
src/modules/file/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''))
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort()
|
||||
reject(new DOMException(`Could not read ${file.name}`))
|
||||
})
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
83
src/modules/yt/index.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
interface YtExportedJsonSubscription {
|
||||
id: string
|
||||
etag: string
|
||||
title: string
|
||||
snippet: {
|
||||
description: string
|
||||
resourceId: {
|
||||
channelId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml')
|
||||
opmlContents = ''
|
||||
return Array.from(opml.querySelectorAll('outline > outline'))
|
||||
.map(outline => outline.getAttribute('xmlUrl'))
|
||||
.filter((url): url is string => !!url)
|
||||
.map(url => getChannelId(url))
|
||||
.filter((url): url is string => !!url) // we don't want it if it's empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions JSON file
|
||||
*
|
||||
* @param jsonContents a JSON file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromJson(jsonContents: string): string[] {
|
||||
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents)
|
||||
jsonContents = ''
|
||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions CSV file
|
||||
*
|
||||
* @param csvContent a CSV file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromCsv(csvContent: string): string[] {
|
||||
const rows = csvContent.split('\n')
|
||||
csvContent = ''
|
||||
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the channelID from a YT URL.
|
||||
*
|
||||
* Handles these two types of YT URLs:
|
||||
* * /feeds/videos.xml?channel_id=*
|
||||
* * /channel/*
|
||||
*/
|
||||
export function getChannelId(channelURL: string) {
|
||||
const match = channelURL.match(/channel\/([^\s?]*)/)
|
||||
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id')
|
||||
}
|
||||
|
||||
export function parseYouTubeURLTimeString(timeString: string) {
|
||||
const signs = timeString.replace(/[0-9]/g, '')
|
||||
if (signs.length === 0) return null
|
||||
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
|
||||
let total = 0
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
let t = parseInt(numbers[i])
|
||||
switch (signs[i]) {
|
||||
case 'd': t *= 24
|
||||
case 'h': t *= 60
|
||||
case 'm': t *= 60
|
||||
case 's': break
|
||||
default: return null
|
||||
}
|
||||
total += t
|
||||
}
|
||||
return total
|
||||
}
|
82
src/modules/yt/urlCache.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
// 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 }
|
||||
|
88
src/modules/yt/urlResolve.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
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
|
||||
}
|
17
src/pages/YTtoLBRY/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<script src="main.tsx" charset="utf-8" defer></script>
|
||||
</head>
|
||||
|
||||
<body id="page">
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
86
src/pages/YTtoLBRY/main.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
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')!)
|
86
src/pages/YTtoLBRY/style.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
main {
|
||||
display: flex;
|
||||
flex-wrap: wrap-reverse;
|
||||
min-height: 100vh;
|
||||
gap: 2em;
|
||||
padding: 1em;
|
||||
overflow-wrap: break-word;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.conversion,
|
||||
.help {
|
||||
flex: 1;
|
||||
min-width: 40em;
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.conversion>*,
|
||||
.help>* {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: .5em;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.help iframe {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.conversion {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.conversion form {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.conversion form .fields {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions::after {
|
||||
content: "";
|
||||
flex-grow: 100000000000000000;
|
||||
}
|
||||
|
||||
.conversion form button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversion form button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conversion .results {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.help a,
|
||||
.conversion .results a {
|
||||
background: var(--color-gradient-0);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.conversion .results .result-item {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
14
src/pages/import/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="main.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
62
src/pages/import/main.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { createDialogManager, Dialogs } from '../../components/dialogs'
|
||||
import { importProfileKeysFromFile, inputKeyFile } from '../../modules/crypto'
|
||||
|
||||
export async function openImportPopup() {
|
||||
const importPopupWindow = open(
|
||||
'/pages/import/index.html',
|
||||
'Import Profile',
|
||||
[
|
||||
`height=${Math.max(document.body.clientHeight, screen.height * .5)}`,
|
||||
`width=${document.body.clientWidth}`,
|
||||
`toolbar=0,menubar=0,location=0`,
|
||||
`top=${screenY}`,
|
||||
`left=${screenX}`
|
||||
].join(','))
|
||||
close()
|
||||
importPopupWindow?.focus()
|
||||
}
|
||||
|
||||
function ImportPage() {
|
||||
const [loading, updateLoading] = useState(() => false)
|
||||
|
||||
async function loads<T>(operation: Promise<T>) {
|
||||
try {
|
||||
updateLoading(true)
|
||||
await operation
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
updateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function importProfile() {
|
||||
inputKeyFile(async (file) => file && await loads(
|
||||
importProfileKeysFromFile(dialogManager, file)
|
||||
.then((success) => success && (location.pathname = '/pages/popup/index.html'))
|
||||
))
|
||||
}
|
||||
|
||||
const dialogManager = createDialogManager()
|
||||
|
||||
return <div id='popup'>
|
||||
<Dialogs manager={dialogManager} />
|
||||
<main>
|
||||
<section>
|
||||
<label>Import your profile</label>
|
||||
<p>Import your unique keypair.</p>
|
||||
<div className='options'>
|
||||
<a onClick={() => importProfile()} className={`button`}>Import</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{loading && <div class="overlay">
|
||||
<span>Loading...</span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
render(<ImportPage />, document.getElementById('root')!)
|
35
src/pages/import/style.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
header {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
padding: .75em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(19, 19, 19, 0.5);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
padding: 1.5em 0.5em;
|
||||
}
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: .75em;
|
||||
}
|
||||
|
||||
section label {
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#popup {
|
||||
width: 35em;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin: auto;
|
||||
}
|
14
src/pages/popup/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="main.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
236
src/pages/popup/main.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
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()
|
55
src/pages/popup/style.css
Normal file
|
@ -0,0 +1,55 @@
|
|||
#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;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<script src="popup.tsx" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="root" />
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,4 +0,0 @@
|
|||
.radio-label
|
||||
font-size: 1.1rem
|
||||
margin: 15px auto
|
||||
display: block
|
|
@ -1,33 +0,0 @@
|
|||
import { h, render } from 'preact'
|
||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
|
||||
import { getPlatfromSettingsEntiries, LbrySettings, PlatformName } from '../common/settings'
|
||||
import { useLbrySettings } from '../common/useSettings'
|
||||
import './popup.sass'
|
||||
|
||||
|
||||
|
||||
/** Utilty to set a setting in the browser */
|
||||
const setSetting = <K extends keyof LbrySettings>(setting: K, value: LbrySettings[K]) => chrome.storage.local.set({ [setting]: value });
|
||||
|
||||
/** Gets all the options for redirect destinations as selection options */
|
||||
const platformOptions: SelectionOption[] = getPlatfromSettingsEntiries()
|
||||
.map(([value, { display }]) => ({ value, display }));
|
||||
|
||||
function WatchOnLbryPopup() {
|
||||
const { enabled, platform } = 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={platform} options={platformOptions}
|
||||
onChange={(platform: PlatformName) => setSetting('platform', platform)} />
|
||||
<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')!);
|
36
src/scripts/background.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
})
|
|
@ -1,28 +0,0 @@
|
|||
import { DEFAULT_SETTINGS, LbrySettings, getSettingsAsync } from '../common/settings';
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
const settings = await getSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof LbrySettings>);
|
||||
|
||||
// get all the values that aren't set and use them as a change set
|
||||
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof LbrySettings, LbrySettings[keyof LbrySettings]]>)
|
||||
.filter(([k]) => settings[k] === null || settings[k] === undefined);
|
||||
|
||||
// fix our local var and set it in storage for later
|
||||
if (invalidEntries.length > 0) {
|
||||
const changeSet = Object.fromEntries(invalidEntries);
|
||||
Object.assign(settings, changeSet);
|
||||
chrome.storage.local.set(changeSet);
|
||||
}
|
||||
|
||||
chrome.browserAction.setBadgeText({ text: settings.enabled ? 'ON' : 'OFF' });
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.enabled) return;
|
||||
chrome.browserAction.setBadgeText({ text: changes.enabled.newValue ? 'ON' : 'OFF' });
|
||||
});
|
||||
|
||||
|
||||
chrome.runtime.onStartup.addListener(initSettings);
|
||||
chrome.runtime.onInstalled.addListener(initSettings);
|
|
@ -1,67 +0,0 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'
|
||||
import { getSettingsAsync, PlatformName } from '../common/settings'
|
||||
import { YTDescriptor, ytService } from '../common/yt'
|
||||
export interface UpdateContext {
|
||||
descriptor: YTDescriptor
|
||||
/** LBRY URL fragment */
|
||||
pathname: string
|
||||
enabled: boolean
|
||||
platform: PlatformName
|
||||
}
|
||||
|
||||
async function resolveYT(descriptor: YTDescriptor) {
|
||||
const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]);
|
||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
||||
if (segments.length === 0) return;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
const pathnameCache: Record<string, string | undefined> = {};
|
||||
|
||||
async function ctxFromURL(url: string): Promise<UpdateContext | void> {
|
||||
if (!url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
||||
url = new URL(url).href;
|
||||
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
|
||||
const descriptor = ytService.getId(url);
|
||||
if (!descriptor) return; // couldn't get the ID, so we're done
|
||||
|
||||
const res = url in pathnameCache ? pathnameCache[url] : await resolveYT(descriptor);
|
||||
pathnameCache[url] = res;
|
||||
if (!res) return; // couldn't find it on lbry, so we're done
|
||||
|
||||
return { descriptor, pathname: res, enabled, platform };
|
||||
}
|
||||
|
||||
// handles lbry.tv -> lbry app redirect
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
|
||||
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
|
||||
if (!enabled || platform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return;
|
||||
|
||||
const url = appRedirectUrl(tabUrl, { encode: true });
|
||||
if (!url) return;
|
||||
chrome.tabs.update(tabId, { url });
|
||||
alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed
|
||||
chrome.tabs.executeScript(tabId, {
|
||||
code: `if (window.history.length === 1) {
|
||||
window.close();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
document.querySelectorAll('video').forEach(v => v.pause());
|
||||
`
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResponse) => {
|
||||
ctxFromURL(url).then(ctx => {
|
||||
sendResponse(ctx);
|
||||
})
|
||||
return true;
|
||||
})
|
||||
|
||||
// relay youtube link changes to the content script
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
|
||||
if (!changeInfo.url || !url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
||||
|
||||
ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
|
||||
});
|
|
@ -1,174 +1,363 @@
|
|||
import { PlatformName, platformSettings } from '../common/settings'
|
||||
import type { UpdateContext } from '../scripts/tabOnUpdated'
|
||||
import { h, JSX, 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';
|
||||
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t));
|
||||
(async () => {
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
||||
|
||||
interface ButtonSettings {
|
||||
text: string
|
||||
icon: string
|
||||
style?:
|
||||
{
|
||||
icon?: JSX.CSSProperties
|
||||
button?: JSX.CSSProperties
|
||||
interface Target {
|
||||
platform: TargetPlatform
|
||||
lbryPathname: string
|
||||
type: ResolveUrlTypes
|
||||
time: number | null
|
||||
}
|
||||
}
|
||||
|
||||
const buttonSettings: Record<PlatformName, ButtonSettings> = {
|
||||
app: {
|
||||
text: 'Watch on LBRY',
|
||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||
},
|
||||
'madiator.com': {
|
||||
text: 'Watch on',
|
||||
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
|
||||
style: {
|
||||
button: { flexDirection: 'row-reverse' },
|
||||
icon: { transform: 'scale(1.2)' }
|
||||
}
|
||||
},
|
||||
odysee: {
|
||||
text: 'Watch on Odysee',
|
||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||
},
|
||||
};
|
||||
interface Source {
|
||||
platform: SourcePlatform
|
||||
id: string
|
||||
type: ResolveUrlTypes
|
||||
url: URL
|
||||
time: number | null
|
||||
}
|
||||
|
||||
interface ButtonParameters
|
||||
{
|
||||
platform?: PlatformName
|
||||
pathname?: string
|
||||
time?: number
|
||||
}
|
||||
const targetPlatforms = getTargetPlatfromSettingsEntiries()
|
||||
const settings = await getExtensionSettingsAsync()
|
||||
// Listen Settings Change
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== 'local') return
|
||||
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])))
|
||||
})
|
||||
|
||||
export function WatchOnLbryButton({ platform = 'app', pathname, time }: ButtonParameters) {
|
||||
if (!pathname || !platform) return null;
|
||||
const platformSetting = platformSettings[platform];
|
||||
const buttonSetting = buttonSettings[platform];
|
||||
const buttonMountPoint = document.createElement('div')
|
||||
buttonMountPoint.style.display = 'inline-flex'
|
||||
|
||||
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
|
||||
if (time) url.searchParams.append('t', time.toFixed(0))
|
||||
const playerButtonMountPoint = document.createElement('div')
|
||||
playerButtonMountPoint.style.display = 'inline-flex'
|
||||
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<a href={`${url.toString()}`} onClick={pauseVideo} role='button'
|
||||
function WatchOnLbryButton({ source, target }: { source?: Source, target?: Target }) {
|
||||
if (!target || !source) return null
|
||||
const url = getLbryUrlByTarget(target)
|
||||
|
||||
return <div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: platformSetting.theme,
|
||||
border: '0',
|
||||
color: 'whitesmoke',
|
||||
padding: '10px 16px',
|
||||
marginRight: '5px',
|
||||
fontSize: '14px',
|
||||
textDecoration: 'none',
|
||||
...buttonSetting.style?.button,
|
||||
}}>
|
||||
<img src={buttonSetting.icon} height={16}
|
||||
style={{ transform: 'scale(1.5)', ...buttonSetting.style?.icon }} />
|
||||
<span>{buttonSetting.text}</span>
|
||||
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',
|
||||
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>;
|
||||
}
|
||||
|
||||
let mountPoint: HTMLDivElement | null = null
|
||||
/** Returns a mount point for the button */
|
||||
async function findButtonMountPoint(): Promise<HTMLDivElement | void> {
|
||||
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');
|
||||
</div>
|
||||
}
|
||||
|
||||
if (!ownerBar) return;
|
||||
const div = document.createElement('div');
|
||||
div.style.display = 'flex';
|
||||
ownerBar.insertAdjacentElement('afterend', div);
|
||||
function WatchOnLbryPlayerButton({ source, target }: { source?: Source, target?: Target }) {
|
||||
if (!target || !source) return null
|
||||
const url = getLbryUrlByTarget(target)
|
||||
|
||||
mountPoint = div
|
||||
}
|
||||
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',
|
||||
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
async function findVideoElement() {
|
||||
while(!(videoElement = document.querySelector('#ytd-player video'))) await sleep(200)
|
||||
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
|
||||
}
|
||||
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 pauseVideo() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
|
||||
|
||||
function openApp(url: string) {
|
||||
pauseVideo();
|
||||
location.assign(url);
|
||||
}
|
||||
|
||||
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
||||
let ctxCache: UpdateContext | null = null
|
||||
function handleURLChange (ctx: UpdateContext | null) {
|
||||
ctxCache = ctx
|
||||
updateButton(ctx)
|
||||
if (ctx?.enabled) redirectTo(ctx)
|
||||
}
|
||||
|
||||
function updateButton(ctx: UpdateContext | null) {
|
||||
if (!mountPoint) return
|
||||
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
|
||||
if (ctx.descriptor.type !== 'video') return;
|
||||
const time = videoElement?.currentTime ?? 0
|
||||
const pathname = ctx.pathname
|
||||
const platform = ctx.platform
|
||||
|
||||
render(<WatchOnLbryButton platform={platform} pathname={pathname} time={time} />, mountPoint)
|
||||
}
|
||||
|
||||
function redirectTo({ platform, pathname }: UpdateContext) {
|
||||
|
||||
const parseYouTubeTime = (timeString: string) => {
|
||||
const signs = timeString.replace(/[0-9]/g, '')
|
||||
if (signs.length === 0) return timeString
|
||||
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 '0'
|
||||
}
|
||||
total += t
|
||||
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)
|
||||
}
|
||||
}
|
||||
return total.toString()
|
||||
}
|
||||
|
||||
const platformSetting = platformSettings[platform];
|
||||
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
|
||||
const time = new URL(location.href).searchParams.get('t')
|
||||
|
||||
if (time) url.searchParams.append('t', parseYouTubeTime(time))
|
||||
async function findVideoElementAwait(source: Source) {
|
||||
let videoElement: HTMLVideoElement | null = null
|
||||
while (!(videoElement = document.querySelector(source.platform.htmlQueries.videoPlayer))) await sleep(200)
|
||||
return videoElement
|
||||
}
|
||||
|
||||
if (platform === 'app') return openApp(url.toString());
|
||||
location.replace(url.toString());
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
findButtonMountPoint().then(() => updateButton(ctxCache))
|
||||
findVideoElement().then(() => updateButton(ctxCache))
|
||||
// Update Buttons
|
||||
if (urlHrefCache !== url.href) updateButtons(null)
|
||||
// If target is a video target add timestampt to it
|
||||
if (target.type === 'video') {
|
||||
const videoElement = document.querySelector<HTMLVideoElement>(source.platform.htmlQueries.videoPlayer)
|
||||
if (videoElement) target.time = videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null
|
||||
}
|
||||
updateButtons({ target, source })
|
||||
|
||||
// Redirect
|
||||
if (
|
||||
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
|
||||
|
||||
/** Request UpdateContext from background */
|
||||
const requestCtxFromUrl = async (url: string) => await new Promise<UpdateContext | null>((resolve) => chrome.runtime.sendMessage({ url }, resolve))
|
||||
const lbryURL = getLbryUrlByTarget(target)
|
||||
|
||||
/** Handle the location on load of the page */
|
||||
requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx))
|
||||
if (source.type === 'video') {
|
||||
findVideoElementAwait(source).then((videoElement) => videoElement.pause())
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
|
||||
* history.pushState changes from a content script
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChange(ctx));
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** On settings change */
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== 'local') return;
|
||||
if (changes.platform) handleURLChange(await requestCtxFromUrl(location.href))
|
||||
});
|
||||
})()
|
2
src/service-worker-entry-point.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import './settings/background'
|
||||
import './scripts/background'
|
33
src/settings/background.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '../settings'
|
||||
|
||||
// This is for manifest v2 and v3
|
||||
const chromeAction = chrome.action ?? chrome.browserAction
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
let settings = await getExtensionSettingsAsync()
|
||||
|
||||
// get all the values that aren't set and use them as a change set
|
||||
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof ExtensionSettings, ExtensionSettings[keyof ExtensionSettings]]>)
|
||||
.filter(([k]) => settings[k] === undefined || settings[k] === null)
|
||||
|
||||
// fix our local var and set it in storage for later
|
||||
if (invalidEntries.length > 0) {
|
||||
const changeSet = Object.fromEntries(invalidEntries)
|
||||
chrome.storage.local.set(changeSet)
|
||||
settings = await getExtensionSettingsAsync()
|
||||
}
|
||||
|
||||
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
||||
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
||||
|
||||
// chromeAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
||||
}
|
||||
|
||||
/* chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.redirect) return
|
||||
chromeAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' })
|
||||
}) */
|
||||
|
||||
chrome.runtime.onStartup.addListener(initSettings)
|
||||
chrome.runtime.onInstalled.addListener(initSettings)
|
201
src/settings/index.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
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
|
||||
}) */
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
# 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
|
|
@ -1,88 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
<link rel="stylesheet" href="YTtoLBRY.css" />
|
||||
<script src="YTtoLBRY.tsx" charset="utf-8" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
</html>
|
|
@ -1,64 +0,0 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { getSettingsAsync, platformSettings } 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 ids = new Set((
|
||||
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
|
||||
ext === 'csv' ? ytService.readCsv :
|
||||
ytService.readJson)(await getFileContent(file)))
|
||||
const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const)));
|
||||
const { platform } = await getSettingsAsync('platform');
|
||||
const urlPrefix = platformSettings[platform].domainPrefix;
|
||||
return lbryUrls.map(channel => urlPrefix + channel);
|
||||
}
|
||||
|
||||
function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | void }) {
|
||||
const [file, setFile] = useState(null as File | null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
return <div className='ConversionCard'>
|
||||
<h2>Select YouTube Subscriptions</h2>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input type='file' onChange={e => setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
|
||||
</div>
|
||||
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
await onSelect(file);
|
||||
setLoading(false);
|
||||
}} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function YTtoLBRY() {
|
||||
const [lbryChannels, setLbryChannels] = useState([] as string[]);
|
||||
|
||||
return <div className='YTtoLBRY'>
|
||||
<div className='Conversion'>
|
||||
<ConversionCard onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
|
||||
<ul>
|
||||
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='ConversionHelp'>
|
||||
<iframe width='712px' height='400px' allowFullScreen
|
||||
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
|
||||
<section dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
render(<YTtoLBRY />, document.getElementById('root')!);
|
|
@ -41,6 +41,7 @@
|
|||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"importsNotUsedAsValues": "error", /* Import types always with `import type` */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
|
@ -53,7 +54,6 @@
|
|||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
|
|