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' {
|
||||
var _: string
|
||||
export default _
|
||||
}
|
||||
}
|
|
@ -5,8 +5,8 @@
|
|||
"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\"",
|
||||
"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": "npm-run-all -l -p build:parcel build: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 {
|
||||
font-size: .95rem;
|
||||
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;
|
||||
|
@ -24,7 +31,7 @@ body {
|
|||
|
||||
body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(19, 19, 19, 0.75);
|
||||
}
|
||||
|
@ -36,6 +43,30 @@ body::before {
|
|||
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 {
|
||||
display: inline-flex;
|
||||
|
@ -48,14 +79,9 @@ body::before {
|
|||
color: var(--color-light);
|
||||
|
||||
border-radius: .5em;
|
||||
}
|
||||
border: unset;
|
||||
|
||||
.filled {
|
||||
background: var(--color-gradient-0);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
font-weight: bold;
|
||||
color: transparent;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.button.active {
|
||||
|
@ -77,13 +103,36 @@ body::before {
|
|||
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;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
|
||||
justify-content: center;
|
||||
gap: .25em;
|
||||
padding: 0 1.5em;
|
||||
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 {
|
|
@ -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": {
|
||||
"scripts": [
|
||||
"scripts/storageSetup.js",
|
||||
"settings/background.js",
|
||||
"scripts/background.js"
|
||||
],
|
||||
"persistent": true
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "Watch on LBRY",
|
||||
"default_popup": "popup/popup.html"
|
||||
"default_popup": "pages/popup/index.html"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
"popup.html",
|
||||
"tools/YTtoLBRY.html",
|
||||
"icons/lbry/lbry-logo.svg",
|
||||
"icons/lbry/odysee-logo.svg",
|
||||
"icons/lbry/madiator-logo.svg"
|
||||
"pages/popup/index.html",
|
||||
"pages/YTtoLBRY/index.html",
|
||||
"assets/icons/lbry/lbry-logo.svg",
|
||||
"assets/icons/lbry/odysee-logo.svg",
|
||||
"assets/icons/lbry/madiator-logo.svg"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/wol/icon16.png",
|
||||
"48": "icons/wol/icon48.png",
|
||||
"128": "icons/wol/icon128.png"
|
||||
"16": "assets/icons/wol/icon16.png",
|
||||
"48": "assets/icons/wol/icon48.png",
|
||||
"128": "assets/icons/wol/icon128.png"
|
||||
},
|
||||
"manifest_version": 2
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "./settings"
|
||||
import { setSetting } from "./useSettings"
|
||||
import path from 'path'
|
||||
import { getExtensionSettingsAsync, setExtensionSetting, ytUrlResolversSettings } from "../../settings"
|
||||
|
||||
async function generateKeys() {
|
||||
const keys = await window.crypto.subtle.generateKey(
|
||||
|
@ -38,7 +37,6 @@ async function exportPublicKey(key: CryptoKey) {
|
|||
key
|
||||
)
|
||||
const publicKey = Buffer.from(exported).toString('base64')
|
||||
console.log(publicKey)
|
||||
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
|
||||
}
|
||||
|
||||
|
@ -64,8 +62,8 @@ export async function sign(data: string, privateKey: string) {
|
|||
}
|
||||
|
||||
export function resetProfileSettings() {
|
||||
setSetting('publicKey', null)
|
||||
setSetting('privateKey', null)
|
||||
setExtensionSetting('publicKey', null)
|
||||
setExtensionSetting('privateKey', null)
|
||||
}
|
||||
|
||||
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]
|
||||
if (!urlResolverSettings.signRequest) throw new Error() */
|
||||
|
||||
console.log(ytUrlResolversSettings)
|
||||
const url = new URL(ytUrlResolversSettings.madiatorFinder.href/* urlResolverSettings.href */)
|
||||
console.log(url)
|
||||
url.pathname = path.join(url.pathname, pathname)
|
||||
url.searchParams.set('data', JSON.stringify(data))
|
||||
|
||||
|
@ -113,8 +109,8 @@ export async function generateProfileAndSetNickname(overwrite = false) {
|
|||
publicKey = keys.publicKey
|
||||
privateKey = keys.privateKey
|
||||
})
|
||||
setSetting('publicKey', publicKey)
|
||||
setSetting('privateKey', privateKey)
|
||||
setExtensionSetting('publicKey', publicKey)
|
||||
setExtensionSetting('privateKey', privateKey)
|
||||
}
|
||||
await apiRequest('POST', '/profile', { nickname })
|
||||
alert(`Your nickname has been set to ${nickname}`)
|
||||
|
@ -198,8 +194,8 @@ export async function importProfileKeysFromFile() {
|
|||
const json = await readFile()
|
||||
if (!json) throw new Error("Invalid")
|
||||
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
|
||||
setSetting('publicKey', publicKey)
|
||||
setSetting('privateKey', privateKey)
|
||||
setExtensionSetting('publicKey', publicKey)
|
||||
setExtensionSetting('privateKey', privateKey)
|
||||
} catch (error: any) {
|
||||
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
|
||||
*
|
|
@ -1,8 +1,8 @@
|
|||
import { chunk } from "lodash"
|
||||
import { sign } from "../crypto"
|
||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
|
||||
import { LbryPathnameCache } from "./urlCache"
|
||||
import path from "path"
|
||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../../settings"
|
||||
import { sign } from "../crypto"
|
||||
import { LbryPathnameCache } from "./urlCache"
|
||||
|
||||
const QUERY_CHUNK_SIZE = 100
|
||||
|
||||
|
@ -11,8 +11,10 @@ type Results = Record<string, YtUrlResolveItem>
|
|||
type Paramaters = YtUrlResolveItem[]
|
||||
|
||||
interface ApiResponse {
|
||||
channels?: Record<string, string>
|
||||
videos?: Record<string, string>
|
||||
data: {
|
||||
channels?: Record<string, string>
|
||||
videos?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return item
|
||||
}))).filter((o) => o) as Paramaters
|
||||
console.log(params)
|
||||
|
||||
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' })
|
||||
if (apiResponse.ok) {
|
||||
const response: ApiResponse = await apiResponse.json()
|
||||
for (const item of params)
|
||||
{
|
||||
const lbryUrl = ((item.type === 'channel' ? response.channels : response.videos) ?? {})[item.id]?.replaceAll('#', ':') ?? null
|
||||
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 LbryPathnameCache.put(lbryUrl, item.id)
|
||||
|
||||
|
@ -76,7 +76,6 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
|
|||
if (progressCallback) progressCallback(++i / (chunks.length + 1))
|
||||
Object.assign(results, await requestChunk(chunk))
|
||||
}
|
||||
console.log(results)
|
||||
|
||||
if (progressCallback) progressCallback(1)
|
||||
return results
|
|
@ -3,4 +3,4 @@
|
|||
1. Go to https://takeout.google.com/settings/takeout
|
||||
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
|
||||
3. Go through the process and create the export
|
||||
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
||||
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension
|
17
src/pages/YTtoLBRY/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="../../assets/styles/common.css" />
|
||||
<script src="main.tsx" charset="utf-8" defer></script>
|
||||
</head>
|
||||
|
||||
<body id="page">
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
80
src/pages/YTtoLBRY/main.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { getFileContent } from '../../modules/file'
|
||||
import { getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../../modules/yt'
|
||||
import { resolveById } from '../../modules/yt/urlResolve'
|
||||
import { targetPlatformSettings, useExtensionSettings } from '../../settings'
|
||||
import readme from './README.md'
|
||||
|
||||
async function getSubscribedChannelIdsFromFile(file: File) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
const content = await getFileContent(file)
|
||||
switch (ext) {
|
||||
case 'xml':
|
||||
case 'opml':
|
||||
return getSubsFromOpml(content)
|
||||
case 'csv':
|
||||
return getSubsFromCsv(content)
|
||||
default:
|
||||
return getSubsFromJson(content)
|
||||
}
|
||||
}
|
||||
|
||||
async function findChannels(channelIds: string[], progressCallback: Parameters<typeof resolveById>['1']) {
|
||||
const resultItems = await resolveById(channelIds.map((channelId) => ({ id: channelId, type: 'channel' })), progressCallback)
|
||||
return Object.values(resultItems).map((item) => item.id)
|
||||
}
|
||||
|
||||
function Conversion() {
|
||||
const [file, setFile] = useState(null as File | null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [lbryChannelIds, setLbryChannels] = useState([] as Awaited<ReturnType<typeof findChannels>>)
|
||||
const settings = useExtensionSettings()
|
||||
|
||||
let loading = progress > 0 && progress !== 1
|
||||
|
||||
return <div className='conversion'>
|
||||
<form onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (file) setLbryChannels(await findChannels(await getSubscribedChannelIdsFromFile(file), (progress) => setProgress(progress)))
|
||||
}}
|
||||
>
|
||||
<div class="fields">
|
||||
<label for="conversion-file">Select YouTube Subscriptions</label>
|
||||
<input id="conversion-file" type='file' onChange={event => setFile(event.currentTarget.files?.length ? event.currentTarget.files[0] : null)} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button className={`button ${!file || progress > 0 ? '' : 'active'}`} disabled={!file || loading}>
|
||||
{loading ? `${(progress * 100).toFixed(1)}%` : 'Start Conversion!'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{
|
||||
progress === 1 &&
|
||||
<div class="results">
|
||||
<b>Results:</b>
|
||||
{
|
||||
lbryChannelIds.length > 0
|
||||
? lbryChannelIds.map((lbryChannelId) =>
|
||||
<article class="result-item">
|
||||
<a href={`${targetPlatformSettings[settings.targetPlatform].domainPrefix}${lbryChannelId}`}>{lbryChannelId}</a>
|
||||
</article>)
|
||||
: <span class="error">No Result</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
function YTtoLBRY() {
|
||||
return <main>
|
||||
<Conversion />
|
||||
<aside class="help">
|
||||
<iframe allowFullScreen
|
||||
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
|
||||
<section dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</aside>
|
||||
</main>
|
||||
}
|
||||
|
||||
render(<YTtoLBRY />, document.getElementById('root')!)
|
86
src/pages/YTtoLBRY/style.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
main {
|
||||
display: flex;
|
||||
flex-wrap: wrap-reverse;
|
||||
min-height: 100vh;
|
||||
gap: 2em;
|
||||
padding: 1em;
|
||||
overflow-wrap: break-word;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.conversion,
|
||||
.help {
|
||||
flex: 1;
|
||||
min-width: 40em;
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.conversion>*,
|
||||
.help>* {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: .5em;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.help iframe {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.conversion {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.conversion form {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.conversion form .fields {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.conversion form .actions::after {
|
||||
content: "";
|
||||
flex-grow: 100000000000000000;
|
||||
}
|
||||
|
||||
.conversion form button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversion form button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conversion .results {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.help a,
|
||||
.conversion .results a {
|
||||
background: var(--color-gradient-0);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.conversion .results .result-item {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
14
src/pages/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 { useState } from 'preact/hooks'
|
||||
import '../common/common.css'
|
||||
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
|
||||
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
|
||||
import { setSetting, useLbrySettings } from '../common/useSettings'
|
||||
import { LbryPathnameCache } from '../common/yt/urlCache'
|
||||
import './popup.css'
|
||||
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../../modules/crypto'
|
||||
import { LbryPathnameCache } from '../../modules/yt/urlCache'
|
||||
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, setExtensionSetting, useExtensionSettings } from '../../settings'
|
||||
|
||||
|
||||
/** Gets all the options for redirect destinations as selection options */
|
||||
|
@ -13,7 +10,7 @@ const targetPlatforms = getTargetPlatfromSettingsEntiries()
|
|||
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
|
||||
|
||||
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 [popupRoute, updateRoute] = useState<string | null>(() => null)
|
||||
|
||||
|
@ -40,21 +37,22 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
|||
<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>
|
||||
<section>
|
||||
{
|
||||
popupRoute === 'profile'
|
||||
? <a onClick={() => updateRoute('')} className="button filled">⇐ Back</a>
|
||||
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||
? <a onClick={() => updateRoute('')} className="filled">⇐ Back</a>
|
||||
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||
}
|
||||
</section>
|
||||
</header>
|
||||
: <header>
|
||||
{
|
||||
popupRoute === 'profile'
|
||||
? <a onClick={() => updateRoute('')} className="button filled">⇐ Back</a>
|
||||
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>
|
||||
}
|
||||
popupRoute === 'profile'
|
||||
? <a onClick={() => updateRoute('')} className="filled">⇐ Back</a>
|
||||
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||
}
|
||||
</header>
|
||||
}
|
||||
{
|
||||
|
@ -123,10 +121,10 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
|||
<section>
|
||||
<label>Pick a mode:</label>
|
||||
<div className='options'>
|
||||
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
||||
<a onClick={() => setExtensionSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
||||
Redirect
|
||||
</a>
|
||||
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
||||
<a onClick={() => setExtensionSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
||||
Show a button
|
||||
</a>
|
||||
</div>
|
||||
|
@ -135,7 +133,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
|||
<label>Which platform you would like to redirect?</label>
|
||||
<div className='options'>
|
||||
{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}
|
||||
</a>
|
||||
)}
|
||||
|
@ -145,7 +143,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
|||
<label>Which resolver API you want to use?</label>
|
||||
<div className='options'>
|
||||
{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}
|
||||
</a>
|
||||
)}
|
||||
|
@ -156,7 +154,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
|
|||
</section>
|
||||
<section>
|
||||
<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
|
||||
</a>
|
||||
</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 {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
|
@ -34,6 +19,12 @@ section {
|
|||
gap: .75em;
|
||||
}
|
||||
|
||||
section label {
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#popup {
|
||||
width: 35em;
|
||||
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>> = {}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { h, render } from 'preact'
|
||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings'
|
||||
import { parseYouTubeURLTimeString } from '../common/yt'
|
||||
import { resolveById } from '../common/yt/urlResolve'
|
||||
import { parseYouTubeURLTimeString } from '../modules/yt'
|
||||
import { resolveById } from '../modules/yt/urlResolve'
|
||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../settings'
|
||||
|
||||
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 result = await requestResolveById([{ id: videoId, type: 'video' }])
|
||||
const target: Target | null = result?.[videoId] ? { lbryPathname: result[videoId].id, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
|
||||
|
||||
|
||||
return target
|
||||
}
|
||||
else if (url.pathname.startsWith('/channel/')) {
|
||||
await requestResolveById([{ id: url.pathname.substring("/channel/".length), type: 'channel' }])
|
||||
}
|
||||
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/'))
|
||||
{
|
||||
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()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, sourcePlatfromSettings, targetPlatformSettings, ytUrlResolversSettings } from '../common/settings'
|
||||
import { setSetting } from '../common/useSettings'
|
||||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '.'
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
|
@ -16,8 +15,8 @@ async function initSettings() {
|
|||
settings = await getExtensionSettingsAsync()
|
||||
}
|
||||
|
||||
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
||||
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
||||
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
|
||||
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
|
||||
|
||||
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { JSX } from "preact"
|
||||
import { useEffect, useReducer } from "preact/hooks"
|
||||
|
||||
export interface ExtensionSettings {
|
||||
redirect: boolean
|
||||
|
@ -20,6 +21,39 @@ 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
|
||||
|
@ -46,7 +80,7 @@ export const targetPlatformSettings = {
|
|||
theme: '#075656',
|
||||
button: {
|
||||
text: 'Watch on',
|
||||
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
|
||||
icon: chrome.runtime.getURL('assets/icons/lbry/madiator-logo.svg'),
|
||||
style: {
|
||||
button: { flexDirection: 'row-reverse' },
|
||||
icon: { transform: 'scale(1.2)' }
|
||||
|
@ -59,7 +93,7 @@ export const targetPlatformSettings = {
|
|||
theme: '#1e013b',
|
||||
button: {
|
||||
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({
|
||||
|
@ -68,7 +102,7 @@ export const targetPlatformSettings = {
|
|||
theme: '#075656',
|
||||
button: {
|
||||
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')!)
|