mirror of
https://github.com/LBRYFoundation/Watch-on-LBRY.git
synced 2025-08-23 17:47:26 +00:00
commit
77d95ab61c
13 changed files with 17865 additions and 154 deletions
17498
package-lock.json
generated
Normal file
17498
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -3,10 +3,11 @@
|
||||||
.ButtonRadio
|
.ButtonRadio
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: center
|
justify-content: center
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: .25em
|
||||||
|
|
||||||
.radio-button
|
.radio-button
|
||||||
@extend .button
|
@extend .button
|
||||||
margin: 6px
|
|
||||||
|
|
||||||
.radio-button.checked
|
.radio-button.checked
|
||||||
@extend .button.active
|
@extend .button.active
|
||||||
|
|
|
@ -1,29 +1,144 @@
|
||||||
export type PlatformName = 'madiator.com' | 'odysee' | 'app'
|
export interface ExtensionSettings {
|
||||||
|
redirect: boolean
|
||||||
|
targetPlatform: TargetPlatformName
|
||||||
|
urlResolver: YTUrlResolverName
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformSettings
|
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 type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
||||||
|
export interface TargetPlatformSettings {
|
||||||
domainPrefix: string
|
domainPrefix: string
|
||||||
display: string
|
displayName: string
|
||||||
theme: string
|
theme: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const platformSettings: Record<PlatformName, PlatformSettings> = {
|
export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatformSettings> = {
|
||||||
'madiator.com': { domainPrefix: 'https://madiator.com/', display: 'Madiator.com', theme: '#075656' },
|
'madiator.com': {
|
||||||
odysee: { domainPrefix: 'https://odysee.com/', display: 'Odysee', theme: '#1e013b' },
|
domainPrefix: 'https://madiator.com/',
|
||||||
app: { domainPrefix: 'lbry://', display: 'App', theme: '#075656' },
|
displayName: 'Madiator.com',
|
||||||
|
theme: '#075656'
|
||||||
|
},
|
||||||
|
odysee: {
|
||||||
|
domainPrefix: 'https://odysee.com/',
|
||||||
|
displayName: 'Odysee',
|
||||||
|
theme: '#1e013b'
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
domainPrefix: 'lbry://',
|
||||||
|
displayName: 'LBRY App',
|
||||||
|
theme: '#075656'
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPlatfromSettingsEntiries = () => {
|
export const getTargetPlatfromSettingsEntiries = () => {
|
||||||
return Object.entries(platformSettings) as any as [Extract<keyof typeof platformSettings, string>, PlatformSettings][]
|
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatformSettings][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LbrySettings {
|
|
||||||
enabled: boolean
|
|
||||||
platform: PlatformName
|
export type SourcePlatfromName = 'youtube.com' | 'yewtu.be'
|
||||||
|
export interface SourcePlatfromSettings {
|
||||||
|
hostnames: string[]
|
||||||
|
htmlQueries: {
|
||||||
|
mountButtonBefore: string,
|
||||||
|
videoPlayer: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, platform: 'odysee' };
|
export const sourcePlatfromSettings: Record<SourcePlatfromName, SourcePlatfromSettings> = {
|
||||||
|
"yewtu.be": {
|
||||||
export function getSettingsAsync<K extends Array<keyof LbrySettings>>(...keys: K): Promise<Pick<LbrySettings, K[number]>> {
|
hostnames: ['yewtu.be'],
|
||||||
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
|
htmlQueries: {
|
||||||
|
mountButtonBefore: '#watch-on-youtube',
|
||||||
|
videoPlayer: '#player-container video'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"youtube.com": {
|
||||||
|
hostnames: ['www.youtube.com'],
|
||||||
|
htmlQueries: {
|
||||||
|
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
|
||||||
|
videoPlayer: '#ytd-player video'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSourcePlatfromSettingsFromHostname(hostname: string) {
|
||||||
|
const values = Object.values(sourcePlatfromSettings)
|
||||||
|
for (const settings of values)
|
||||||
|
if (settings.hostnames.includes(hostname)) return settings
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type YTUrlResolverName = 'lbryInc' | 'madiatorScrap'
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
pathname: string
|
||||||
|
paramName: string
|
||||||
|
paramArraySeperator: string | typeof SingleValueAtATime
|
||||||
|
responsePath: YtUrlResolveResponsePath
|
||||||
|
}
|
||||||
|
export interface YTUrlResolver
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
hostname: string
|
||||||
|
functions: {
|
||||||
|
getChannelId: YtUrlResolveFunction
|
||||||
|
getVideoId: YtUrlResolveFunction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
||||||
|
lbryInc: {
|
||||||
|
name: "LBRY Inc.",
|
||||||
|
hostname: "api.odysee.com",
|
||||||
|
functions: {
|
||||||
|
getChannelId : {
|
||||||
|
pathname: "/yt/resolve",
|
||||||
|
paramName: "channel_ids",
|
||||||
|
paramArraySeperator: ',',
|
||||||
|
responsePath: ["data", "channels", Values]
|
||||||
|
},
|
||||||
|
getVideoId: {
|
||||||
|
pathname: "/yt/resolve",
|
||||||
|
paramName: "video_ids",
|
||||||
|
paramArraySeperator: ",",
|
||||||
|
responsePath: ["data", "videos", Values]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
madiatorScrap: {
|
||||||
|
name: "Madiator.com",
|
||||||
|
hostname: "scrap.madiator.com",
|
||||||
|
functions: {
|
||||||
|
getChannelId: {
|
||||||
|
pathname: "/api/get-lbry-channel",
|
||||||
|
paramName: "url",
|
||||||
|
paramArraySeperator: SingleValueAtATime,
|
||||||
|
responsePath: ["lbrych"]
|
||||||
|
},
|
||||||
|
getVideoId: {
|
||||||
|
pathname: "/api/get-lbry-video",
|
||||||
|
paramName: "url",
|
||||||
|
paramArraySeperator: SingleValueAtATime,
|
||||||
|
responsePath: ["lbryurl"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getYtUrlResolversSettingsEntiries = () => {
|
||||||
|
return Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
|
||||||
|
}
|
|
@ -5,30 +5,29 @@ $btn-color: #075656 !default
|
||||||
$btn-select: teal !default
|
$btn-select: teal !default
|
||||||
|
|
||||||
body
|
body
|
||||||
width: 400px
|
width: 30em
|
||||||
text-align: center
|
text-align: center
|
||||||
background-color: $background-color
|
background-color: $background-color
|
||||||
color: $text-color
|
color: $text-color
|
||||||
font-family: sans-serif
|
font-family: sans-serif
|
||||||
|
padding: 1em
|
||||||
|
|
||||||
.container
|
.container
|
||||||
display: block
|
display: block
|
||||||
text-align: center
|
text-align: center
|
||||||
margin: 0 32px
|
|
||||||
margin-bottom: 15px
|
|
||||||
|
|
||||||
.button
|
.button
|
||||||
border-radius: 5px
|
border-radius: .5em
|
||||||
background-color: $btn-color
|
background-color: $btn-color
|
||||||
border: 4px solid $btn-color
|
border: .2em solid $btn-color
|
||||||
color: $text-color
|
color: $text-color
|
||||||
font-size: 0.8rem
|
font-size: 0.8rem
|
||||||
font-weight: 400
|
font-weight: 400
|
||||||
padding: 4px 15px
|
padding: .5em
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
&.active
|
&.active
|
||||||
border: 4px solid $btn-select
|
border-color: $btn-select
|
||||||
|
|
||||||
&:focus
|
&:focus
|
||||||
outline: none
|
outline: none
|
||||||
|
|
127
src/common/yt.ts
127
src/common/yt.ts
|
@ -1,20 +1,11 @@
|
||||||
import chunk from 'lodash/chunk';
|
import chunk from 'lodash/chunk';
|
||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
import pickBy from 'lodash/pickBy';
|
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YTUrlResolver, YtUrlResolveResponsePath, ytUrlResolversSettings } from './settings'
|
||||||
|
|
||||||
const LBRY_API_HOST = 'https://api.odysee.com';
|
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
|
||||||
const QUERY_CHUNK_SIZE = 300;
|
const QUERY_CHUNK_SIZE = 300;
|
||||||
|
|
||||||
interface YtResolverResponse {
|
interface YtExportedJsonSubscription {
|
||||||
success: boolean;
|
|
||||||
error: object | null;
|
|
||||||
data: {
|
|
||||||
videos?: Record<string, string>;
|
|
||||||
channels?: Record<string, string>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface YtSubscription {
|
|
||||||
id: string;
|
id: string;
|
||||||
etag: string;
|
etag: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -42,7 +33,7 @@ export function getFileContent(file: File): Promise<string> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YTDescriptor {
|
export interface YtIdResolverDescriptor {
|
||||||
id: string
|
id: string
|
||||||
type: 'channel' | 'video'
|
type: 'channel' | 'video'
|
||||||
}
|
}
|
||||||
|
@ -72,7 +63,7 @@ export const ytService = {
|
||||||
* @returns the channel IDs
|
* @returns the channel IDs
|
||||||
*/
|
*/
|
||||||
readJson(jsonContents: string): string[] {
|
readJson(jsonContents: string): string[] {
|
||||||
const subscriptions: YtSubscription[] = JSON.parse(jsonContents);
|
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents);
|
||||||
jsonContents = ''
|
jsonContents = ''
|
||||||
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
|
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
|
||||||
},
|
},
|
||||||
|
@ -86,7 +77,7 @@ export const ytService = {
|
||||||
readCsv(csvContent: string): string[] {
|
readCsv(csvContent: string): string[] {
|
||||||
const rows = csvContent.split('\n')
|
const rows = csvContent.split('\n')
|
||||||
csvContent = ''
|
csvContent = ''
|
||||||
return rows.map((row) => row.substr(0, row.indexOf(',')))
|
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -108,7 +99,7 @@ export const ytService = {
|
||||||
return match ? match[1] : null; // match[1] is the videoId
|
return match ? match[1] : null; // match[1] is the videoId
|
||||||
},
|
},
|
||||||
|
|
||||||
getId(url: string): YTDescriptor | null {
|
getId(url: string): YtIdResolverDescriptor | null {
|
||||||
const videoId = ytService.getVideoId(url);
|
const videoId = ytService.getVideoId(url);
|
||||||
if (videoId) return { id: videoId, type: 'video' };
|
if (videoId) return { id: videoId, type: 'video' };
|
||||||
const channelId = ytService.getChannelId(url);
|
const channelId = ytService.getChannelId(url);
|
||||||
|
@ -117,23 +108,93 @@ export const ytService = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param descriptors YT resource IDs to check
|
* @param descriptorsWithIndex YT resource IDs to check
|
||||||
* @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 resolveById(...descriptors: YTDescriptor[]): Promise<string[]> {
|
async resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<string[]> {
|
||||||
const descChunks = chunk(descriptors, QUERY_CHUNK_SIZE);
|
const descriptorsWithIndex: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({...descriptor, index}))
|
||||||
const responses: (YtResolverResponse | null)[] = await Promise.all(descChunks.map(descChunk => {
|
|
||||||
const groups = groupBy(descChunk, d => d.type);
|
const descriptorsChunks = chunk(descriptorsWithIndex, QUERY_CHUNK_SIZE);
|
||||||
const params = new URLSearchParams(pickBy({
|
const results: string[] = []
|
||||||
video_ids: groups['video']?.map(s => s.id).join(','),
|
let progressCount = 0;
|
||||||
channel_ids: groups['channel']?.map(s => s.id).join(','),
|
await Promise.all(descriptorsChunks.map(async (descriptorChunk) =>
|
||||||
}));
|
{
|
||||||
return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`, { cache: 'force-cache' })
|
const descriptorsGroupedByType: Record<YtIdResolverDescriptor['type'], typeof descriptorsWithIndex | null> = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any;
|
||||||
.then(rsp => rsp.ok ? rsp.json() : null);
|
|
||||||
}));
|
|
||||||
|
|
||||||
return responses.filter((rsp): rsp is YtResolverResponse => !!rsp)
|
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync('urlResolver')
|
||||||
.flatMap(rsp => [...Object.values(rsp.data.videos || {}), ...Object.values(rsp.data.channels || {})]) // flatten the results into a 1D array
|
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||||
.filter(s => s);
|
|
||||||
},
|
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)
|
||||||
|
{
|
||||||
|
for (const descriptor of descriptorsGroup)
|
||||||
|
{
|
||||||
|
progressCount++
|
||||||
|
if (!descriptor.id) continue
|
||||||
|
url.searchParams.set(urlResolverFunction.paramName, descriptor.id)
|
||||||
|
|
||||||
|
const apiResponse = await fetch(url.toString(), { cache: 'force-cache' });
|
||||||
|
if (!apiResponse.ok) continue
|
||||||
|
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||||
|
if (value) results[descriptor.index] = value
|
||||||
|
if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
progressCount += descriptorsGroup.length
|
||||||
|
url.searchParams
|
||||||
|
.set(urlResolverFunction.paramName, descriptorsGroup
|
||||||
|
.map((descriptor) => descriptor.id)
|
||||||
|
.filter((descriptorId) => descriptorId)
|
||||||
|
.join(urlResolverFunction.paramArraySeperator)
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiResponse = await fetch(url.toString(), { cache: 'force-cache' });
|
||||||
|
if (!apiResponse.ok) return
|
||||||
|
const values = followResponsePath<string[]>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const descriptorIndex = descriptorsGroup[index].index
|
||||||
|
if (value) (results[descriptorIndex] = value)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,8 +3,7 @@
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"https://www.youtube.com/",
|
"https://www.youtube.com/",
|
||||||
"https://invidio.us/channel/*",
|
"https://yewtu.be/",
|
||||||
"https://invidio.us/watch?v=*",
|
|
||||||
"https://api.odysee.com/*",
|
"https://api.odysee.com/*",
|
||||||
"https://lbry.tv/*",
|
"https://lbry.tv/*",
|
||||||
"https://odysee.com/*",
|
"https://odysee.com/*",
|
||||||
|
@ -15,7 +14,8 @@
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://www.youtube.com/*"
|
"https://www.youtube.com/*",
|
||||||
|
"https://yewtu.be/*"
|
||||||
],
|
],
|
||||||
"js": [
|
"js": [
|
||||||
"scripts/ytContent.js"
|
"scripts/ytContent.js"
|
||||||
|
|
|
@ -1,4 +1,13 @@
|
||||||
.radio-label
|
.radio-label
|
||||||
font-size: 1.1rem
|
font-size: 1.1rem
|
||||||
margin: 15px auto
|
|
||||||
display: block
|
display: block
|
||||||
|
|
||||||
|
.container
|
||||||
|
display: grid
|
||||||
|
grid-auto-flow: row
|
||||||
|
gap: 1.5em
|
||||||
|
|
||||||
|
.container > section
|
||||||
|
display: grid
|
||||||
|
grid-auto-flow: row
|
||||||
|
gap: 1em
|
|
@ -1,32 +1,44 @@
|
||||||
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 { getPlatfromSettingsEntiries, LbrySettings, PlatformName } from '../common/settings'
|
import { getTargetPlatfromSettingsEntiries, ExtensionSettings, TargetPlatformName, getYtUrlResolversSettingsEntiries, 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 LbrySettings>(setting: K, value: LbrySettings[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[] = getPlatfromSettingsEntiries()
|
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
|
||||||
.map(([value, { display }]) => ({ value, display }));
|
.map(([value, { displayName: display }]) => ({ value, display }));
|
||||||
|
|
||||||
|
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
||||||
|
.map(([value, { name: display }]) => ({ value, display }));
|
||||||
|
|
||||||
function WatchOnLbryPopup() {
|
function WatchOnLbryPopup() {
|
||||||
const { enabled, platform } = useLbrySettings();
|
const { redirect, targetPlatform, urlResolver } = useLbrySettings();
|
||||||
|
|
||||||
return <div className='container'>
|
return <div className='container'>
|
||||||
<label className='radio-label'>Enable Redirection:</label>
|
<section>
|
||||||
<ButtonRadio value={enabled ? 'YES' : 'NO'} options={['YES', 'NO']}
|
<label className='radio-label'>Enable Redirection:</label>
|
||||||
onChange={enabled => setSetting('enabled', enabled.toLowerCase() === 'yes')} />
|
<ButtonRadio value={redirect ? 'YES' : 'NO'} options={['YES', 'NO']}
|
||||||
<label className='radio-label'>Where would you like to redirect?</label>
|
onChange={redirect => setSetting('redirect', redirect.toLowerCase() === 'yes')} />
|
||||||
<ButtonRadio value={platform} options={platformOptions}
|
</section>
|
||||||
onChange={(platform: PlatformName) => setSetting('platform', platform)} />
|
<section>
|
||||||
<label className='radio-label'>Other useful tools:</label>
|
<label className='radio-label'>Where would you like to redirect?</label>
|
||||||
<a href='/tools/YTtoLBRY.html' target='_blank'>
|
<ButtonRadio value={targetPlatform} options={platformOptions}
|
||||||
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
onChange={(platform: TargetPlatformName) => setSetting('targetPlatform', platform)} />
|
||||||
</a>
|
</section>
|
||||||
|
<section>
|
||||||
|
<label className='radio-label'>Resolve URL with:</label>
|
||||||
|
<ButtonRadio value={urlResolver} options={ytUrlResolverOptions}
|
||||||
|
onChange={(urlResolver: YTUrlResolverName) => setSetting('urlResolver', urlResolver)} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label className='radio-label'>Other useful tools:</label>
|
||||||
|
<a href='/tools/YTtoLBRY.html' target='_blank'>
|
||||||
|
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { DEFAULT_SETTINGS, LbrySettings, getSettingsAsync } 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 getSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof LbrySettings>);
|
const settings = await getExtensionSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof ExtensionSettings>);
|
||||||
|
|
||||||
// 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 LbrySettings, LbrySettings[keyof LbrySettings]]>)
|
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
|
||||||
|
@ -15,12 +15,12 @@ async function initSettings() {
|
||||||
chrome.storage.local.set(changeSet);
|
chrome.storage.local.set(changeSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.browserAction.setBadgeText({ text: settings.enabled ? '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.enabled) return;
|
if (areaName !== 'local' || !changes.redirect) return;
|
||||||
chrome.browserAction.setBadgeText({ text: changes.enabled.newValue ? 'ON' : 'OFF' });
|
chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,41 +1,46 @@
|
||||||
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'
|
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'
|
||||||
import { getSettingsAsync, PlatformName } from '../common/settings'
|
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatformName } from '../common/settings'
|
||||||
import { YTDescriptor, ytService } from '../common/yt'
|
import { YtIdResolverDescriptor, ytService } from '../common/yt'
|
||||||
export interface UpdateContext {
|
export interface UpdateContext {
|
||||||
descriptor: YTDescriptor
|
descriptor: YtIdResolverDescriptor
|
||||||
/** LBRY URL fragment */
|
/** LBRY URL fragment */
|
||||||
pathname: string
|
lbryPathname: string
|
||||||
enabled: boolean
|
redirect: boolean
|
||||||
platform: PlatformName
|
targetPlatform: TargetPlatformName
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveYT(descriptor: YTDescriptor) {
|
async function resolveYT(descriptor: YtIdResolverDescriptor) {
|
||||||
const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]);
|
const lbryProtocolUrl: string | null = await ytService.resolveById([descriptor]).then(a => a[0]);
|
||||||
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
|
||||||
if (segments.length === 0) return;
|
if (segments.length === 0) return;
|
||||||
return segments.join('/');
|
return segments.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathnameCache: Record<string, string | undefined> = {};
|
const lbryPathnameCache: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
async function ctxFromURL(url: string): Promise<UpdateContext | void> {
|
async function ctxFromURL(href: string): Promise<UpdateContext | void> {
|
||||||
if (!url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
if (!href) return;
|
||||||
url = new URL(url).href;
|
|
||||||
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
|
const url = new URL(href);
|
||||||
const descriptor = ytService.getId(url);
|
if (!getSourcePlatfromSettingsFromHostname(url.hostname)) return
|
||||||
|
if (url.pathname.startsWith('/watch?')) return
|
||||||
|
if (url.pathname.startsWith('/channel?')) return
|
||||||
|
|
||||||
|
const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform');
|
||||||
|
const descriptor = ytService.getId(href);
|
||||||
if (!descriptor) return; // couldn't get the ID, so we're done
|
if (!descriptor) return; // couldn't get the ID, so we're done
|
||||||
|
|
||||||
const res = url in pathnameCache ? pathnameCache[url] : await resolveYT(descriptor);
|
const res = href in lbryPathnameCache ? lbryPathnameCache[href] : await resolveYT(descriptor);
|
||||||
pathnameCache[url] = res;
|
lbryPathnameCache[href] = res;
|
||||||
if (!res) return; // couldn't find it on lbry, so we're done
|
if (!res) return; // couldn't find it on lbry, so we're done
|
||||||
|
|
||||||
return { descriptor, pathname: res, enabled, platform };
|
return { descriptor, lbryPathname: res, redirect, targetPlatform };
|
||||||
}
|
}
|
||||||
|
|
||||||
// handles lbry.tv -> lbry app redirect
|
// handles lbry.tv -> lbry app redirect
|
||||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
|
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
|
||||||
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
|
const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform');
|
||||||
if (!enabled || platform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return;
|
if (!redirect || targetPlatform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return;
|
||||||
|
|
||||||
const url = appRedirectUrl(tabUrl, { encode: true });
|
const url = appRedirectUrl(tabUrl, { encode: true });
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
@ -61,7 +66,5 @@ chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResp
|
||||||
|
|
||||||
// relay youtube link changes to the content script
|
// relay youtube link changes to the content script
|
||||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
|
||||||
if (!changeInfo.url || !url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
|
if (url) ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
|
||||||
|
|
||||||
ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { PlatformName, platformSettings } from '../common/settings'
|
import { getSourcePlatfromSettingsFromHostname, TargetPlatformName, targetPlatformSettings } from '../common/settings'
|
||||||
import type { UpdateContext } from '../scripts/tabOnUpdated'
|
import type { UpdateContext } from '../scripts/tabOnUpdated'
|
||||||
import { h, JSX, render } from 'preact'
|
import { h, JSX, render } from 'preact'
|
||||||
|
|
||||||
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 {
|
interface ButtonSettings {
|
||||||
text: string
|
text: string
|
||||||
icon: string
|
icon: string
|
||||||
|
@ -14,7 +16,7 @@ interface ButtonSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonSettings: Record<PlatformName, ButtonSettings> = {
|
const buttonSettings: Record<TargetPlatformName, ButtonSettings> = {
|
||||||
app: {
|
app: {
|
||||||
text: 'Watch on LBRY',
|
text: 'Watch on LBRY',
|
||||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||||
|
@ -35,32 +37,32 @@ const buttonSettings: Record<PlatformName, ButtonSettings> = {
|
||||||
|
|
||||||
interface ButtonParameters
|
interface ButtonParameters
|
||||||
{
|
{
|
||||||
platform?: PlatformName
|
targetPlatform?: TargetPlatformName
|
||||||
pathname?: string
|
lbryPathname?: string
|
||||||
time?: number
|
time?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatchOnLbryButton({ platform = 'app', pathname, time }: ButtonParameters) {
|
export function WatchOnLbryButton({ targetPlatform = 'app', lbryPathname, time }: ButtonParameters = {}) {
|
||||||
if (!pathname || !platform) return null;
|
if (!lbryPathname || !targetPlatform) return null;
|
||||||
const platformSetting = platformSettings[platform];
|
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
||||||
const buttonSetting = buttonSettings[platform];
|
const buttonSetting = buttonSettings[targetPlatform];
|
||||||
|
|
||||||
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
|
const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`)
|
||||||
if (time) url.searchParams.append('t', time.toFixed(0))
|
if (time) url.searchParams.append('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={pauseVideo} role='button'
|
<a href={`${url.toString()}`} onClick={pauseAllVideos} 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: platformSetting.theme,
|
backgroundColor: targetPlatformSetting.theme,
|
||||||
border: '0',
|
border: '0',
|
||||||
color: 'whitesmoke',
|
color: 'whitesmoke',
|
||||||
padding: '10px 16px',
|
padding: '10px 16px',
|
||||||
marginRight: '5px',
|
marginRight: '4px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
...buttonSetting.style?.button,
|
...buttonSetting.style?.button,
|
||||||
|
@ -75,53 +77,49 @@ export function WatchOnLbryButton({ platform = 'app', pathname, time }: ButtonPa
|
||||||
let mountPoint: HTMLDivElement | null = null
|
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 | void> {
|
||||||
let ownerBar = document.querySelector('ytd-video-owner-renderer');
|
let mountBefore: HTMLDivElement | null = null
|
||||||
for (let i = 0; !ownerBar && i < 50; i++) {
|
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||||
await sleep(200);
|
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||||
ownerBar = document.querySelector('ytd-video-owner-renderer');
|
|
||||||
}
|
while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200);
|
||||||
|
|
||||||
if (!ownerBar) return;
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.style.display = 'flex';
|
div.style.display = 'flex';
|
||||||
ownerBar.insertAdjacentElement('afterend', div);
|
div.style.alignItems = 'center'
|
||||||
|
mountBefore.parentElement?.insertBefore(div, mountBefore)
|
||||||
mountPoint = div
|
mountPoint = div
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoElement: HTMLVideoElement | null = null;
|
let videoElement: HTMLVideoElement | null = null;
|
||||||
async function findVideoElement() {
|
async function findVideoElement() {
|
||||||
while(!(videoElement = document.querySelector('#ytd-player video'))) await sleep(200)
|
const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
||||||
|
if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`)
|
||||||
|
|
||||||
|
while(!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200)
|
||||||
|
|
||||||
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
|
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseVideo() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
|
|
||||||
|
|
||||||
function openApp(url: string) {
|
|
||||||
pauseVideo();
|
|
||||||
location.assign(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
|
||||||
let ctxCache: UpdateContext | null = null
|
let ctxCache: UpdateContext | null = null
|
||||||
function handleURLChange (ctx: UpdateContext | null) {
|
function handleURLChange (ctx: UpdateContext | null): void {
|
||||||
ctxCache = ctx
|
ctxCache = ctx
|
||||||
updateButton(ctx)
|
updateButton(ctx)
|
||||||
if (ctx?.enabled) redirectTo(ctx)
|
if (ctx?.redirect) redirectTo(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButton(ctx: UpdateContext | null) {
|
function updateButton(ctx: UpdateContext | null): void {
|
||||||
if (!mountPoint) return
|
if (!mountPoint) return
|
||||||
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
|
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
|
||||||
if (ctx.descriptor.type !== 'video') return;
|
if (ctx.descriptor.type !== 'video') return;
|
||||||
const time = videoElement?.currentTime ?? 0
|
const time = videoElement?.currentTime ?? 0
|
||||||
const pathname = ctx.pathname
|
const lbryPathname = ctx.lbryPathname
|
||||||
const platform = ctx.platform
|
const targetPlatform = ctx.targetPlatform
|
||||||
|
|
||||||
render(<WatchOnLbryButton platform={platform} pathname={pathname} time={time} />, mountPoint)
|
render(<WatchOnLbryButton targetPlatform={targetPlatform} lbryPathname={lbryPathname} time={time} />, mountPoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectTo({ platform, pathname }: UpdateContext) {
|
function redirectTo({ targetPlatform, lbryPathname }: UpdateContext): void {
|
||||||
|
|
||||||
const parseYouTubeTime = (timeString: string) => {
|
const parseYouTubeTime = (timeString: string) => {
|
||||||
const signs = timeString.replace(/[0-9]/g, '')
|
const signs = timeString.replace(/[0-9]/g, '')
|
||||||
|
@ -139,13 +137,18 @@ function redirectTo({ platform, pathname }: UpdateContext) {
|
||||||
return total.toString()
|
return total.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformSetting = platformSettings[platform];
|
const targetPlatformSetting = targetPlatformSettings[targetPlatform];
|
||||||
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
|
const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`)
|
||||||
const time = new URL(location.href).searchParams.get('t')
|
const time = new URL(location.href).searchParams.get('t')
|
||||||
|
|
||||||
if (time) url.searchParams.append('t', parseYouTubeTime(time))
|
if (time) url.searchParams.append('t', parseYouTubeTime(time))
|
||||||
|
|
||||||
if (platform === 'app') return openApp(url.toString());
|
if (targetPlatform === 'app')
|
||||||
|
{
|
||||||
|
pauseAllVideos();
|
||||||
|
location.assign(url);
|
||||||
|
return
|
||||||
|
}
|
||||||
location.replace(url.toString());
|
location.replace(url.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,5 +173,5 @@ chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChan
|
||||||
/** On settings change */
|
/** On settings change */
|
||||||
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
||||||
if (areaName !== 'local') return;
|
if (areaName !== 'local') return;
|
||||||
if (changes.platform) handleURLChange(await requestCtxFromUrl(location.href))
|
if (changes.targetPlatform) handleURLChange(await requestCtxFromUrl(location.href))
|
||||||
});
|
});
|
|
@ -68,6 +68,11 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: .25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.btn-primary {
|
.btn.btn-primary {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import { getSettingsAsync, platformSettings } from '../common/settings'
|
import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings'
|
||||||
import { getFileContent, ytService } from '../common/yt'
|
import { getFileContent, ytService } from '../common/yt'
|
||||||
import readme from './README.md'
|
import readme from './README.md'
|
||||||
|
|
||||||
|
@ -19,13 +19,15 @@ async function lbryChannelsFromFile(file: File) {
|
||||||
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
|
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
|
||||||
ext === 'csv' ? ytService.readCsv :
|
ext === 'csv' ? ytService.readCsv :
|
||||||
ytService.readJson)(await getFileContent(file)))
|
ytService.readJson)(await getFileContent(file)))
|
||||||
const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const)));
|
const lbryUrls = await ytService.resolveById(
|
||||||
const { platform } = await getSettingsAsync('platform');
|
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
|
||||||
const urlPrefix = platformSettings[platform].domainPrefix;
|
(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);
|
return lbryUrls.map(channel => urlPrefix + channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | void }) {
|
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);
|
||||||
|
|
||||||
|
@ -40,15 +42,18 @@ function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | vo
|
||||||
await onSelect(file);
|
await onSelect(file);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}} />
|
}} />
|
||||||
|
<div class="progress-text">
|
||||||
|
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function YTtoLBRY() {
|
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'>
|
||||||
<ConversionCard onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
|
<ConversionCard progress={progress} onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
|
||||||
<ul>
|
<ul>
|
||||||
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
|
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -61,4 +66,4 @@ function YTtoLBRY() {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<YTtoLBRY />, document.getElementById('root')!);
|
render(<YTtoLBRY progress={0} />, document.getElementById('root')!);
|
||||||
|
|
Loading…
Add table
Reference in a new issue