mirror of
https://github.com/LBRYFoundation/Watch-on-LBRY.git
synced 2025-08-23 17:47:26 +00:00
commit
889c64c0a6
19 changed files with 585 additions and 659 deletions
|
@ -1,7 +1,7 @@
|
|||
import { h } from 'preact';
|
||||
import classnames from 'classnames';
|
||||
import classnames from 'classnames'
|
||||
import { h } from 'preact'
|
||||
import './ButtonRadio.sass'
|
||||
|
||||
import './ButtonRadio.sass';
|
||||
|
||||
export interface SelectionOption {
|
||||
value: string
|
||||
|
@ -9,13 +9,13 @@ export interface SelectionOption {
|
|||
}
|
||||
|
||||
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
|
||||
name?: string;
|
||||
onChange(redirect: string): void;
|
||||
value: T extends SelectionOption ? T['value'] : T;
|
||||
options: T[];
|
||||
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];
|
||||
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 */
|
||||
|
@ -27,5 +27,5 @@ export default function ButtonRadio<T extends string | SelectionOption = string>
|
|||
<label>{display}</label>
|
||||
</div>
|
||||
)}
|
||||
</div>;
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from './lbry-url';
|
||||
import { appRedirectUrl, parseProtocolUrl } from './lbry-url'
|
||||
|
||||
describe('web url parsing', () => {
|
||||
const testCases: [string, string | undefined][] = [
|
||||
|
@ -9,21 +9,21 @@ describe('web url parsing', () => {
|
|||
['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);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
expect(parseProtocolUrl(url)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -8,14 +8,14 @@ interface UrlOptions {
|
|||
encode?: boolean
|
||||
}
|
||||
|
||||
const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source;
|
||||
const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source
|
||||
|
||||
/** Creates a named regex group */
|
||||
const named = (name: string, regex: string) => `(?<${name}>${regex})`;
|
||||
const named = (name: string, regex: string) => `(?<${name}>${regex})`
|
||||
/** Creates a non-capturing group */
|
||||
const group = (regex: string) => `(?:${regex})`;
|
||||
const group = (regex: string) => `(?:${regex})`
|
||||
/** Allows for one of the patterns */
|
||||
const oneOf = (...choices: string[]) => group(choices.join('|'));
|
||||
const oneOf = (...choices: string[]) => group(choices.join('|'))
|
||||
/** Create an lbry url claim */
|
||||
const claim = (name: string, prefix = '') => group(
|
||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
||||
|
@ -24,7 +24,7 @@ const claim = (name: string, prefix = '') => group(
|
|||
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(
|
||||
|
@ -33,34 +33,34 @@ const legacyClaim = (name: string, prefix = '') => group(
|
|||
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 };
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
export const URL_REGEX = createWebUrlRegex()
|
||||
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex()
|
||||
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true)
|
||||
|
||||
/**
|
||||
* Encapsulates a lbry url path segment.
|
||||
|
@ -78,15 +78,15 @@ export class PathSegment {
|
|||
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;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,18 +98,18 @@ export class PathSegment {
|
|||
* @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 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;
|
||||
: null
|
||||
|
||||
if (!segments) throw new Error(`${url} matched the overall pattern, but could not determine type`);
|
||||
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);
|
||||
.map(s => options.encode ? encodeURIComponent(s) : s)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,11 +119,11 @@ function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { enco
|
|||
* @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('/');
|
||||
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options)
|
||||
if (segments.length === 0) return
|
||||
const path = segments.join('/')
|
||||
|
||||
return `lbry://${path}`;
|
||||
return `lbry://${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -134,9 +134,9 @@ export function appRedirectUrl(url: string, options?: UrlOptions): string | unde
|
|||
*/
|
||||
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;
|
||||
const segments = patternSegmenter(ptn, url, options)
|
||||
if (segments.length === 0) continue
|
||||
return segments
|
||||
}
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -1,50 +1,76 @@
|
|||
import { JSX } from "preact"
|
||||
|
||||
export interface ExtensionSettings {
|
||||
redirect: boolean
|
||||
targetPlatform: TargetPlatformName
|
||||
urlResolver: YTUrlResolverName
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' };
|
||||
export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' }
|
||||
|
||||
export function getExtensionSettingsAsync<K extends Array<keyof ExtensionSettings>>(...keys: K): Promise<Pick<ExtensionSettings, K[number]>> {
|
||||
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
|
||||
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
||||
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
||||
export interface TargetPlatformSettings {
|
||||
export interface TargetPlatform {
|
||||
domainPrefix: string
|
||||
displayName: string
|
||||
theme: string
|
||||
button: {
|
||||
text: string
|
||||
icon: string
|
||||
style?:
|
||||
{
|
||||
icon?: JSX.CSSProperties
|
||||
button?: JSX.CSSProperties
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatformSettings> = {
|
||||
export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform> = {
|
||||
'madiator.com': {
|
||||
domainPrefix: 'https://madiator.com/',
|
||||
displayName: 'Madiator.com',
|
||||
theme: '#075656'
|
||||
theme: '#075656',
|
||||
button: {
|
||||
text: 'Watch on',
|
||||
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
|
||||
style: {
|
||||
button: { flexDirection: 'row-reverse' },
|
||||
icon: { transform: 'scale(1.2)' }
|
||||
}
|
||||
}
|
||||
},
|
||||
odysee: {
|
||||
domainPrefix: 'https://odysee.com/',
|
||||
displayName: 'Odysee',
|
||||
theme: '#1e013b'
|
||||
theme: '#1e013b',
|
||||
button: {
|
||||
text: 'Watch on Odysee',
|
||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||
}
|
||||
},
|
||||
app: {
|
||||
domainPrefix: 'lbry://',
|
||||
displayName: 'LBRY App',
|
||||
theme: '#075656'
|
||||
theme: '#075656',
|
||||
button: {
|
||||
text: 'Watch on LBRY',
|
||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getTargetPlatfromSettingsEntiries = () => {
|
||||
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatformSettings][]
|
||||
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type SourcePlatfromName = 'youtube.com' | 'yewtu.be'
|
||||
export interface SourcePlatfromSettings {
|
||||
export type SourcePlatformName = 'youtube.com' | 'yewtu.be'
|
||||
export interface SourcePlatform {
|
||||
hostnames: string[]
|
||||
htmlQueries: {
|
||||
mountButtonBefore: string,
|
||||
|
@ -52,7 +78,7 @@ export interface SourcePlatfromSettings {
|
|||
}
|
||||
}
|
||||
|
||||
export const sourcePlatfromSettings: Record<SourcePlatfromName, SourcePlatfromSettings> = {
|
||||
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
|
||||
"yewtu.be": {
|
||||
hostnames: ['yewtu.be'],
|
||||
htmlQueries: {
|
||||
|
@ -83,15 +109,13 @@ export const Keys = Symbol('keys')
|
|||
export const Values = Symbol('values')
|
||||
export const SingleValueAtATime = Symbol()
|
||||
export type YtUrlResolveResponsePath = (string | number | typeof Keys | typeof Values)[]
|
||||
export interface YtUrlResolveFunction
|
||||
{
|
||||
export interface YtUrlResolveFunction {
|
||||
pathname: string
|
||||
paramName: string
|
||||
paramArraySeperator: string | typeof SingleValueAtATime
|
||||
responsePath: YtUrlResolveResponsePath
|
||||
}
|
||||
export interface YTUrlResolver
|
||||
{
|
||||
export interface YTUrlResolver {
|
||||
name: string
|
||||
hostname: string
|
||||
functions: {
|
||||
|
@ -102,7 +126,7 @@ export interface YTUrlResolver
|
|||
|
||||
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
||||
lbryInc: {
|
||||
name: "LBRY Inc.",
|
||||
name: "Odysee",
|
||||
hostname: "api.odysee.com",
|
||||
functions: {
|
||||
getChannelId: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useReducer, useEffect } from 'preact/hooks';
|
||||
import { DEFAULT_SETTINGS } from './settings';
|
||||
import { useEffect, useReducer } from 'preact/hooks'
|
||||
import { DEFAULT_SETTINGS } from './settings'
|
||||
|
||||
/**
|
||||
* A hook to read the settings from local storage
|
||||
|
@ -7,24 +7,24 @@ import { DEFAULT_SETTINGS } from './settings';
|
|||
* @param initial the default value. Must have all relevant keys present and should not change
|
||||
*/
|
||||
export function useSettings<T extends object>(initial: T) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial)
|
||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||
useEffect(() => {
|
||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== 'local') return;
|
||||
if (areaName !== 'local') return
|
||||
const changeSet = Object.keys(changes)
|
||||
.filter(k => Object.keys(initial).includes(k))
|
||||
.map(k => [k, changes[k].newValue]);
|
||||
if (changeSet.length === 0) return; // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeSet));
|
||||
};
|
||||
chrome.storage.onChanged.addListener(changeListener);
|
||||
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
|
||||
return () => chrome.storage.onChanged.removeListener(changeListener);
|
||||
}, []);
|
||||
.map(k => [k, changes[k].newValue])
|
||||
if (changeSet.length === 0) return // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeSet))
|
||||
}
|
||||
chrome.storage.onChanged.addListener(changeListener)
|
||||
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>))
|
||||
return () => chrome.storage.onChanged.removeListener(changeListener)
|
||||
}, [])
|
||||
|
||||
return state;
|
||||
return state
|
||||
}
|
||||
|
||||
/** A hook to read watch on lbry settings from local storage */
|
||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS);
|
||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)
|
||||
|
|
292
src/common/yt.ts
292
src/common/yt.ts
|
@ -1,292 +0,0 @@
|
|||
import chunk from 'lodash/chunk';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YTUrlResolver, YtUrlResolveResponsePath, ytUrlResolversSettings } from './settings'
|
||||
|
||||
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
|
||||
const QUERY_CHUNK_SIZE = 300;
|
||||
|
||||
interface YtExportedJsonSubscription {
|
||||
id: string;
|
||||
etag: string;
|
||||
title: string;
|
||||
snippet: {
|
||||
description: string;
|
||||
resourceId: {
|
||||
channelId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''));
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort();
|
||||
reject(new DOMException(`Could not read ${file.name}`));
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export interface YtIdResolverDescriptor {
|
||||
id: string
|
||||
type: 'channel' | 'video'
|
||||
}
|
||||
|
||||
const URLResolverCache = (() =>
|
||||
{
|
||||
const openRequest = indexedDB.open("yt-url-resolver-cache")
|
||||
|
||||
if (typeof self.indexedDB !== 'undefined')
|
||||
{
|
||||
openRequest.addEventListener('upgradeneeded', () =>
|
||||
{
|
||||
const db = openRequest.result
|
||||
const store = db.createObjectStore("store")
|
||||
store.createIndex("expireAt", "expireAt")
|
||||
})
|
||||
|
||||
// Delete Expired
|
||||
openRequest.addEventListener('success', () =>
|
||||
{
|
||||
const db = openRequest.result
|
||||
const transaction = db.transaction("store", "readwrite")
|
||||
const range = IDBKeyRange.upperBound(new Date())
|
||||
|
||||
const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range)
|
||||
expireAtCursorRequest.addEventListener('success', () =>
|
||||
{
|
||||
const expireCursor = expireAtCursorRequest.result
|
||||
if (!expireCursor) return
|
||||
expireCursor.delete()
|
||||
expireCursor.continue()
|
||||
})
|
||||
})
|
||||
}
|
||||
else console.warn(`IndexedDB not supported`)
|
||||
|
||||
async function put(url: string | null, id: string) : Promise<void>
|
||||
{
|
||||
return await new Promise((resolve, reject) =>
|
||||
{
|
||||
const db = openRequest.result
|
||||
if (!db) return resolve()
|
||||
const store = db.transaction("store", "readwrite").objectStore("store")
|
||||
const putRequest = store.put({ value: url, expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, id)
|
||||
putRequest.addEventListener('success', () => resolve())
|
||||
putRequest.addEventListener('error', () => reject(putRequest.error))
|
||||
})
|
||||
}
|
||||
async function get(id: string): Promise<string | null>
|
||||
{
|
||||
return (await new Promise((resolve, reject) =>
|
||||
{
|
||||
const db = openRequest.result
|
||||
if (!db) return resolve(null)
|
||||
const store = db.transaction("store", "readonly").objectStore("store")
|
||||
const getRequest = store.get(id)
|
||||
getRequest.addEventListener('success', () => resolve(getRequest.result))
|
||||
getRequest.addEventListener('error', () => reject(getRequest.error))
|
||||
}) as any)?.value
|
||||
}
|
||||
|
||||
return { put, get }
|
||||
})()
|
||||
|
||||
export const ytService = {
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml');
|
||||
opmlContents = ''
|
||||
return Array.from(opml.querySelectorAll('outline > outline'))
|
||||
.map(outline => outline.getAttribute('xmlUrl'))
|
||||
.filter((url): url is string => !!url)
|
||||
.map(url => ytService.getChannelId(url))
|
||||
.filter((url): url is string => !!url); // we don't want it if it's empty
|
||||
},
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions JSON file
|
||||
*
|
||||
* @param jsonContents a JSON file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readJson(jsonContents: string): string[] {
|
||||
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents);
|
||||
jsonContents = ''
|
||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions CSV file
|
||||
*
|
||||
* @param csvContent a CSV file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
readCsv(csvContent: string): string[] {
|
||||
const rows = csvContent.split('\n')
|
||||
csvContent = ''
|
||||
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the channelID from a YT URL.
|
||||
*
|
||||
* Handles these two types of YT URLs:
|
||||
* * /feeds/videos.xml?channel_id=*
|
||||
* * /channel/*
|
||||
*/
|
||||
getChannelId(channelURL: string) {
|
||||
const match = channelURL.match(/channel\/([^\s?]*)/);
|
||||
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id');
|
||||
},
|
||||
|
||||
/** Extracts the video ID from a YT URL */
|
||||
getVideoId(url: string) {
|
||||
const regex = /watch\/?\?.*v=([^\s&]*)/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null; // match[1] is the videoId
|
||||
},
|
||||
|
||||
getId(url: string): YtIdResolverDescriptor | null {
|
||||
const videoId = ytService.getVideoId(url);
|
||||
if (videoId) return { id: videoId, type: 'video' };
|
||||
const channelId = ytService.getChannelId(url);
|
||||
if (channelId) return { id: channelId, type: 'channel' };
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param descriptorsWithIndex YT resource IDs to check
|
||||
* @returns a promise with the list of channels that were found on lbry
|
||||
*/
|
||||
async resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<(string | null)[]> {
|
||||
const descriptorsWithIndex: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({...descriptor, index}))
|
||||
descriptors = null as any
|
||||
const results: (string | null)[] = []
|
||||
|
||||
await Promise.all(descriptorsWithIndex.map(async (descriptor, index) => {
|
||||
if (!descriptor) return
|
||||
const cache = await URLResolverCache.get(descriptor.id)
|
||||
|
||||
// Cache can be null, if there is no lbry url yet
|
||||
if (cache !== undefined) {
|
||||
// Directly setting it to results
|
||||
results[index] = cache
|
||||
|
||||
// We remove it so we dont ask it to API
|
||||
descriptorsWithIndex.splice(index, 1)
|
||||
}
|
||||
}))
|
||||
|
||||
const descriptorsChunks = chunk(descriptorsWithIndex, QUERY_CHUNK_SIZE);
|
||||
let progressCount = 0;
|
||||
await Promise.all(descriptorsChunks.map(async (descriptorChunk) =>
|
||||
{
|
||||
const descriptorsGroupedByType: Record<YtIdResolverDescriptor['type'], typeof descriptorsWithIndex | null> = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any;
|
||||
|
||||
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync('urlResolver')
|
||||
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||
|
||||
const url = new URL(`https://${urlResolverSetting.hostname}`);
|
||||
|
||||
function followResponsePath<T>(response: any, responsePath: YtUrlResolveResponsePath)
|
||||
{
|
||||
for (const path of responsePath)
|
||||
{
|
||||
switch (typeof path)
|
||||
{
|
||||
case 'string':
|
||||
case 'number':
|
||||
response = response[path]
|
||||
break
|
||||
default:
|
||||
switch (path)
|
||||
{
|
||||
case Keys:
|
||||
response = Object.keys(response)
|
||||
break
|
||||
case Values:
|
||||
response = Object.values(response)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsWithIndex)
|
||||
{
|
||||
url.pathname = urlResolverFunction.pathname
|
||||
|
||||
if (urlResolverFunction.paramArraySeperator === SingleValueAtATime)
|
||||
{
|
||||
await Promise.all(descriptorsGroup.map(async (descriptor) => {
|
||||
switch (null)
|
||||
{
|
||||
default:
|
||||
if (!descriptor.id) break
|
||||
url.searchParams.set(urlResolverFunction.paramName, descriptor.id)
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' });
|
||||
if (!apiResponse.ok) {
|
||||
// Some API might not respond with 200 if it can't find the url
|
||||
if (apiResponse.status === 404) await URLResolverCache.put(null, descriptor.id)
|
||||
break
|
||||
}
|
||||
|
||||
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||
if (value) results[descriptor.index] = value
|
||||
await URLResolverCache.put(value, descriptor.id)
|
||||
}
|
||||
progressCount++
|
||||
if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length)
|
||||
}))
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
switch (null)
|
||||
{
|
||||
default:
|
||||
url.searchParams
|
||||
.set(urlResolverFunction.paramName, descriptorsGroup
|
||||
.map((descriptor) => descriptor.id)
|
||||
.filter((descriptorId) => descriptorId)
|
||||
.join(urlResolverFunction.paramArraySeperator)
|
||||
)
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' });
|
||||
if (!apiResponse.ok) break
|
||||
const values = followResponsePath<string[]>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||
|
||||
await Promise.all(values.map(async (value, index) => {
|
||||
const descriptor = descriptorsGroup[index]
|
||||
if (value) results[descriptor.index] = value
|
||||
await URLResolverCache.put(value, descriptor.id)
|
||||
}))
|
||||
}
|
||||
progressCount += descriptorsGroup.length
|
||||
if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel'])
|
||||
if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video'])
|
||||
|
||||
}));
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
101
src/common/yt/index.ts
Normal file
101
src/common/yt/index.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
|
||||
interface YtExportedJsonSubscription {
|
||||
id: string
|
||||
etag: string
|
||||
title: string
|
||||
snippet: {
|
||||
description: string
|
||||
resourceId: {
|
||||
channelId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param file to load
|
||||
* @returns a promise with the file as a string
|
||||
*/
|
||||
export function getFileContent(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', event => resolve(event.target?.result as string || ''))
|
||||
reader.addEventListener('error', () => {
|
||||
reader.abort()
|
||||
reject(new DOMException(`Could not read ${file.name}`))
|
||||
})
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads the array of YT channels from an OPML file
|
||||
*
|
||||
* @param opmlContents an opml file as as tring
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromOpml(opmlContents: string): string[] {
|
||||
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml')
|
||||
opmlContents = ''
|
||||
return Array.from(opml.querySelectorAll('outline > outline'))
|
||||
.map(outline => outline.getAttribute('xmlUrl'))
|
||||
.filter((url): url is string => !!url)
|
||||
.map(url => getChannelId(url))
|
||||
.filter((url): url is string => !!url) // we don't want it if it's empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions JSON file
|
||||
*
|
||||
* @param jsonContents a JSON file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromJson(jsonContents: string): string[] {
|
||||
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents)
|
||||
jsonContents = ''
|
||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an array of YT channel IDs from the YT subscriptions CSV file
|
||||
*
|
||||
* @param csvContent a CSV file as a string
|
||||
* @returns the channel IDs
|
||||
*/
|
||||
export function getSubsFromCsv(csvContent: string): string[] {
|
||||
const rows = csvContent.split('\n')
|
||||
csvContent = ''
|
||||
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the channelID from a YT URL.
|
||||
*
|
||||
* Handles these two types of YT URLs:
|
||||
* * /feeds/videos.xml?channel_id=*
|
||||
* * /channel/*
|
||||
*/
|
||||
export function getChannelId(channelURL: string) {
|
||||
const match = channelURL.match(/channel\/([^\s?]*)/)
|
||||
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id')
|
||||
}
|
||||
|
||||
export function parseYouTubeURLTimeString(timeString: string) {
|
||||
const signs = timeString.replace(/[0-9]/g, '')
|
||||
if (signs.length === 0) return null
|
||||
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
|
||||
let total = 0
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
let t = parseInt(numbers[i])
|
||||
switch (signs[i]) {
|
||||
case 'd': t *= 24
|
||||
case 'h': t *= 60
|
||||
case 'm': t *= 60
|
||||
case 's': break
|
||||
default: return null
|
||||
}
|
||||
total += t
|
||||
}
|
||||
return total
|
||||
}
|
48
src/common/yt/urlCache.ts
Normal file
48
src/common/yt/urlCache.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
// This should only work in background
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
if (typeof self.indexedDB !== 'undefined') {
|
||||
const openRequest = indexedDB.open("yt-url-resolver-cache")
|
||||
openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt"))
|
||||
|
||||
// Delete Expired
|
||||
openRequest.addEventListener('success', () => {
|
||||
db = openRequest.result
|
||||
const transaction = db.transaction("store", "readwrite")
|
||||
const range = IDBKeyRange.upperBound(new Date())
|
||||
|
||||
const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range)
|
||||
expireAtCursorRequest.addEventListener('success', () => {
|
||||
const expireCursor = expireAtCursorRequest.result
|
||||
if (!expireCursor) return
|
||||
expireCursor.delete()
|
||||
expireCursor.continue()
|
||||
})
|
||||
})
|
||||
}
|
||||
else console.warn(`IndexedDB not supported`)
|
||||
|
||||
|
||||
async function put(url: string | null, id: string): Promise<void> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const store = db?.transaction("store", "readwrite").objectStore("store")
|
||||
if (!store) return resolve()
|
||||
const request = store.put({ value: url, expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, id)
|
||||
request.addEventListener('success', () => resolve())
|
||||
request.addEventListener('error', () => reject(request.error))
|
||||
})
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<string | null> {
|
||||
return (await new Promise((resolve, reject) => {
|
||||
const store = db?.transaction("store", "readonly").objectStore("store")
|
||||
if (!store) return resolve(null)
|
||||
const request = store.get(id)
|
||||
request.addEventListener('success', () => resolve(request.result))
|
||||
request.addEventListener('error', () => reject(request.error))
|
||||
}) as any)?.value
|
||||
}
|
||||
|
||||
export const LbryPathnameCache = { put, get }
|
||||
|
104
src/common/yt/urlResolve.ts
Normal file
104
src/common/yt/urlResolve.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { chunk, groupBy } from "lodash"
|
||||
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from "../settings"
|
||||
import { LbryPathnameCache } from "./urlCache"
|
||||
|
||||
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
|
||||
const QUERY_CHUNK_SIZE = 300
|
||||
|
||||
export interface YtIdResolverDescriptor {
|
||||
id: string
|
||||
type: 'channel' | 'video'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param descriptorsWithIndex YT resource IDs to check
|
||||
* @returns a promise with the list of channels that were found on lbry
|
||||
*/
|
||||
export async function resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<(string | null)[]> {
|
||||
let descriptorsPayload: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({ ...descriptor, index }))
|
||||
descriptors = null as any
|
||||
const results: (string | null)[] = []
|
||||
|
||||
|
||||
descriptorsPayload = (await Promise.all(descriptorsPayload.map(async (descriptor, index) => {
|
||||
if (!descriptor?.id) return
|
||||
const cache = await LbryPathnameCache.get(descriptor.id)
|
||||
|
||||
// Cache can be null, if there is no lbry url yet
|
||||
if (cache !== undefined) {
|
||||
// Null values shouldn't be in the results
|
||||
if (cache) results[index] = cache
|
||||
return
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}))).filter((descriptor) => descriptor) as any
|
||||
|
||||
const descriptorsPayloadChunks = chunk(descriptorsPayload, QUERY_CHUNK_SIZE)
|
||||
let progressCount = 0
|
||||
await Promise.all(descriptorsPayloadChunks.map(async (descriptorChunk) => {
|
||||
const descriptorsGroupedByType: Record<YtIdResolverDescriptor['type'], typeof descriptorsPayload | null> = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any
|
||||
|
||||
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync()
|
||||
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||
|
||||
const url = new URL(`https://${urlResolverSetting.hostname}`)
|
||||
|
||||
function followResponsePath<T>(response: any, responsePath: YtUrlResolveResponsePath) {
|
||||
for (const path of responsePath) {
|
||||
switch (typeof path) {
|
||||
case 'string': case 'number': response = response[path]; continue
|
||||
}
|
||||
switch (path) {
|
||||
case Keys: response = Object.keys(response); continue
|
||||
case Values: response = Object.values(response); continue
|
||||
}
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsPayload) {
|
||||
url.pathname = urlResolverFunction.pathname
|
||||
|
||||
if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) {
|
||||
await Promise.all(descriptorsGroup.map(async (descriptor) => {
|
||||
url.searchParams.set(urlResolverFunction.paramName, descriptor.id)
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||
if (apiResponse.ok) {
|
||||
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||
if (value) results[descriptor.index] = value
|
||||
await LbryPathnameCache.put(value, descriptor.id)
|
||||
}
|
||||
else if (apiResponse.status === 404) await LbryPathnameCache.put(null, descriptor.id)
|
||||
|
||||
progressCount++
|
||||
if (progressCallback) progressCallback(progressCount / descriptorsPayload.length)
|
||||
}))
|
||||
}
|
||||
else {
|
||||
url.searchParams.set(urlResolverFunction.paramName, descriptorsGroup
|
||||
.map((descriptor) => descriptor.id)
|
||||
.join(urlResolverFunction.paramArraySeperator))
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||
if (apiResponse.ok) {
|
||||
const values = followResponsePath<string[]>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||
await Promise.all(values.map(async (value, index) => {
|
||||
const descriptor = descriptorsGroup[index]
|
||||
if (value) results[descriptor.index] = value
|
||||
await LbryPathnameCache.put(value, descriptor.id)
|
||||
}))
|
||||
}
|
||||
|
||||
progressCount += descriptorsGroup.length
|
||||
if (progressCallback) progressCallback(progressCount / descriptorsPayload.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel'])
|
||||
if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video'])
|
||||
}))
|
||||
if (progressCallback) progressCallback(1)
|
||||
return results
|
||||
}
|
4
src/global.d.ts
vendored
4
src/global.d.ts
vendored
|
@ -1,4 +1,4 @@
|
|||
declare module '*.md' {
|
||||
var _: string;
|
||||
export default _;
|
||||
var _: string
|
||||
export default _
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"background": {
|
||||
"scripts": [
|
||||
"scripts/storageSetup.js",
|
||||
"scripts/tabOnUpdated.js"
|
||||
"scripts/background.js"
|
||||
],
|
||||
"persistent": false
|
||||
},
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { h, render } from 'preact'
|
||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
|
||||
import { getTargetPlatfromSettingsEntiries, ExtensionSettings, TargetPlatformName, getYtUrlResolversSettingsEntiries, YTUrlResolverName } from '../common/settings'
|
||||
import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings'
|
||||
import { useLbrySettings } from '../common/useSettings'
|
||||
import './popup.sass'
|
||||
|
||||
/** Utilty to set a setting in the browser */
|
||||
const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value });
|
||||
const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
|
||||
|
||||
/** Gets all the options for redirect destinations as selection options */
|
||||
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
|
||||
.map(([value, { displayName: display }]) => ({ value, display }));
|
||||
.map(([value, { displayName: display }]) => ({ value, display }))
|
||||
|
||||
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
||||
.map(([value, { name: display }]) => ({ value, display }));
|
||||
.map(([value, { name: display }]) => ({ value, display }))
|
||||
|
||||
function WatchOnLbryPopup() {
|
||||
const { redirect, targetPlatform, urlResolver } = useLbrySettings();
|
||||
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
|
||||
|
||||
return <div className='container'>
|
||||
<section>
|
||||
|
@ -39,7 +39,7 @@ function WatchOnLbryPopup() {
|
|||
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
||||
</a>
|
||||
</section>
|
||||
</div>;
|
||||
</div>
|
||||
}
|
||||
|
||||
render(<WatchOnLbryPopup />, document.getElementById('root')!);
|
||||
render(<WatchOnLbryPopup />, document.getElementById('root')!)
|
||||
|
|
24
src/scripts/background.ts
Normal file
24
src/scripts/background.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { parseProtocolUrl } from '../common/lbry-url'
|
||||
import { resolveById, YtIdResolverDescriptor } from '../common/yt/urlResolve'
|
||||
async function resolveYT(descriptor: YtIdResolverDescriptor) {
|
||||
const lbryProtocolUrl: string | null = await resolveById([descriptor]).then(a => a[0])
|
||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true })
|
||||
if (segments.length === 0) return
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
const onGoingLbryPathnameRequest: Record<string, Promise<string | void>> = {}
|
||||
async function lbryPathnameFromVideoId(videoId: string): Promise<string | void> {
|
||||
// Don't create a new Promise for same ID until on going one is over.
|
||||
const promise = onGoingLbryPathnameRequest[videoId] ?? (onGoingLbryPathnameRequest[videoId] = resolveYT({ id: videoId, type: 'video' }))
|
||||
await promise
|
||||
delete onGoingLbryPathnameRequest[videoId]
|
||||
return await promise
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => {
|
||||
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname))
|
||||
return true
|
||||
})
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => changeInfo.url && chrome.tabs.sendMessage(tabId, {}))
|
|
@ -1,28 +1,28 @@
|
|||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings';
|
||||
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings'
|
||||
|
||||
/** Reset settings to default value and update the browser badge text */
|
||||
async function initSettings() {
|
||||
const settings = await getExtensionSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof ExtensionSettings>);
|
||||
const settings = await getExtensionSettingsAsync()
|
||||
|
||||
// get all the values that aren't set and use them as a change set
|
||||
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof ExtensionSettings, ExtensionSettings[keyof ExtensionSettings]]>)
|
||||
.filter(([k]) => settings[k] === null || settings[k] === undefined);
|
||||
.filter(([k]) => settings[k] === null || settings[k] === undefined)
|
||||
|
||||
// fix our local var and set it in storage for later
|
||||
if (invalidEntries.length > 0) {
|
||||
const changeSet = Object.fromEntries(invalidEntries);
|
||||
Object.assign(settings, changeSet);
|
||||
chrome.storage.local.set(changeSet);
|
||||
const changeSet = Object.fromEntries(invalidEntries)
|
||||
Object.assign(settings, changeSet)
|
||||
chrome.storage.local.set(changeSet)
|
||||
}
|
||||
|
||||
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' });
|
||||
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local' || !changes.redirect) return;
|
||||
chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' });
|
||||
});
|
||||
if (areaName !== 'local' || !changes.redirect) return
|
||||
chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' })
|
||||
})
|
||||
|
||||
|
||||
chrome.runtime.onStartup.addListener(initSettings);
|
||||
chrome.runtime.onInstalled.addListener(initSettings);
|
||||
chrome.runtime.onStartup.addListener(initSettings)
|
||||
chrome.runtime.onInstalled.addListener(initSettings)
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'
|
||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatformName } from '../common/settings'
|
||||
import { YtIdResolverDescriptor, ytService } from '../common/yt'
|
||||
export interface UpdateContext {
|
||||
descriptor: YtIdResolverDescriptor
|
||||
/** LBRY URL fragment */
|
||||
lbryPathname: string
|
||||
redirect: boolean
|
||||
targetPlatform: TargetPlatformName
|
||||
}
|
||||
|
||||
async function resolveYT(descriptor: YtIdResolverDescriptor) {
|
||||
const lbryProtocolUrl: string | null = await ytService.resolveById([descriptor]).then(a => a[0]);
|
||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
||||
if (segments.length === 0) return;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
const ctxFromURLOnGoingPromise: Record<string, Promise<UpdateContext | void>> = {}
|
||||
async function ctxFromURL(href: string): Promise<UpdateContext | void> {
|
||||
if (!href) return;
|
||||
|
||||
const url = new URL(href);
|
||||
if (!getSourcePlatfromSettingsFromHostname(url.hostname)) return
|
||||
if (!(url.pathname.startsWith('/watch') || url.pathname.startsWith('/channel'))) return
|
||||
|
||||
const descriptor = ytService.getId(href);
|
||||
if (!descriptor) return; // couldn't get the ID, so we're done
|
||||
|
||||
// Don't create a new Promise for same ID until on going one is over.
|
||||
const promise = ctxFromURLOnGoingPromise[descriptor.id] ?? (ctxFromURLOnGoingPromise[descriptor.id] = (async () => {
|
||||
// NOTE: API call cached by resolveYT method automatically
|
||||
const res = await resolveYT(descriptor)
|
||||
if (!res) return // couldn't find it on lbry, so we're done
|
||||
|
||||
const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform')
|
||||
return { descriptor, lbryPathname: res, redirect, targetPlatform }
|
||||
})())
|
||||
await promise
|
||||
delete ctxFromURLOnGoingPromise[descriptor.id]
|
||||
return await promise
|
||||
}
|
||||
|
||||
// handles lbry.tv -> lbry app redirect
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
|
||||
const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform');
|
||||
if (!redirect || targetPlatform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return;
|
||||
|
||||
const url = appRedirectUrl(tabUrl, { encode: true });
|
||||
if (!url) return;
|
||||
chrome.tabs.update(tabId, { url });
|
||||
alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed
|
||||
chrome.tabs.executeScript(tabId, {
|
||||
code: `if (window.history.length === 1) {
|
||||
window.close();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
document.querySelectorAll('video').forEach(v => v.pause());
|
||||
`
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResponse) => {
|
||||
ctxFromURL(url).then(ctx => {
|
||||
sendResponse(ctx);
|
||||
})
|
||||
return true;
|
||||
})
|
||||
|
||||
// relay youtube link changes to the content script
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
|
||||
if (url) ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
|
||||
});
|
|
@ -1,179 +1,169 @@
|
|||
import { getSourcePlatfromSettingsFromHostname, TargetPlatformName, targetPlatformSettings } from '../common/settings'
|
||||
import type { UpdateContext } from '../scripts/tabOnUpdated'
|
||||
import { h, JSX, render } from 'preact'
|
||||
import { h, render } from 'preact'
|
||||
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings'
|
||||
import { parseYouTubeURLTimeString } from '../common/yt'
|
||||
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t));
|
||||
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
||||
|
||||
function pauseAllVideos() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
|
||||
|
||||
interface ButtonSettings {
|
||||
text: string
|
||||
icon: string
|
||||
style?:
|
||||
{
|
||||
icon?: JSX.CSSProperties
|
||||
button?: JSX.CSSProperties
|
||||
}
|
||||
}
|
||||
|
||||
const buttonSettings: Record<TargetPlatformName, ButtonSettings> = {
|
||||
app: {
|
||||
text: 'Watch on LBRY',
|
||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||
},
|
||||
'madiator.com': {
|
||||
text: 'Watch on',
|
||||
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
|
||||
style: {
|
||||
button: { flexDirection: 'row-reverse' },
|
||||
icon: { transform: 'scale(1.2)' }
|
||||
}
|
||||
},
|
||||
odysee: {
|
||||
text: 'Watch on Odysee',
|
||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||
},
|
||||
};
|
||||
|
||||
interface ButtonParameters
|
||||
{
|
||||
targetPlatform?: TargetPlatformName
|
||||
interface WatchOnLbryButtonParameters {
|
||||
targetPlatform?: TargetPlatform
|
||||
lbryPathname?: string
|
||||
time?: number
|
||||
}
|
||||
|
||||
export function WatchOnLbryButton({ targetPlatform = 'app', lbryPathname, time }: ButtonParameters = {}) {
|
||||
if (!lbryPathname || !targetPlatform) return null;
|
||||
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
||||
const buttonSetting = buttonSettings[targetPlatform];
|
||||
interface Target {
|
||||
platfrom: TargetPlatform
|
||||
lbryPathname: string
|
||||
time: number | null
|
||||
}
|
||||
|
||||
const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`)
|
||||
if (time) url.searchParams.append('t', time.toFixed(0))
|
||||
function WatchOnLbryButton({ targetPlatform, lbryPathname, time }: WatchOnLbryButtonParameters) {
|
||||
if (!lbryPathname || !targetPlatform) return null
|
||||
|
||||
const url = new URL(`${targetPlatform.domainPrefix}${lbryPathname}`)
|
||||
if (time) url.searchParams.set('t', time.toFixed(0))
|
||||
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
|
||||
<a href={`${url.toString()}`} onClick={pauseAllVideos} role='button'
|
||||
<a href={`${url.toString()}`} role='button'
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: targetPlatformSetting.theme,
|
||||
backgroundColor: targetPlatform.theme,
|
||||
border: '0',
|
||||
color: 'whitesmoke',
|
||||
padding: '10px 16px',
|
||||
marginRight: '4px',
|
||||
fontSize: '14px',
|
||||
textDecoration: 'none',
|
||||
...buttonSetting.style?.button,
|
||||
...targetPlatform.button.style?.button,
|
||||
}}>
|
||||
<img src={buttonSetting.icon} height={16}
|
||||
style={{ transform: 'scale(1.5)', ...buttonSetting.style?.icon }} />
|
||||
<span>{buttonSetting.text}</span>
|
||||
<img src={targetPlatform.button.icon} height={16}
|
||||
style={{ transform: 'scale(1.5)', ...targetPlatform.button.style?.icon }} />
|
||||
<span>{targetPlatform.button.text}</span>
|
||||
</a>
|
||||
</div>;
|
||||
</div>
|
||||
}
|
||||
|
||||
function updateButton(mountPoint: HTMLDivElement, target: Target | null): void {
|
||||
if (!target) return render(<WatchOnLbryButton />, mountPoint)
|
||||
render(<WatchOnLbryButton targetPlatform={target.platfrom} lbryPathname={target.lbryPathname} time={target.time ?? undefined} />, mountPoint)
|
||||
}
|
||||
|
||||
async function redirectTo({ lbryPathname, platfrom, time }: Target) {
|
||||
const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`)
|
||||
|
||||
if (time) url.searchParams.set('t', time.toFixed(0))
|
||||
|
||||
findVideoElement().then((videoElement) => {
|
||||
videoElement.addEventListener('play', () => videoElement.pause(), { once: true })
|
||||
videoElement.pause()
|
||||
})
|
||||
|
||||
if (platfrom === targetPlatformSettings.app) {
|
||||
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
|
||||
open(url, '_blank')
|
||||
if (window.history.length === 1) window.close()
|
||||
else window.history.back()
|
||||
}
|
||||
else
|
||||
location.replace(url.toString())
|
||||
}
|
||||
|
||||
let mountPoint: HTMLDivElement | null = null
|
||||
/** Returns a mount point for the button */
|
||||
async function findButtonMountPoint(): Promise<HTMLDivElement | void> {
|
||||
async function findButtonMountPoint(): Promise<HTMLDivElement> {
|
||||
const id = 'watch-on-lbry-button-container'
|
||||
let mountBefore: HTMLDivElement | null = null
|
||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||
const exits: HTMLDivElement | null = document.querySelector(`#${id}`)
|
||||
if (exits) return exits
|
||||
while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200)
|
||||
|
||||
while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.style.display = 'flex';
|
||||
const div = document.createElement('div')
|
||||
div.id = id
|
||||
div.style.display = 'flex'
|
||||
div.style.alignItems = 'center'
|
||||
mountBefore.parentElement?.insertBefore(div, mountBefore)
|
||||
mountPoint = div
|
||||
|
||||
return div
|
||||
}
|
||||
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
async function findVideoElement() {
|
||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||
|
||||
let videoElement: HTMLVideoElement | null = null
|
||||
while (!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200)
|
||||
|
||||
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
|
||||
return videoElement
|
||||
}
|
||||
|
||||
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
||||
let ctxCache: UpdateContext | null = null
|
||||
function handleURLChange (ctx: UpdateContext | null): void {
|
||||
ctxCache = ctx
|
||||
updateButton(ctx)
|
||||
if (ctx?.redirect) redirectTo(ctx)
|
||||
// We should get this from background, so the caching works and we don't get errors in the future if yt decides to impliment CORS
|
||||
async function requestLbryPathname(videoId: string) {
|
||||
return await new Promise<string | null>((resolve) => chrome.runtime.sendMessage({ videoId }, resolve))
|
||||
}
|
||||
|
||||
function updateButton(ctx: UpdateContext | null): void {
|
||||
if (!mountPoint) return
|
||||
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
|
||||
if (ctx.descriptor.type !== 'video') return;
|
||||
const lbryPathname = ctx.lbryPathname
|
||||
const targetPlatform = ctx.targetPlatform
|
||||
let time: number = videoElement?.currentTime ?? 0
|
||||
if (time < 3) time = 0
|
||||
if (time >= (videoElement?.duration ?? 0) - 1) time = 0
|
||||
// Start
|
||||
(async () => {
|
||||
const settings = await getExtensionSettingsAsync()
|
||||
let updater: (() => Promise<void>)
|
||||
|
||||
render(<WatchOnLbryButton targetPlatform={targetPlatform} lbryPathname={lbryPathname} time={time || undefined} />, mountPoint)
|
||||
}
|
||||
|
||||
function redirectTo({ targetPlatform, lbryPathname }: UpdateContext): void {
|
||||
|
||||
const parseYouTubeTime = (timeString: string) => {
|
||||
const signs = timeString.replace(/[0-9]/g, '')
|
||||
if (signs.length === 0) return timeString
|
||||
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
|
||||
let total = 0
|
||||
for (let i = 0; i < signs.length; i++) {
|
||||
let t = parseInt(numbers[i])
|
||||
switch (signs[i]) {
|
||||
case 'd': t *= 24; case 'h': t *= 60; case 'm': t *= 60; case 's': break
|
||||
default: return '0'
|
||||
}
|
||||
total += t
|
||||
}
|
||||
return total.toString()
|
||||
}
|
||||
|
||||
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
||||
const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`)
|
||||
const time = new URL(location.href).searchParams.get('t')
|
||||
|
||||
if (time) url.searchParams.append('t', parseYouTubeTime(time))
|
||||
|
||||
if (targetPlatform === 'app')
|
||||
{
|
||||
pauseAllVideos();
|
||||
location.assign(url);
|
||||
return
|
||||
}
|
||||
location.replace(url.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
findButtonMountPoint().then(() => updateButton(ctxCache))
|
||||
findVideoElement().then(() => updateButton(ctxCache))
|
||||
|
||||
|
||||
/** Request UpdateContext from background */
|
||||
const requestCtxFromUrl = async (url: string) => await new Promise<UpdateContext | null>((resolve) => chrome.runtime.sendMessage({ url }, resolve))
|
||||
|
||||
/** Handle the location on load of the page */
|
||||
requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx))
|
||||
// Listen Settings Change
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== 'local') return
|
||||
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])))
|
||||
if (changes.redirect) await onModeChange()
|
||||
})
|
||||
|
||||
/*
|
||||
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
|
||||
* history.pushState changes from a content script
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChange(ctx));
|
||||
// Listen URL Change
|
||||
chrome.runtime.onMessage.addListener(() => updater())
|
||||
|
||||
/** On settings change */
|
||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||
if (areaName !== 'local') return;
|
||||
if (changes.targetPlatform) handleURLChange(await requestCtxFromUrl(location.href))
|
||||
});
|
||||
async function getTargetByURL(url: URL) {
|
||||
if (url.pathname !== '/watch') return null
|
||||
|
||||
const videoId = url.searchParams.get('v')
|
||||
const lbryPathname = videoId && await requestLbryPathname(videoId)
|
||||
const target: Target | null = lbryPathname ? { lbryPathname, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
let removeVideoTimeUpdateListener: (() => void) | null = null
|
||||
async function onModeChange() {
|
||||
let target: Target | null = null
|
||||
if (settings.redirect)
|
||||
updater = async () => {
|
||||
const url = new URL(location.href)
|
||||
target = await getTargetByURL(url)
|
||||
if (!target) return
|
||||
target.time = url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null
|
||||
redirectTo(target)
|
||||
}
|
||||
else {
|
||||
const mountPoint = await findButtonMountPoint()
|
||||
const videoElement = await findVideoElement()
|
||||
|
||||
const getTime = () => videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null
|
||||
|
||||
const onTimeUpdate = () => target && updateButton(mountPoint, Object.assign(target, { time: getTime() }))
|
||||
removeVideoTimeUpdateListener?.call(null)
|
||||
videoElement.addEventListener('timeupdate', onTimeUpdate)
|
||||
removeVideoTimeUpdateListener = () => videoElement.removeEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
updater = async () => {
|
||||
const url = new URL(location.href)
|
||||
target = await getTargetByURL(url)
|
||||
if (target) target.time = getTime()
|
||||
updateButton(mountPoint, target)
|
||||
}
|
||||
}
|
||||
|
||||
await updater()
|
||||
}
|
||||
|
||||
await onModeChange()
|
||||
})()
|
|
@ -1,5 +1,6 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Subscription Converter</title>
|
||||
|
@ -10,4 +11,5 @@
|
|||
<body>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,11 +1,10 @@
|
|||
import { h, render } from 'preact'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings'
|
||||
import { getFileContent, ytService } from '../common/yt'
|
||||
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
|
||||
*
|
||||
|
@ -13,23 +12,23 @@ import readme from './README.md'
|
|||
* @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 ext = file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
const ids = new Set((
|
||||
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
|
||||
ext === 'csv' ? ytService.readCsv :
|
||||
ytService.readJson)(await getFileContent(file)))
|
||||
const lbryUrls = await ytService.resolveById(
|
||||
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
|
||||
ext === 'csv' ? getSubsFromCsv :
|
||||
getSubsFromJson)(await getFileContent(file)))
|
||||
const lbryPathnames = 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('targetPlatform');
|
||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix;
|
||||
return lbryUrls.map(channel => urlPrefix + channel);
|
||||
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!))
|
||||
const { targetPlatform: platform } = await getExtensionSettingsAsync()
|
||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
||||
return lbryPathnames.map(channel => urlPrefix + channel)
|
||||
}
|
||||
|
||||
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);
|
||||
const [file, setFile] = useState(null as File | null)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
||||
return <div className='ConversionCard'>
|
||||
<h2>Select YouTube Subscriptions</h2>
|
||||
|
@ -37,10 +36,10 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<
|
|||
<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);
|
||||
if (!file) return
|
||||
setLoading(true)
|
||||
await onSelect(file)
|
||||
setLoading(false)
|
||||
}} />
|
||||
<div class="progress-text">
|
||||
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
|
||||
|
@ -49,7 +48,7 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<
|
|||
}
|
||||
|
||||
function YTtoLBRY({ progress }: { progress: number }) {
|
||||
const [lbryChannels, setLbryChannels] = useState([] as string[]);
|
||||
const [lbryChannels, setLbryChannels] = useState([] as string[])
|
||||
|
||||
return <div className='YTtoLBRY'>
|
||||
<div className='Conversion'>
|
||||
|
@ -66,4 +65,4 @@ function YTtoLBRY({ progress }: { progress: number }) {
|
|||
</div>
|
||||
}
|
||||
|
||||
render(<YTtoLBRY progress={0} />, document.getElementById('root')!);
|
||||
render(<YTtoLBRY progress={0} />, document.getElementById('root')!)
|
||||
|
|
Loading…
Add table
Reference in a new issue