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 {
|
export interface SelectionOption {
|
||||||
value: string
|
value: string
|
||||||
|
@ -9,13 +9,13 @@ export interface SelectionOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
|
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
|
||||||
name?: string;
|
name?: string
|
||||||
onChange(redirect: string): void;
|
onChange(redirect: string): void
|
||||||
value: T extends SelectionOption ? T['value'] : T;
|
value: T extends SelectionOption ? T['value'] : T
|
||||||
options: 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>) {
|
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 */
|
/** 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>
|
<label>{display}</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>;
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { appRedirectUrl, parseProtocolUrl } from './lbry-url';
|
import { appRedirectUrl, parseProtocolUrl } from './lbry-url'
|
||||||
|
|
||||||
describe('web url parsing', () => {
|
describe('web url parsing', () => {
|
||||||
const testCases: [string, string | undefined][] = [
|
const testCases: [string, string | undefined][] = [
|
||||||
|
@ -9,21 +9,21 @@ describe('web url parsing', () => {
|
||||||
['https://lbry.tv/@test:c', 'lbry://@test:c'],
|
['https://lbry.tv/@test:c', 'lbry://@test:c'],
|
||||||
['https://lbry.tv/$/discover?t=foo%20bar', undefined],
|
['https://lbry.tv/$/discover?t=foo%20bar', undefined],
|
||||||
['https://lbry.tv/$/signup?redirect=/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', undefined],
|
['https://lbry.tv/$/signup?redirect=/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', undefined],
|
||||||
];
|
]
|
||||||
|
|
||||||
test.each(testCases)('redirect %s', (url, expected) => {
|
test.each(testCases)('redirect %s', (url, expected) => {
|
||||||
expect(appRedirectUrl(url)).toEqual(expected);
|
expect(appRedirectUrl(url)).toEqual(expected)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('app url parsing', () => {
|
describe('app url parsing', () => {
|
||||||
const testCases: Array<[string, string[]]> = [
|
const testCases: Array<[string, string[]]> = [
|
||||||
['test', ['test']],
|
['test', ['test']],
|
||||||
['@test', ['@test']],
|
['@test', ['@test']],
|
||||||
['lbry://@test$1/stuff', ['@test$1', 'stuff']],
|
['lbry://@test$1/stuff', ['@test$1', 'stuff']],
|
||||||
];
|
]
|
||||||
|
|
||||||
test.each(testCases)('redirect %s', (url, expected) => {
|
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
|
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 */
|
/** 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 */
|
/** Creates a non-capturing group */
|
||||||
const group = (regex: string) => `(?:${regex})`;
|
const group = (regex: string) => `(?:${regex})`
|
||||||
/** Allows for one of the patterns */
|
/** 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 */
|
/** Create an lbry url claim */
|
||||||
const claim = (name: string, prefix = '') => group(
|
const claim = (name: string, prefix = '') => group(
|
||||||
named(`${name}_name`, prefix + invalidNamesRegex)
|
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}_sequence`, '[1-9][0-9]*')),
|
||||||
group('\\$' + named(`${name}_amount_order`, '[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 */
|
/** Create an lbry url claim, but use the old pattern for claims */
|
||||||
const legacyClaim = (name: string, prefix = '') => group(
|
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}_claim_id`, '[0-9a-f]{1,40}')),
|
||||||
group(':' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
group(':' + named(`${name}_sequence`, '[1-9][0-9]*')),
|
||||||
group('\\$' + named(`${name}_amount_order`, '[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. */
|
/** Creates a pattern to parse lbry protocol URLs. Unused, but I left it here. */
|
||||||
function createProtocolUrlRegex(legacy = false) {
|
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(
|
return new RegExp('^' + named('scheme', 'lbry://') + '?' + oneOf(
|
||||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||||
claim('channel', '@'),
|
claim('channel', '@'),
|
||||||
claim('stream'),
|
claim('stream'),
|
||||||
) + '$');
|
) + '$')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a pattern to match lbry.tv style sites by their pathname */
|
/** Creates a pattern to match lbry.tv style sites by their pathname */
|
||||||
function createWebUrlRegex(legacy = false) {
|
function createWebUrlRegex(legacy = false) {
|
||||||
const claim = legacy ? builder.legacyClaim : builder.claim;
|
const claim = legacy ? builder.legacyClaim : builder.claim
|
||||||
return new RegExp('^/' + oneOf(
|
return new RegExp('^/' + oneOf(
|
||||||
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
|
||||||
claim('channel', '@'),
|
claim('channel', '@'),
|
||||||
claim('stream'),
|
claim('stream'),
|
||||||
) + '$');
|
) + '$')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pattern for lbry.tv style sites */
|
/** Pattern for lbry.tv style sites */
|
||||||
export const URL_REGEX = createWebUrlRegex();
|
export const URL_REGEX = createWebUrlRegex()
|
||||||
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex();
|
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex()
|
||||||
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true);
|
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates a lbry url path segment.
|
* Encapsulates a lbry url path segment.
|
||||||
|
@ -78,15 +78,15 @@ export class PathSegment {
|
||||||
groups[`${segment}_claim_id`],
|
groups[`${segment}_claim_id`],
|
||||||
parseInt(groups[`${segment}_sequence`]),
|
parseInt(groups[`${segment}_sequence`]),
|
||||||
parseInt(groups[`${segment}_amount_order`])
|
parseInt(groups[`${segment}_amount_order`])
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prints the segment */
|
/** Prints the segment */
|
||||||
toString() {
|
toString() {
|
||||||
if (this.claimID) return `${this.name}:${this.claimID}`;
|
if (this.claimID) return `${this.name}:${this.claimID}`
|
||||||
if (this.sequence) return `${this.name}*${this.sequence}`;
|
if (this.sequence) return `${this.name}*${this.sequence}`
|
||||||
if (this.amountOrder) return `${this.name}$${this.amountOrder}`;
|
if (this.amountOrder) return `${this.name}$${this.amountOrder}`
|
||||||
return this.name;
|
return this.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,18 +98,18 @@ export class PathSegment {
|
||||||
* @returns an array of path segments; if invalid, will return an empty array
|
* @returns an array of path segments; if invalid, will return an empty array
|
||||||
*/
|
*/
|
||||||
function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { encode: false }): string[] {
|
function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { encode: false }): string[] {
|
||||||
const match = url.match(ptn)?.groups;
|
const match = url.match(ptn)?.groups
|
||||||
if (!match) return [];
|
if (!match) return []
|
||||||
|
|
||||||
const segments = match['channel_name'] ? ['channel']
|
const segments = match['channel_name'] ? ['channel']
|
||||||
: match['channel_with_stream_name'] ? ['channel_with_stream', 'stream_in_channel']
|
: match['channel_with_stream_name'] ? ['channel_with_stream', 'stream_in_channel']
|
||||||
: match['stream_name'] ? ['stream']
|
: 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())
|
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
|
* @param options options for the redirect
|
||||||
*/
|
*/
|
||||||
export function appRedirectUrl(url: string, options?: UrlOptions): string | undefined {
|
export function appRedirectUrl(url: string, options?: UrlOptions): string | undefined {
|
||||||
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options);
|
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options)
|
||||||
if (segments.length === 0) return;
|
if (segments.length === 0) return
|
||||||
const path = segments.join('/');
|
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[] {
|
export function parseProtocolUrl(url: string, options: UrlOptions = { encode: false }): string[] {
|
||||||
for (const ptn of [PROTOCOL_URL_REGEX, PROTOCOL_URL_REGEX_LEGACY]) {
|
for (const ptn of [PROTOCOL_URL_REGEX, PROTOCOL_URL_REGEX_LEGACY]) {
|
||||||
const segments = patternSegmenter(ptn, url, options);
|
const segments = patternSegmenter(ptn, url, options)
|
||||||
if (segments.length === 0) continue;
|
if (segments.length === 0) continue
|
||||||
return segments;
|
return segments
|
||||||
}
|
}
|
||||||
return [];
|
return []
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,50 +1,76 @@
|
||||||
|
import { JSX } from "preact"
|
||||||
|
|
||||||
export interface ExtensionSettings {
|
export interface ExtensionSettings {
|
||||||
redirect: boolean
|
redirect: boolean
|
||||||
targetPlatform: TargetPlatformName
|
targetPlatform: TargetPlatformName
|
||||||
urlResolver: YTUrlResolverName
|
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]>> {
|
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
||||||
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
|
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
||||||
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
export interface TargetPlatform {
|
||||||
export interface TargetPlatformSettings {
|
|
||||||
domainPrefix: string
|
domainPrefix: string
|
||||||
displayName: string
|
displayName: string
|
||||||
theme: 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': {
|
'madiator.com': {
|
||||||
domainPrefix: 'https://madiator.com/',
|
domainPrefix: 'https://madiator.com/',
|
||||||
displayName: '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: {
|
odysee: {
|
||||||
domainPrefix: 'https://odysee.com/',
|
domainPrefix: 'https://odysee.com/',
|
||||||
displayName: 'Odysee',
|
displayName: 'Odysee',
|
||||||
theme: '#1e013b'
|
theme: '#1e013b',
|
||||||
|
button: {
|
||||||
|
text: 'Watch on Odysee',
|
||||||
|
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
domainPrefix: 'lbry://',
|
domainPrefix: 'lbry://',
|
||||||
displayName: 'LBRY App',
|
displayName: 'LBRY App',
|
||||||
theme: '#075656'
|
theme: '#075656',
|
||||||
|
button: {
|
||||||
|
text: 'Watch on LBRY',
|
||||||
|
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getTargetPlatfromSettingsEntiries = () => {
|
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 type SourcePlatformName = 'youtube.com' | 'yewtu.be'
|
||||||
export interface SourcePlatfromSettings {
|
export interface SourcePlatform {
|
||||||
hostnames: string[]
|
hostnames: string[]
|
||||||
htmlQueries: {
|
htmlQueries: {
|
||||||
mountButtonBefore: string,
|
mountButtonBefore: string,
|
||||||
|
@ -52,7 +78,7 @@ export interface SourcePlatfromSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sourcePlatfromSettings: Record<SourcePlatfromName, SourcePlatfromSettings> = {
|
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
|
||||||
"yewtu.be": {
|
"yewtu.be": {
|
||||||
hostnames: ['yewtu.be'],
|
hostnames: ['yewtu.be'],
|
||||||
htmlQueries: {
|
htmlQueries: {
|
||||||
|
@ -77,21 +103,19 @@ export function getSourcePlatfromSettingsFromHostname(hostname: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type YTUrlResolverName = 'lbryInc' | 'madiatorScrap'
|
export type YTUrlResolverName = 'lbryInc' | 'madiatorScrap'
|
||||||
|
|
||||||
export const Keys = Symbol('keys')
|
export const Keys = Symbol('keys')
|
||||||
export const Values = Symbol('values')
|
export const Values = Symbol('values')
|
||||||
export const SingleValueAtATime = Symbol()
|
export const SingleValueAtATime = Symbol()
|
||||||
export type YtUrlResolveResponsePath = (string | number | typeof Keys | typeof Values)[]
|
export type YtUrlResolveResponsePath = (string | number | typeof Keys | typeof Values)[]
|
||||||
export interface YtUrlResolveFunction
|
export interface YtUrlResolveFunction {
|
||||||
{
|
|
||||||
pathname: string
|
pathname: string
|
||||||
paramName: string
|
paramName: string
|
||||||
paramArraySeperator: string | typeof SingleValueAtATime
|
paramArraySeperator: string | typeof SingleValueAtATime
|
||||||
responsePath: YtUrlResolveResponsePath
|
responsePath: YtUrlResolveResponsePath
|
||||||
}
|
}
|
||||||
export interface YTUrlResolver
|
export interface YTUrlResolver {
|
||||||
{
|
|
||||||
name: string
|
name: string
|
||||||
hostname: string
|
hostname: string
|
||||||
functions: {
|
functions: {
|
||||||
|
@ -102,10 +126,10 @@ export interface YTUrlResolver
|
||||||
|
|
||||||
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
||||||
lbryInc: {
|
lbryInc: {
|
||||||
name: "LBRY Inc.",
|
name: "Odysee",
|
||||||
hostname: "api.odysee.com",
|
hostname: "api.odysee.com",
|
||||||
functions: {
|
functions: {
|
||||||
getChannelId : {
|
getChannelId: {
|
||||||
pathname: "/yt/resolve",
|
pathname: "/yt/resolve",
|
||||||
paramName: "channel_ids",
|
paramName: "channel_ids",
|
||||||
paramArraySeperator: ',',
|
paramArraySeperator: ',',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useReducer, useEffect } from 'preact/hooks';
|
import { useEffect, useReducer } from 'preact/hooks'
|
||||||
import { DEFAULT_SETTINGS } from './settings';
|
import { DEFAULT_SETTINGS } from './settings'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook to read the settings from local storage
|
* 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
|
* @param initial the default value. Must have all relevant keys present and should not change
|
||||||
*/
|
*/
|
||||||
export function useSettings<T extends object>(initial: T) {
|
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
|
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||||
if (areaName !== 'local') return;
|
if (areaName !== 'local') return
|
||||||
const changeSet = Object.keys(changes)
|
const changeSet = Object.keys(changes)
|
||||||
.filter(k => Object.keys(initial).includes(k))
|
.filter(k => Object.keys(initial).includes(k))
|
||||||
.map(k => [k, changes[k].newValue]);
|
.map(k => [k, changes[k].newValue])
|
||||||
if (changeSet.length === 0) return; // no changes; no use dispatching
|
if (changeSet.length === 0) return // no changes; no use dispatching
|
||||||
dispatch(Object.fromEntries(changeSet));
|
dispatch(Object.fromEntries(changeSet))
|
||||||
};
|
}
|
||||||
chrome.storage.onChanged.addListener(changeListener);
|
chrome.storage.onChanged.addListener(changeListener)
|
||||||
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
|
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>))
|
||||||
return () => chrome.storage.onChanged.removeListener(changeListener);
|
return () => chrome.storage.onChanged.removeListener(changeListener)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return state;
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A hook to read watch on lbry settings from local storage */
|
/** 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' {
|
declare module '*.md' {
|
||||||
var _: string;
|
var _: string
|
||||||
export default _;
|
export default _
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"scripts/storageSetup.js",
|
"scripts/storageSetup.js",
|
||||||
"scripts/tabOnUpdated.js"
|
"scripts/background.js"
|
||||||
],
|
],
|
||||||
"persistent": false
|
"persistent": false
|
||||||
},
|
},
|
||||||
|
@ -46,4 +46,4 @@
|
||||||
"128": "icons/wol/icon128.png"
|
"128": "icons/wol/icon128.png"
|
||||||
},
|
},
|
||||||
"manifest_version": 2
|
"manifest_version": 2
|
||||||
}
|
}
|
|
@ -11,4 +11,4 @@
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,21 +1,21 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
|
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 { useLbrySettings } from '../common/useSettings'
|
||||||
import './popup.sass'
|
import './popup.sass'
|
||||||
|
|
||||||
/** Utilty to set a setting in the browser */
|
/** 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 */
|
/** Gets all the options for redirect destinations as selection options */
|
||||||
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
|
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
|
||||||
.map(([value, { displayName: display }]) => ({ value, display }));
|
.map(([value, { displayName: display }]) => ({ value, display }))
|
||||||
|
|
||||||
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
||||||
.map(([value, { name: display }]) => ({ value, display }));
|
.map(([value, { name: display }]) => ({ value, display }))
|
||||||
|
|
||||||
function WatchOnLbryPopup() {
|
function WatchOnLbryPopup() {
|
||||||
const { redirect, targetPlatform, urlResolver } = useLbrySettings();
|
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
|
||||||
|
|
||||||
return <div className='container'>
|
return <div className='container'>
|
||||||
<section>
|
<section>
|
||||||
|
@ -39,7 +39,7 @@ function WatchOnLbryPopup() {
|
||||||
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</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 */
|
/** Reset settings to default value and update the browser badge text */
|
||||||
async function initSettings() {
|
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
|
// 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]]>)
|
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
|
// fix our local var and set it in storage for later
|
||||||
if (invalidEntries.length > 0) {
|
if (invalidEntries.length > 0) {
|
||||||
const changeSet = Object.fromEntries(invalidEntries);
|
const changeSet = Object.fromEntries(invalidEntries)
|
||||||
Object.assign(settings, changeSet);
|
Object.assign(settings, changeSet)
|
||||||
chrome.storage.local.set(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) => {
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName !== 'local' || !changes.redirect) return;
|
if (areaName !== 'local' || !changes.redirect) return
|
||||||
chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' });
|
chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' })
|
||||||
});
|
})
|
||||||
|
|
||||||
|
|
||||||
chrome.runtime.onStartup.addListener(initSettings);
|
chrome.runtime.onStartup.addListener(initSettings)
|
||||||
chrome.runtime.onInstalled.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 { h, render } from 'preact'
|
||||||
import type { UpdateContext } from '../scripts/tabOnUpdated'
|
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings'
|
||||||
import { h, JSX, render } from 'preact'
|
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 WatchOnLbryButtonParameters {
|
||||||
|
targetPlatform?: TargetPlatform
|
||||||
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
|
|
||||||
lbryPathname?: string
|
lbryPathname?: string
|
||||||
time?: number
|
time?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatchOnLbryButton({ targetPlatform = 'app', lbryPathname, time }: ButtonParameters = {}) {
|
interface Target {
|
||||||
if (!lbryPathname || !targetPlatform) return null;
|
platfrom: TargetPlatform
|
||||||
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
lbryPathname: string
|
||||||
const buttonSetting = buttonSettings[targetPlatform];
|
time: number | null
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`)
|
function WatchOnLbryButton({ targetPlatform, lbryPathname, time }: WatchOnLbryButtonParameters) {
|
||||||
if (time) url.searchParams.append('t', time.toFixed(0))
|
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' }}>
|
return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
|
||||||
<a href={`${url.toString()}`} onClick={pauseAllVideos} role='button'
|
<a href={`${url.toString()}`} role='button'
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
backgroundColor: targetPlatformSetting.theme,
|
backgroundColor: targetPlatform.theme,
|
||||||
border: '0',
|
border: '0',
|
||||||
color: 'whitesmoke',
|
color: 'whitesmoke',
|
||||||
padding: '10px 16px',
|
padding: '10px 16px',
|
||||||
marginRight: '4px',
|
marginRight: '4px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
...buttonSetting.style?.button,
|
...targetPlatform.button.style?.button,
|
||||||
}}>
|
}}>
|
||||||
<img src={buttonSetting.icon} height={16}
|
<img src={targetPlatform.button.icon} height={16}
|
||||||
style={{ transform: 'scale(1.5)', ...buttonSetting.style?.icon }} />
|
style={{ transform: 'scale(1.5)', ...targetPlatform.button.style?.icon }} />
|
||||||
<span>{buttonSetting.text}</span>
|
<span>{targetPlatform.button.text}</span>
|
||||||
</a>
|
</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 */
|
/** 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
|
let mountBefore: HTMLDivElement | null = null
|
||||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
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.id = id
|
||||||
const div = document.createElement('div');
|
div.style.display = 'flex'
|
||||||
div.style.display = 'flex';
|
|
||||||
div.style.alignItems = 'center'
|
div.style.alignItems = 'center'
|
||||||
mountBefore.parentElement?.insertBefore(div, mountBefore)
|
mountBefore.parentElement?.insertBefore(div, mountBefore)
|
||||||
mountPoint = div
|
|
||||||
|
return div
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoElement: HTMLVideoElement | null = null;
|
|
||||||
async function findVideoElement() {
|
async function findVideoElement() {
|
||||||
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||||
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
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)
|
while (!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200)
|
||||||
|
return videoElement
|
||||||
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
// 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
|
||||||
let ctxCache: UpdateContext | null = null
|
async function requestLbryPathname(videoId: string) {
|
||||||
function handleURLChange (ctx: UpdateContext | null): void {
|
return await new Promise<string | null>((resolve) => chrome.runtime.sendMessage({ videoId }, resolve))
|
||||||
ctxCache = ctx
|
|
||||||
updateButton(ctx)
|
|
||||||
if (ctx?.redirect) redirectTo(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButton(ctx: UpdateContext | null): void {
|
// Start
|
||||||
if (!mountPoint) return
|
(async () => {
|
||||||
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
|
const settings = await getExtensionSettingsAsync()
|
||||||
if (ctx.descriptor.type !== 'video') return;
|
let updater: (() => Promise<void>)
|
||||||
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
|
|
||||||
|
|
||||||
render(<WatchOnLbryButton targetPlatform={targetPlatform} lbryPathname={lbryPathname} time={time || undefined} />, mountPoint)
|
// 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()
|
||||||
|
})
|
||||||
|
|
||||||
function redirectTo({ targetPlatform, lbryPathname }: UpdateContext): void {
|
/*
|
||||||
|
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
|
||||||
const parseYouTubeTime = (timeString: string) => {
|
* history.pushState changes from a content script
|
||||||
const signs = timeString.replace(/[0-9]/g, '')
|
*/
|
||||||
if (signs.length === 0) return timeString
|
// Listen URL Change
|
||||||
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
|
chrome.runtime.onMessage.addListener(() => updater())
|
||||||
let total = 0
|
|
||||||
for (let i = 0; i < signs.length; i++) {
|
async function getTargetByURL(url: URL) {
|
||||||
let t = parseInt(numbers[i])
|
if (url.pathname !== '/watch') return null
|
||||||
switch (signs[i]) {
|
|
||||||
case 'd': t *= 24; case 'h': t *= 60; case 'm': t *= 60; case 's': break
|
const videoId = url.searchParams.get('v')
|
||||||
default: return '0'
|
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)
|
||||||
}
|
}
|
||||||
total += t
|
|
||||||
}
|
}
|
||||||
return total.toString()
|
|
||||||
|
await updater()
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
await onModeChange()
|
||||||
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))
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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));
|
|
||||||
|
|
||||||
/** On settings change */
|
|
||||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
|
||||||
if (areaName !== 'local') return;
|
|
||||||
if (changes.targetPlatform) handleURLChange(await requestCtxFromUrl(location.href))
|
|
||||||
});
|
|
|
@ -1,13 +1,15 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" dir="ltr">
|
<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>
|
<head>
|
||||||
<div id="root" />
|
<meta charset="utf-8">
|
||||||
</body>
|
<title>Subscription Converter</title>
|
||||||
</html>
|
<link rel="stylesheet" href="YTtoLBRY.css" />
|
||||||
|
<script src="YTtoLBRY.tsx" charset="utf-8" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root" />
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -1,11 +1,10 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings'
|
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'
|
import readme from './README.md'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the subscription file and queries the API for lbry channels
|
* 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
|
* @returns a promise with the list of channels that were found on lbry
|
||||||
*/
|
*/
|
||||||
async function lbryChannelsFromFile(file: File) {
|
async function lbryChannelsFromFile(file: File) {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
const ids = new Set((
|
const ids = new Set((
|
||||||
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
|
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
|
||||||
ext === 'csv' ? ytService.readCsv :
|
ext === 'csv' ? getSubsFromCsv :
|
||||||
ytService.readJson)(await getFileContent(file)))
|
getSubsFromJson)(await getFileContent(file)))
|
||||||
const lbryUrls = await ytService.resolveById(
|
const lbryPathnames = await resolveById(
|
||||||
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
|
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
|
||||||
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!));
|
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!))
|
||||||
const { targetPlatform: platform } = await getExtensionSettingsAsync('targetPlatform');
|
const { targetPlatform: platform } = await getExtensionSettingsAsync()
|
||||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix;
|
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
||||||
return lbryUrls.map(channel => urlPrefix + channel);
|
return lbryPathnames.map(channel => urlPrefix + channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
|
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
|
||||||
const [file, setFile] = useState(null as File | null);
|
const [file, setFile] = useState(null as File | null)
|
||||||
const [isLoading, setLoading] = useState(false);
|
const [isLoading, setLoading] = useState(false)
|
||||||
|
|
||||||
return <div className='ConversionCard'>
|
return <div className='ConversionCard'>
|
||||||
<h2>Select YouTube Subscriptions</h2>
|
<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)} />
|
<input type='file' onChange={e => setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
|
||||||
</div>
|
</div>
|
||||||
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
|
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
|
||||||
if (!file) return;
|
if (!file) return
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
await onSelect(file);
|
await onSelect(file)
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}} />
|
}} />
|
||||||
<div class="progress-text">
|
<div class="progress-text">
|
||||||
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
|
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
|
||||||
|
@ -49,7 +48,7 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<
|
||||||
}
|
}
|
||||||
|
|
||||||
function YTtoLBRY({ progress }: { progress: number }) {
|
function YTtoLBRY({ progress }: { progress: number }) {
|
||||||
const [lbryChannels, setLbryChannels] = useState([] as string[]);
|
const [lbryChannels, setLbryChannels] = useState([] as string[])
|
||||||
|
|
||||||
return <div className='YTtoLBRY'>
|
return <div className='YTtoLBRY'>
|
||||||
<div className='Conversion'>
|
<div className='Conversion'>
|
||||||
|
@ -66,4 +65,4 @@ function YTtoLBRY({ progress }: { progress: number }) {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<YTtoLBRY progress={0} />, document.getElementById('root')!);
|
render(<YTtoLBRY progress={0} />, document.getElementById('root')!)
|
||||||
|
|
Loading…
Add table
Reference in a new issue