Merge pull request #116 from DeepDoge/master
Organized Files, Remade YTtoLBRY page, And some refactoring
2
src/global.d.ts → global.d.ts
vendored
|
@ -1,4 +1,4 @@
|
||||||
declare module '*.md' {
|
declare module '*.md' {
|
||||||
var _: string
|
var _: string
|
||||||
export default _
|
export default _
|
||||||
}
|
}
|
|
@ -5,8 +5,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/",
|
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/",
|
||||||
"watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/",
|
"watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/",
|
||||||
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/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\"",
|
||||||
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
|
||||||
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest",
|
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest",
|
||||||
"build": "npm-run-all -l -p build:parcel build:assets",
|
"build": "npm-run-all -l -p build:parcel build:assets",
|
||||||
"watch": "npm-run-all -l -p watch:parcel watch:assets",
|
"watch": "npm-run-all -l -p watch:parcel watch:assets",
|
||||||
|
|
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 |
|
@ -10,10 +10,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
font-size: .95rem;
|
||||||
font-family: Arial, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif;
|
font-family: Arial, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif;
|
||||||
letter-spacing: .2ch;
|
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 {
|
body {
|
||||||
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
|
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
|
@ -24,7 +31,7 @@ body {
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(19, 19, 19, 0.75);
|
background: rgba(19, 19, 19, 0.75);
|
||||||
}
|
}
|
||||||
|
@ -36,6 +43,30 @@ body::before {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
|
||||||
|
justify-content: center;
|
||||||
|
gap: .25em;
|
||||||
|
padding: 0 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
@ -48,14 +79,9 @@ body::before {
|
||||||
color: var(--color-light);
|
color: var(--color-light);
|
||||||
|
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
}
|
border: unset;
|
||||||
|
|
||||||
.filled {
|
font-size: inherit;
|
||||||
background: var(--color-gradient-0);
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
font-weight: bold;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.active {
|
.button.active {
|
||||||
|
@ -77,13 +103,36 @@ body::before {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options {
|
.filled {
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
font-weight: bold;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
grid-auto-flow: column;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
|
gap: .5em;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
gap: .25em;
|
justify-content: start;
|
||||||
padding: 0 1.5em;
|
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 {
|
.overlay {
|
|
@ -1,21 +0,0 @@
|
||||||
@import '../style'
|
|
||||||
|
|
||||||
.ButtonRadio
|
|
||||||
display: flex
|
|
||||||
justify-content: center
|
|
||||||
flex-wrap: wrap
|
|
||||||
gap: .25em
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
*
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
.radio-button
|
|
||||||
@extend .button
|
|
||||||
|
|
||||||
.radio-button.checked
|
|
||||||
@extend .button.active
|
|
||||||
|
|
||||||
input[type="radio"]
|
|
||||||
opacity: 0
|
|
||||||
position: absolute
|
|
|
@ -1,31 +0,0 @@
|
||||||
import classnames from 'classnames'
|
|
||||||
import { h } from 'preact'
|
|
||||||
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,33 +0,0 @@
|
||||||
$background-color: #191a1c !default
|
|
||||||
$text-color: whitesmoke !default
|
|
||||||
|
|
||||||
$btn-color: #075656 !default
|
|
||||||
$btn-select: teal !default
|
|
||||||
|
|
||||||
body
|
|
||||||
width: 30em
|
|
||||||
text-align: center
|
|
||||||
background-color: $background-color
|
|
||||||
color: $text-color
|
|
||||||
font-family: sans-serif
|
|
||||||
padding: 1em
|
|
||||||
|
|
||||||
.container
|
|
||||||
display: block
|
|
||||||
text-align: center
|
|
||||||
|
|
||||||
.button
|
|
||||||
border-radius: .5em
|
|
||||||
background-color: $btn-color
|
|
||||||
border: .2em solid $btn-color
|
|
||||||
color: $text-color
|
|
||||||
font-size: 0.8rem
|
|
||||||
font-weight: 400
|
|
||||||
padding: .5em
|
|
||||||
text-align: center
|
|
||||||
|
|
||||||
&.active
|
|
||||||
border-color: $btn-select
|
|
||||||
|
|
||||||
&:focus
|
|
||||||
outline: none
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { useEffect, useReducer } from 'preact/hooks'
|
|
||||||
import { DEFAULT_SETTINGS, ExtensionSettings } from './settings'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Utilty to set a setting in the browser */
|
|
||||||
export const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
|
|
||||||
|
|
||||||
|
|
||||||
/** A hook to read watch on lbry settings from local storage */
|
|
||||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)
|
|
|
@ -1,32 +0,0 @@
|
||||||
import crypto from 'crypto'
|
|
||||||
|
|
||||||
function generateKeys() {
|
|
||||||
// The `generateKeyPairSync` method accepts two arguments:
|
|
||||||
// 1. The type ok keys we want, which in this case is "rsa"
|
|
||||||
// 2. An object with the properties of the key
|
|
||||||
const keys = crypto.generateKeyPairSync("rsa", {
|
|
||||||
// The standard secure default length for RSA keys is 2048 bits
|
|
||||||
modulusLength: 2048,
|
|
||||||
})
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeKeys(keys: { publicKey: Buffer, privateKey: Buffer }) {
|
|
||||||
return JSON.stringify({ publicKey: keys.publicKey.toString('base64'), privateKey: keys.privateKey.toString('base64') })
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeKeys(encodedKeys: string) {
|
|
||||||
const keysBase64 = JSON.parse(encodedKeys)
|
|
||||||
return {
|
|
||||||
publicKey: Buffer.from(keysBase64.publicKey),
|
|
||||||
privateKey: Buffer.from(keysBase64.privateKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sign(data: string, privateKey: Buffer) {
|
|
||||||
return crypto.sign("sha256", Buffer.from(data), {
|
|
||||||
key: privateKey,
|
|
||||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
|
||||||
})
|
|
||||||
}
|
|
33
src/components/Row/index.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { ComponentChildren, h } from 'preact'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
/*
|
||||||
|
Re-implementation of https://github.com/DeepDoge/svelte-responsive-row
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function Row(params: {
|
||||||
|
children: ComponentChildren
|
||||||
|
type?: "fit" | "fill",
|
||||||
|
idealSize?: string,
|
||||||
|
gap?: string,
|
||||||
|
maxColumnCount?: number,
|
||||||
|
justifyItems?: "center" | "start" | "end" | "stretch"
|
||||||
|
}) {
|
||||||
|
if (!params.type) params.type = 'fill'
|
||||||
|
if (!params.gap) params.gap = '0'
|
||||||
|
if (!params.idealSize) params.idealSize = '100%'
|
||||||
|
if (!params.maxColumnCount) params.maxColumnCount = Number.MAX_SAFE_INTEGER
|
||||||
|
if (!params.justifyItems) params.justifyItems = 'center'
|
||||||
|
|
||||||
|
return <div className='responsive-row'
|
||||||
|
style={{
|
||||||
|
'--ideal-size': params.idealSize,
|
||||||
|
'--gap': params.gap === "0" ? "0px" : params.gap,
|
||||||
|
'--type': `auto-${params.type}`,
|
||||||
|
'--max-column-count': params.maxColumnCount,
|
||||||
|
'--justify-items': params.justifyItems
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{params.children}
|
||||||
|
</div>
|
||||||
|
}
|
6
src/components/Row/style.css
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
.responsive-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--type), minmax(min(max(100% / var(--max-column-count) - var(--gap), var(--ideal-size)), 100%), 1fr));
|
||||||
|
gap: var(--gap);
|
||||||
|
justify-items: var(--justify-items);
|
||||||
|
}
|
|
@ -31,26 +31,26 @@
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"scripts/storageSetup.js",
|
"settings/background.js",
|
||||||
"scripts/background.js"
|
"scripts/background.js"
|
||||||
],
|
],
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_title": "Watch on LBRY",
|
"default_title": "Watch on LBRY",
|
||||||
"default_popup": "popup/popup.html"
|
"default_popup": "pages/popup/index.html"
|
||||||
},
|
},
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
"popup.html",
|
"pages/popup/index.html",
|
||||||
"tools/YTtoLBRY.html",
|
"pages/YTtoLBRY/index.html",
|
||||||
"icons/lbry/lbry-logo.svg",
|
"assets/icons/lbry/lbry-logo.svg",
|
||||||
"icons/lbry/odysee-logo.svg",
|
"assets/icons/lbry/odysee-logo.svg",
|
||||||
"icons/lbry/madiator-logo.svg"
|
"assets/icons/lbry/madiator-logo.svg"
|
||||||
],
|
],
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/wol/icon16.png",
|
"16": "assets/icons/wol/icon16.png",
|
||||||
"48": "icons/wol/icon48.png",
|
"48": "assets/icons/wol/icon48.png",
|
||||||
"128": "icons/wol/icon128.png"
|
"128": "assets/icons/wol/icon128.png"
|
||||||
},
|
},
|
||||||
"manifest_version": 2
|
"manifest_version": 2
|
||||||
}
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "./settings"
|
|
||||||
import { setSetting } from "./useSettings"
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { getExtensionSettingsAsync, setExtensionSetting, ytUrlResolversSettings } from "../../settings"
|
||||||
|
|
||||||
async function generateKeys() {
|
async function generateKeys() {
|
||||||
const keys = await window.crypto.subtle.generateKey(
|
const keys = await window.crypto.subtle.generateKey(
|
||||||
|
@ -38,7 +37,6 @@ async function exportPublicKey(key: CryptoKey) {
|
||||||
key
|
key
|
||||||
)
|
)
|
||||||
const publicKey = Buffer.from(exported).toString('base64')
|
const publicKey = Buffer.from(exported).toString('base64')
|
||||||
console.log(publicKey)
|
|
||||||
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
|
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,8 +62,8 @@ export async function sign(data: string, privateKey: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetProfileSettings() {
|
export function resetProfileSettings() {
|
||||||
setSetting('publicKey', null)
|
setExtensionSetting('publicKey', null)
|
||||||
setSetting('privateKey', null)
|
setExtensionSetting('privateKey', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: string, data: T) {
|
async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: string, data: T) {
|
||||||
|
@ -73,9 +71,7 @@ async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: st
|
||||||
/* const urlResolverSettings = ytUrlResolversSettings[settings.urlResolver]
|
/* const urlResolverSettings = ytUrlResolversSettings[settings.urlResolver]
|
||||||
if (!urlResolverSettings.signRequest) throw new Error() */
|
if (!urlResolverSettings.signRequest) throw new Error() */
|
||||||
|
|
||||||
console.log(ytUrlResolversSettings)
|
|
||||||
const url = new URL(ytUrlResolversSettings.madiatorFinder.href/* urlResolverSettings.href */)
|
const url = new URL(ytUrlResolversSettings.madiatorFinder.href/* urlResolverSettings.href */)
|
||||||
console.log(url)
|
|
||||||
url.pathname = path.join(url.pathname, pathname)
|
url.pathname = path.join(url.pathname, pathname)
|
||||||
url.searchParams.set('data', JSON.stringify(data))
|
url.searchParams.set('data', JSON.stringify(data))
|
||||||
|
|
||||||
|
@ -113,8 +109,8 @@ export async function generateProfileAndSetNickname(overwrite = false) {
|
||||||
publicKey = keys.publicKey
|
publicKey = keys.publicKey
|
||||||
privateKey = keys.privateKey
|
privateKey = keys.privateKey
|
||||||
})
|
})
|
||||||
setSetting('publicKey', publicKey)
|
setExtensionSetting('publicKey', publicKey)
|
||||||
setSetting('privateKey', privateKey)
|
setExtensionSetting('privateKey', privateKey)
|
||||||
}
|
}
|
||||||
await apiRequest('POST', '/profile', { nickname })
|
await apiRequest('POST', '/profile', { nickname })
|
||||||
alert(`Your nickname has been set to ${nickname}`)
|
alert(`Your nickname has been set to ${nickname}`)
|
||||||
|
@ -198,8 +194,8 @@ export async function importProfileKeysFromFile() {
|
||||||
const json = await readFile()
|
const json = await readFile()
|
||||||
if (!json) throw new Error("Invalid")
|
if (!json) throw new Error("Invalid")
|
||||||
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
|
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
|
||||||
setSetting('publicKey', publicKey)
|
setExtensionSetting('publicKey', publicKey)
|
||||||
setSetting('privateKey', privateKey)
|
setExtensionSetting('privateKey', privateKey)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.message)
|
alert(error.message)
|
||||||
}
|
}
|
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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -12,23 +12,6 @@ interface YtExportedJsonSubscription {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the array of YT channels from an OPML file
|
* Reads the array of YT channels from an OPML file
|
||||||
*
|
*
|
|
@ -1,8 +1,8 @@
|
||||||
import { chunk } from "lodash"
|
import { chunk } from "lodash"
|
||||||
import { sign } from "../crypto"
|
|
||||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
|
|
||||||
import { LbryPathnameCache } from "./urlCache"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../../settings"
|
||||||
|
import { sign } from "../crypto"
|
||||||
|
import { LbryPathnameCache } from "./urlCache"
|
||||||
|
|
||||||
const QUERY_CHUNK_SIZE = 100
|
const QUERY_CHUNK_SIZE = 100
|
||||||
|
|
||||||
|
@ -11,8 +11,10 @@ type Results = Record<string, YtUrlResolveItem>
|
||||||
type Paramaters = YtUrlResolveItem[]
|
type Paramaters = YtUrlResolveItem[]
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
channels?: Record<string, string>
|
data: {
|
||||||
videos?: Record<string, string>
|
channels?: Record<string, string>
|
||||||
|
videos?: Record<string, string>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
|
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
|
||||||
|
@ -37,7 +39,6 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
|
||||||
// No cache found
|
// No cache found
|
||||||
return item
|
return item
|
||||||
}))).filter((o) => o) as Paramaters
|
}))).filter((o) => o) as Paramaters
|
||||||
console.log(params)
|
|
||||||
|
|
||||||
if (params.length === 0) return results
|
if (params.length === 0) return results
|
||||||
|
|
||||||
|
@ -54,9 +55,8 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
|
||||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||||
if (apiResponse.ok) {
|
if (apiResponse.ok) {
|
||||||
const response: ApiResponse = await apiResponse.json()
|
const response: ApiResponse = await apiResponse.json()
|
||||||
for (const item of params)
|
for (const item of params) {
|
||||||
{
|
const lbryUrl = (item.type === 'channel' ? response.data.channels : response.data.videos)?.[item.id]?.replaceAll('#', ':') ?? null
|
||||||
const lbryUrl = ((item.type === 'channel' ? response.channels : response.videos) ?? {})[item.id]?.replaceAll('#', ':') ?? null
|
|
||||||
// we cache it no matter if its null or not
|
// we cache it no matter if its null or not
|
||||||
await LbryPathnameCache.put(lbryUrl, item.id)
|
await LbryPathnameCache.put(lbryUrl, item.id)
|
||||||
|
|
||||||
|
@ -76,7 +76,6 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
|
||||||
if (progressCallback) progressCallback(++i / (chunks.length + 1))
|
if (progressCallback) progressCallback(++i / (chunks.length + 1))
|
||||||
Object.assign(results, await requestChunk(chunk))
|
Object.assign(results, await requestChunk(chunk))
|
||||||
}
|
}
|
||||||
console.log(results)
|
|
||||||
|
|
||||||
if (progressCallback) progressCallback(1)
|
if (progressCallback) progressCallback(1)
|
||||||
return results
|
return results
|
|
@ -3,4 +3,4 @@
|
||||||
1. Go to https://takeout.google.com/settings/takeout
|
1. Go to https://takeout.google.com/settings/takeout
|
||||||
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
|
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
|
||||||
3. Go through the process and create the export
|
3. Go through the process and create the export
|
||||||
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
17
src/pages/YTtoLBRY/index.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Subscription Converter</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||||
|
<script src="main.tsx" charset="utf-8" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body id="page">
|
||||||
|
<div id="root" />
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
80
src/pages/YTtoLBRY/main.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { h, render } from 'preact'
|
||||||
|
import { useState } from 'preact/hooks'
|
||||||
|
import { getFileContent } from '../../modules/file'
|
||||||
|
import { getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../../modules/yt'
|
||||||
|
import { resolveById } from '../../modules/yt/urlResolve'
|
||||||
|
import { targetPlatformSettings, useExtensionSettings } from '../../settings'
|
||||||
|
import readme from './README.md'
|
||||||
|
|
||||||
|
async function getSubscribedChannelIdsFromFile(file: File) {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||||
|
const content = await getFileContent(file)
|
||||||
|
switch (ext) {
|
||||||
|
case 'xml':
|
||||||
|
case 'opml':
|
||||||
|
return getSubsFromOpml(content)
|
||||||
|
case 'csv':
|
||||||
|
return getSubsFromCsv(content)
|
||||||
|
default:
|
||||||
|
return getSubsFromJson(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findChannels(channelIds: string[], progressCallback: Parameters<typeof resolveById>['1']) {
|
||||||
|
const resultItems = await resolveById(channelIds.map((channelId) => ({ id: channelId, type: 'channel' })), progressCallback)
|
||||||
|
return Object.values(resultItems).map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Conversion() {
|
||||||
|
const [file, setFile] = useState(null as File | null)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [lbryChannelIds, setLbryChannels] = useState([] as Awaited<ReturnType<typeof findChannels>>)
|
||||||
|
const settings = useExtensionSettings()
|
||||||
|
|
||||||
|
let loading = progress > 0 && progress !== 1
|
||||||
|
|
||||||
|
return <div className='conversion'>
|
||||||
|
<form onSubmit={async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (file) setLbryChannels(await findChannels(await getSubscribedChannelIdsFromFile(file), (progress) => setProgress(progress)))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="fields">
|
||||||
|
<label for="conversion-file">Select YouTube Subscriptions</label>
|
||||||
|
<input id="conversion-file" type='file' onChange={event => setFile(event.currentTarget.files?.length ? event.currentTarget.files[0] : null)} />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button className={`button ${!file || progress > 0 ? '' : 'active'}`} disabled={!file || loading}>
|
||||||
|
{loading ? `${(progress * 100).toFixed(1)}%` : 'Start Conversion!'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{
|
||||||
|
progress === 1 &&
|
||||||
|
<div class="results">
|
||||||
|
<b>Results:</b>
|
||||||
|
{
|
||||||
|
lbryChannelIds.length > 0
|
||||||
|
? lbryChannelIds.map((lbryChannelId) =>
|
||||||
|
<article class="result-item">
|
||||||
|
<a href={`${targetPlatformSettings[settings.targetPlatform].domainPrefix}${lbryChannelId}`}>{lbryChannelId}</a>
|
||||||
|
</article>)
|
||||||
|
: <span class="error">No Result</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function YTtoLBRY() {
|
||||||
|
return <main>
|
||||||
|
<Conversion />
|
||||||
|
<aside class="help">
|
||||||
|
<iframe allowFullScreen
|
||||||
|
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
|
||||||
|
<section dangerouslySetInnerHTML={{ __html: readme }} />
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<YTtoLBRY />, document.getElementById('root')!)
|
86
src/pages/YTtoLBRY/style.css
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap-reverse;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 2em;
|
||||||
|
padding: 1em;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion,
|
||||||
|
.help {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 40em;
|
||||||
|
display: grid;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion>*,
|
||||||
|
.help>* {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: .5em;
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help iframe {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
max-height: 50vh;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1em;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form .fields {
|
||||||
|
display: grid;
|
||||||
|
gap: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form .actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form .actions::after {
|
||||||
|
content: "";
|
||||||
|
flex-grow: 100000000000000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion form button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion .results {
|
||||||
|
display: grid;
|
||||||
|
gap: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help a,
|
||||||
|
.conversion .results a {
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversion .results .result-item {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
14
src/pages/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>
|
|
@ -1,11 +1,8 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import '../common/common.css'
|
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../../modules/crypto'
|
||||||
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
|
import { LbryPathnameCache } from '../../modules/yt/urlCache'
|
||||||
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
|
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, setExtensionSetting, useExtensionSettings } from '../../settings'
|
||||||
import { setSetting, useLbrySettings } from '../common/useSettings'
|
|
||||||
import { LbryPathnameCache } from '../common/yt/urlCache'
|
|
||||||
import './popup.css'
|
|
||||||
|
|
||||||
|
|
||||||
/** Gets all the options for redirect destinations as selection options */
|
/** Gets all the options for redirect destinations as selection options */
|
||||||
|
@ -13,7 +10,7 @@ const targetPlatforms = getTargetPlatfromSettingsEntiries()
|
||||||
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
|
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
|
||||||
|
|
||||||
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
|
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
|
||||||
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
|
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useExtensionSettings()
|
||||||
let [loading, updateLoading] = useState(() => false)
|
let [loading, updateLoading] = useState(() => false)
|
||||||
let [popupRoute, updateRoute] = useState<string | null>(() => null)
|
let [popupRoute, updateRoute] = useState<string | null>(() => null)
|
||||||
|
|
||||||
|
@ -40,21 +37,22 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
||||||
<label>{nickname}</label>
|
<label>{nickname}</label>
|
||||||
<p>{friendlyPublicKey(publicKey)}</p>
|
<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>
|
<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>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
{
|
{
|
||||||
popupRoute === 'profile'
|
popupRoute === 'profile'
|
||||||
? <a onClick={() => updateRoute('')} className="button filled">⇐ Back</a>
|
? <a onClick={() => updateRoute('')} className="filled">⇐ Back</a>
|
||||||
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
: <header>
|
: <header>
|
||||||
{
|
{
|
||||||
popupRoute === 'profile'
|
popupRoute === 'profile'
|
||||||
? <a onClick={() => updateRoute('')} className="button filled">⇐ Back</a>
|
? <a onClick={() => updateRoute('')} className="filled">⇐ Back</a>
|
||||||
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>
|
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
@ -123,10 +121,10 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
||||||
<section>
|
<section>
|
||||||
<label>Pick a mode:</label>
|
<label>Pick a mode:</label>
|
||||||
<div className='options'>
|
<div className='options'>
|
||||||
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
<a onClick={() => setExtensionSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
||||||
Redirect
|
Redirect
|
||||||
</a>
|
</a>
|
||||||
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
<a onClick={() => setExtensionSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
||||||
Show a button
|
Show a button
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -135,7 +133,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
||||||
<label>Which platform you would like to redirect?</label>
|
<label>Which platform you would like to redirect?</label>
|
||||||
<div className='options'>
|
<div className='options'>
|
||||||
{targetPlatforms.map(([name, value]) =>
|
{targetPlatforms.map(([name, value]) =>
|
||||||
<a onClick={() => setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
|
<a onClick={() => setExtensionSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
|
||||||
{value.displayName}
|
{value.displayName}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -145,7 +143,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
||||||
<label>Which resolver API you want to use?</label>
|
<label>Which resolver API you want to use?</label>
|
||||||
<div className='options'>
|
<div className='options'>
|
||||||
{ytUrlResolverOptions.map(([name, value]) =>
|
{ytUrlResolverOptions.map(([name, value]) =>
|
||||||
<a onClick={() => setSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
|
<a onClick={() => setExtensionSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
|
||||||
{value.name}
|
{value.name}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -156,7 +154,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<label>Tools</label>
|
<label>Tools</label>
|
||||||
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
|
<a target='_blank' href='/tools/YTtoLBRY/index.html' className={`filled`}>
|
||||||
Subscription Converter
|
Subscription Converter
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
|
@ -1,18 +1,3 @@
|
||||||
label {
|
|
||||||
font-size: 1.75em;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
|
@ -34,6 +19,12 @@ section {
|
||||||
gap: .75em;
|
gap: .75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section label {
|
||||||
|
font-size: 1.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
#popup {
|
#popup {
|
||||||
width: 35em;
|
width: 35em;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
|
@ -1,12 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<script src="popup.tsx" defer></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root" />
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { resolveById } from '../common/yt/urlResolve'
|
import { resolveById } from "../modules/yt/urlResolve"
|
||||||
|
|
||||||
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}
|
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings'
|
import { parseYouTubeURLTimeString } from '../modules/yt'
|
||||||
import { parseYouTubeURLTimeString } from '../common/yt'
|
import { resolveById } from '../modules/yt/urlResolve'
|
||||||
import { resolveById } from '../common/yt/urlResolve'
|
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../settings'
|
||||||
|
|
||||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
||||||
|
|
||||||
|
@ -111,14 +111,13 @@ async function requestResolveById(...params: Parameters<typeof resolveById>): Re
|
||||||
const videoId = url.searchParams.get('v')!
|
const videoId = url.searchParams.get('v')!
|
||||||
const result = await requestResolveById([{ id: videoId, type: 'video' }])
|
const result = await requestResolveById([{ id: videoId, type: 'video' }])
|
||||||
const target: Target | null = result?.[videoId] ? { lbryPathname: result[videoId].id, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
|
const target: Target | null = result?.[videoId] ? { lbryPathname: result[videoId].id, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
|
||||||
|
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
else if (url.pathname.startsWith('/channel/')) {
|
else if (url.pathname.startsWith('/channel/')) {
|
||||||
await requestResolveById([{ id: url.pathname.substring("/channel/".length), type: 'channel' }])
|
await requestResolveById([{ id: url.pathname.substring("/channel/".length), type: 'channel' }])
|
||||||
}
|
}
|
||||||
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/'))
|
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
|
// We have to download the page content again because these parts of the page are not responsive
|
||||||
// yt front end sucks anyway
|
// yt front end sucks anyway
|
||||||
const content = await (await fetch(location.href)).text()
|
const content = await (await fetch(location.href)).text()
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, sourcePlatfromSettings, targetPlatformSettings, ytUrlResolversSettings } from '../common/settings'
|
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '.'
|
||||||
import { setSetting } from '../common/useSettings'
|
|
||||||
|
|
||||||
/** Reset settings to default value and update the browser badge text */
|
/** Reset settings to default value and update the browser badge text */
|
||||||
async function initSettings() {
|
async function initSettings() {
|
||||||
|
@ -16,8 +15,8 @@ async function initSettings() {
|
||||||
settings = await getExtensionSettingsAsync()
|
settings = await getExtensionSettingsAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
||||||
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
||||||
|
|
||||||
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
||||||
}
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { JSX } from "preact"
|
import { JSX } from "preact"
|
||||||
|
import { useEffect, useReducer } from "preact/hooks"
|
||||||
|
|
||||||
export interface ExtensionSettings {
|
export interface ExtensionSettings {
|
||||||
redirect: boolean
|
redirect: boolean
|
||||||
|
@ -20,6 +21,39 @@ export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
||||||
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
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: {
|
const targetPlatform = (o: {
|
||||||
domainPrefix: string
|
domainPrefix: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
@ -46,7 +80,7 @@ export const targetPlatformSettings = {
|
||||||
theme: '#075656',
|
theme: '#075656',
|
||||||
button: {
|
button: {
|
||||||
text: 'Watch on',
|
text: 'Watch on',
|
||||||
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
|
icon: chrome.runtime.getURL('assets/icons/lbry/madiator-logo.svg'),
|
||||||
style: {
|
style: {
|
||||||
button: { flexDirection: 'row-reverse' },
|
button: { flexDirection: 'row-reverse' },
|
||||||
icon: { transform: 'scale(1.2)' }
|
icon: { transform: 'scale(1.2)' }
|
||||||
|
@ -59,7 +93,7 @@ export const targetPlatformSettings = {
|
||||||
theme: '#1e013b',
|
theme: '#1e013b',
|
||||||
button: {
|
button: {
|
||||||
text: 'Watch on Odysee',
|
text: 'Watch on Odysee',
|
||||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
icon: chrome.runtime.getURL('assets/icons/lbry/odysee-logo.svg')
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
app: targetPlatform({
|
app: targetPlatform({
|
||||||
|
@ -68,7 +102,7 @@ export const targetPlatformSettings = {
|
||||||
theme: '#075656',
|
theme: '#075656',
|
||||||
button: {
|
button: {
|
||||||
text: 'Watch on LBRY',
|
text: 'Watch on LBRY',
|
||||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
icon: chrome.runtime.getURL('assets/icons/lbry/lbry-logo.svg')
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
|
@ -1,93 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text {
|
|
||||||
font-weight: bold;
|
|
||||||
padding: .25em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,15 +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,70 +0,0 @@
|
||||||
import { h, render } from 'preact'
|
|
||||||
import { useState } from 'preact/hooks'
|
|
||||||
import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings'
|
|
||||||
import { getFileContent, getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../common/yt'
|
|
||||||
import { resolveById } from '../common/yt/urlResolve'
|
|
||||||
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' ? getSubsFromOpml :
|
|
||||||
ext === 'csv' ? getSubsFromCsv :
|
|
||||||
getSubsFromJson)(await getFileContent(file)))
|
|
||||||
|
|
||||||
const items = await resolveById(
|
|
||||||
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
|
|
||||||
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!))
|
|
||||||
|
|
||||||
const { targetPlatform: platform } = await getExtensionSettingsAsync()
|
|
||||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
|
||||||
return Object.values(items).map((item) => urlPrefix + item.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
|
|
||||||
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 class="progress-text">
|
|
||||||
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function YTtoLBRY({ progress }: { progress: number }) {
|
|
||||||
const [lbryChannels, setLbryChannels] = useState([] as string[])
|
|
||||||
|
|
||||||
return <div className='YTtoLBRY'>
|
|
||||||
<div className='Conversion'>
|
|
||||||
<ConversionCard progress={progress} 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 progress={0} />, document.getElementById('root')!)
|
|