mirror of
https://github.com/LBRYFoundation/Watch-on-LBRY.git
synced 2025-08-23 17:47:26 +00:00
Merge pull request #113 from DeepDoge/master
New UI and Madiator Finder Features
This commit is contained in:
commit
53eb4a0c8e
16 changed files with 768 additions and 286 deletions
61
src/common/common.css
Normal file
61
src/common/common.css
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
:root {
|
||||||
|
--color-master: #499375;
|
||||||
|
--color-slave: #43889d;
|
||||||
|
--color-error: rgb(245, 81, 69);
|
||||||
|
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
|
||||||
|
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);
|
||||||
|
--color-dark: #0e1117;
|
||||||
|
--color-light: rgb(235, 237, 241);
|
||||||
|
--gradient-animation: gradient-animation 5s linear infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
letter-spacing: .2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--color-light);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5em 1em;
|
||||||
|
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: var(--color-light);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.active {
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
filter: blur(.5em);
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.disabled {
|
||||||
|
filter: saturate(0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
211
src/common/crypto.ts
Normal file
211
src/common/crypto.ts
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import { getExtensionSettingsAsync } from "./settings"
|
||||||
|
import { setSetting } from "./useSettings"
|
||||||
|
|
||||||
|
async function generateKeys() {
|
||||||
|
const keys = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "RSASSA-PKCS1-v1_5",
|
||||||
|
// Consider using a 4096-bit key for systems that require long-term security
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["sign", "verify"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: await exportPublicKey(keys.publicKey),
|
||||||
|
privateKey: await exportPrivateKey(keys.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPrivateKey(key: CryptoKey) {
|
||||||
|
const exported = await window.crypto.subtle.exportKey(
|
||||||
|
"pkcs8",
|
||||||
|
key
|
||||||
|
)
|
||||||
|
return Buffer.from(exported).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPublicKey(key: CryptoKey) {
|
||||||
|
const exported = await window.crypto.subtle.exportKey(
|
||||||
|
"spki",
|
||||||
|
key
|
||||||
|
)
|
||||||
|
return Buffer.from(exported).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
function importPrivateKey(base64: string) {
|
||||||
|
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
Buffer.from(base64, 'base64'),
|
||||||
|
{
|
||||||
|
name: "RSASSA-PKCS1-v1_5",
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["sign"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sign(data: string, privateKey: string) {
|
||||||
|
return Buffer.from(await window.crypto.subtle.sign(
|
||||||
|
{ name: "RSASSA-PKCS1-v1_5" },
|
||||||
|
await importPrivateKey(privateKey),
|
||||||
|
Buffer.from(data)
|
||||||
|
)).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetProfileSettings() {
|
||||||
|
setSetting('publicKey', null)
|
||||||
|
setSetting('privateKey', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateProfileAndSetNickname(overwrite = false) {
|
||||||
|
let { publicKey, privateKey } = await getExtensionSettingsAsync()
|
||||||
|
|
||||||
|
let nickname
|
||||||
|
while (true) {
|
||||||
|
nickname = prompt("Pick a nickname")
|
||||||
|
if (nickname) break
|
||||||
|
if (nickname === null) return
|
||||||
|
alert("Invalid nickname")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overwrite || !privateKey || !publicKey) {
|
||||||
|
resetProfileSettings()
|
||||||
|
await generateKeys().then((keys) => {
|
||||||
|
publicKey = keys.publicKey
|
||||||
|
privateKey = keys.privateKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('https://finder.madiator.com/api/v1/profile')
|
||||||
|
url.searchParams.set('data', JSON.stringify({ nickname }))
|
||||||
|
url.searchParams.set('keys', JSON.stringify({
|
||||||
|
signature: await sign(url.searchParams.toString(), privateKey!),
|
||||||
|
publicKey
|
||||||
|
}))
|
||||||
|
const respond = await fetch(url.href, { method: "POST" })
|
||||||
|
if (respond.ok) {
|
||||||
|
setSetting('publicKey', publicKey)
|
||||||
|
setSetting('privateKey', privateKey)
|
||||||
|
alert(`Your nickname has been set to ${nickname}`)
|
||||||
|
}
|
||||||
|
else alert((await respond.json()).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeProfile() {
|
||||||
|
try {
|
||||||
|
if (!confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return
|
||||||
|
const settings = await getExtensionSettingsAsync()
|
||||||
|
|
||||||
|
if (!settings.privateKey || !settings.publicKey)
|
||||||
|
throw new Error('There is no profile to be purged.')
|
||||||
|
|
||||||
|
const url = new URL('https://finder.madiator.com/api/v1/profile/purge')
|
||||||
|
url.searchParams.set('keys', JSON.stringify({
|
||||||
|
signature: await sign(url.searchParams.toString(), settings.privateKey!),
|
||||||
|
publicKey: settings.publicKey
|
||||||
|
}))
|
||||||
|
const respond = await fetch(url.href, { method: "POST" })
|
||||||
|
if (respond.ok) {
|
||||||
|
resetProfileSettings()
|
||||||
|
alert(`Your profile has been purged`)
|
||||||
|
}
|
||||||
|
else throw new Error((await respond.json()).message)
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile() {
|
||||||
|
try {
|
||||||
|
const settings = await getExtensionSettingsAsync()
|
||||||
|
|
||||||
|
if (!settings.privateKey || !settings.publicKey)
|
||||||
|
throw new Error('There is no profile.')
|
||||||
|
|
||||||
|
const url = new URL('https://finder.madiator.com/api/v1/profile')
|
||||||
|
url.searchParams.set('data', JSON.stringify({ publicKey: settings.publicKey }))
|
||||||
|
url.searchParams.set('keys', JSON.stringify({
|
||||||
|
signature: await sign(url.searchParams.toString(), settings.privateKey!),
|
||||||
|
publicKey: settings.publicKey
|
||||||
|
}))
|
||||||
|
const respond = await fetch(url.href, { method: "GET" })
|
||||||
|
if (respond.ok) {
|
||||||
|
const profile = await respond.json() as { nickname: string, score: number, publickKey: string }
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
else throw new Error((await respond.json()).message)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyPublicKey(publicKey: string | null) {
|
||||||
|
// This is copy paste of Madiator Finder's friendly public key
|
||||||
|
return publicKey?.substring(publicKey.length - 64, publicKey.length - 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(data: string, filename: string, type: string) {
|
||||||
|
const file = new Blob([data], { type: type })
|
||||||
|
const a = document.createElement("a")
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFile() {
|
||||||
|
return await new Promise<string | null>((resolve) => {
|
||||||
|
const input = document.createElement("input")
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.wol-keys.json'
|
||||||
|
|
||||||
|
input.click()
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
if (!input.files?.[0]) return
|
||||||
|
const myFile = input.files[0]
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.addEventListener('load', () => resolve(reader.result?.toString() ?? null))
|
||||||
|
reader.readAsBinaryString(myFile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportedProfileKeysFile {
|
||||||
|
publicKey: string
|
||||||
|
privateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportProfileKeysAsFile() {
|
||||||
|
const { publicKey, privateKey } = await getExtensionSettingsAsync()
|
||||||
|
|
||||||
|
const json = JSON.stringify({
|
||||||
|
publicKey,
|
||||||
|
privateKey
|
||||||
|
})
|
||||||
|
|
||||||
|
download(json, `watch-on-lbry-profile-export-${friendlyPublicKey(publicKey)}.wol-keys.json`, 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importProfileKeysFromFile() {
|
||||||
|
try {
|
||||||
|
const json = await readFile()
|
||||||
|
if (!json) throw new Error("Invalid")
|
||||||
|
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
|
||||||
|
setSetting('publicKey', publicKey)
|
||||||
|
setSetting('privateKey', privateKey)
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,17 +4,23 @@ export interface ExtensionSettings {
|
||||||
redirect: boolean
|
redirect: boolean
|
||||||
targetPlatform: TargetPlatformName
|
targetPlatform: TargetPlatformName
|
||||||
urlResolver: YTUrlResolverName
|
urlResolver: YTUrlResolverName
|
||||||
|
publicKey: string | null,
|
||||||
|
privateKey: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' }
|
export const DEFAULT_SETTINGS: ExtensionSettings = {
|
||||||
|
redirect: true,
|
||||||
|
targetPlatform: 'odysee',
|
||||||
|
urlResolver: 'odyseeApi',
|
||||||
|
privateKey: null,
|
||||||
|
publicKey: null
|
||||||
|
}
|
||||||
|
|
||||||
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
|
||||||
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetPlatform = (o: {
|
||||||
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
|
||||||
export interface TargetPlatform {
|
|
||||||
domainPrefix: string
|
domainPrefix: string
|
||||||
displayName: string
|
displayName: string
|
||||||
theme: string
|
theme: string
|
||||||
|
@ -27,10 +33,14 @@ export interface TargetPlatform {
|
||||||
button?: JSX.CSSProperties
|
button?: JSX.CSSProperties
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}) => o
|
||||||
|
export type TargetPlatform = ReturnType<typeof targetPlatform>
|
||||||
|
export type TargetPlatformName = Extract<keyof typeof targetPlatformSettings, string>
|
||||||
|
export const getTargetPlatfromSettingsEntiries = () => {
|
||||||
|
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
|
||||||
}
|
}
|
||||||
|
export const targetPlatformSettings = {
|
||||||
export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform> = {
|
'madiator.com': targetPlatform({
|
||||||
'madiator.com': {
|
|
||||||
domainPrefix: 'https://madiator.com/',
|
domainPrefix: 'https://madiator.com/',
|
||||||
displayName: 'Madiator.com',
|
displayName: 'Madiator.com',
|
||||||
theme: '#075656',
|
theme: '#075656',
|
||||||
|
@ -42,8 +52,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
||||||
icon: { transform: 'scale(1.2)' }
|
icon: { transform: 'scale(1.2)' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
odysee: {
|
odysee: targetPlatform({
|
||||||
domainPrefix: 'https://odysee.com/',
|
domainPrefix: 'https://odysee.com/',
|
||||||
displayName: 'Odysee',
|
displayName: 'Odysee',
|
||||||
theme: '#1e013b',
|
theme: '#1e013b',
|
||||||
|
@ -51,8 +61,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
||||||
text: 'Watch on Odysee',
|
text: 'Watch on Odysee',
|
||||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
app: {
|
app: targetPlatform({
|
||||||
domainPrefix: 'lbry://',
|
domainPrefix: 'lbry://',
|
||||||
displayName: 'LBRY App',
|
displayName: 'LBRY App',
|
||||||
theme: '#075656',
|
theme: '#075656',
|
||||||
|
@ -60,118 +70,60 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
||||||
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')
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
}
|
|
||||||
|
|
||||||
export const getTargetPlatfromSettingsEntiries = () => {
|
|
||||||
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type SourcePlatformName = 'youtube.com' | 'yewtu.be'
|
const sourcePlatform = (o: {
|
||||||
export interface SourcePlatform {
|
|
||||||
hostnames: string[]
|
hostnames: string[]
|
||||||
htmlQueries: {
|
htmlQueries: {
|
||||||
mountButtonBefore: string,
|
mountButtonBefore: string,
|
||||||
videoPlayer: string
|
videoPlayer: string
|
||||||
}
|
}
|
||||||
}
|
}) => o
|
||||||
|
export type SourcePlatform = ReturnType<typeof sourcePlatform>
|
||||||
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
|
export type SourcePlatformName = Extract<keyof typeof sourcePlatfromSettings, string>
|
||||||
"yewtu.be": {
|
|
||||||
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
|
|
||||||
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) {
|
export function getSourcePlatfromSettingsFromHostname(hostname: string) {
|
||||||
const values = Object.values(sourcePlatfromSettings)
|
const values = Object.values(sourcePlatfromSettings)
|
||||||
for (const settings of values)
|
for (const settings of values)
|
||||||
if (settings.hostnames.includes(hostname)) return settings
|
if (settings.hostnames.includes(hostname)) return settings
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
export const sourcePlatfromSettings = {
|
||||||
|
"yewtu.be": sourcePlatform({
|
||||||
export type YTUrlResolverName = 'lbryInc' | 'madiatorScrap'
|
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
|
||||||
|
htmlQueries: {
|
||||||
export const Keys = Symbol('keys')
|
mountButtonBefore: '#watch-on-youtube',
|
||||||
export const Values = Symbol('values')
|
videoPlayer: '#player-container video'
|
||||||
export const SingleValueAtATime = Symbol()
|
|
||||||
export type YtUrlResolveResponsePath = (string | number | typeof Keys | typeof Values)[]
|
|
||||||
export interface YtUrlResolveFunction {
|
|
||||||
pathname: string
|
|
||||||
defaultParams: Record<string, string | number>
|
|
||||||
valueParamName: string
|
|
||||||
paramArraySeperator: string | typeof SingleValueAtATime
|
|
||||||
responsePath: YtUrlResolveResponsePath
|
|
||||||
}
|
}
|
||||||
export interface YTUrlResolver {
|
}),
|
||||||
|
"youtube.com": sourcePlatform({
|
||||||
|
hostnames: ['www.youtube.com'],
|
||||||
|
htmlQueries: {
|
||||||
|
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
|
||||||
|
videoPlayer: '#ytd-player video'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ytUrlResolver = (o: {
|
||||||
name: string
|
name: string
|
||||||
hostname: string
|
href: string
|
||||||
functions: {
|
signRequest: boolean
|
||||||
getChannelId: YtUrlResolveFunction
|
}) => o
|
||||||
getVideoId: YtUrlResolveFunction
|
export type YTUrlResolver = ReturnType<typeof ytUrlResolver>
|
||||||
}
|
export type YTUrlResolverName = Extract<keyof typeof ytUrlResolversSettings, string>
|
||||||
}
|
export const getYtUrlResolversSettingsEntiries = () => Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
|
||||||
|
export const ytUrlResolversSettings = {
|
||||||
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
odyseeApi: ytUrlResolver({
|
||||||
lbryInc: {
|
|
||||||
name: "Odysee",
|
name: "Odysee",
|
||||||
hostname: "api.odysee.com",
|
href: "https://api.odysee.com/yt/resolve",
|
||||||
functions: {
|
signRequest: false
|
||||||
getChannelId: {
|
}),
|
||||||
pathname: "/yt/resolve",
|
madiatorFinder: ytUrlResolver({
|
||||||
defaultParams: {},
|
name: "Madiator Finder",
|
||||||
valueParamName: "channel_ids",
|
href: "https://finder.madiator.com/api/v1/resolve",
|
||||||
paramArraySeperator: ',',
|
signRequest: true
|
||||||
responsePath: ["data", "channels", Values]
|
})
|
||||||
},
|
|
||||||
getVideoId: {
|
|
||||||
pathname: "/yt/resolve",
|
|
||||||
defaultParams: {},
|
|
||||||
valueParamName: "video_ids",
|
|
||||||
paramArraySeperator: ",",
|
|
||||||
responsePath: ["data", "videos", Values]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
madiatorScrap: {
|
|
||||||
name: "Madiator.com",
|
|
||||||
hostname: "scrap.madiator.com",
|
|
||||||
functions: {
|
|
||||||
getChannelId: {
|
|
||||||
pathname: "/api/get-lbry-channel",
|
|
||||||
defaultParams: {
|
|
||||||
v: 2
|
|
||||||
},
|
|
||||||
valueParamName: "url",
|
|
||||||
paramArraySeperator: SingleValueAtATime,
|
|
||||||
responsePath: ["lbrych"]
|
|
||||||
},
|
|
||||||
getVideoId: {
|
|
||||||
pathname: "/api/get-lbry-video",
|
|
||||||
defaultParams: {
|
|
||||||
v: 2
|
|
||||||
},
|
|
||||||
valueParamName: "url",
|
|
||||||
paramArraySeperator: SingleValueAtATime,
|
|
||||||
responsePath: ["lbryurl"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getYtUrlResolversSettingsEntiries = () => {
|
|
||||||
return Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
|
|
||||||
}
|
}
|
|
@ -1,30 +1,35 @@
|
||||||
import { useEffect, useReducer } from 'preact/hooks'
|
import { useEffect, useReducer } from 'preact/hooks'
|
||||||
import { DEFAULT_SETTINGS } from './settings'
|
import { DEFAULT_SETTINGS, ExtensionSettings } from './settings'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook to read the settings from local storage
|
* A hook to read the settings from local storage
|
||||||
*
|
*
|
||||||
* @param initial the default value. Must have all relevant keys present and should not change
|
* @param defaultSettings the default value. Must have all relevant keys present and should not change
|
||||||
*/
|
*/
|
||||||
export function useSettings<T extends object>(initial: T) {
|
function useSettings(defaultSettings: ExtensionSettings) {
|
||||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial)
|
const [state, dispatch] = useReducer((state, nstate: Partial<ExtensionSettings>) => ({ ...state, ...nstate }), defaultSettings)
|
||||||
|
const settingsKeys = Object.keys(defaultSettings)
|
||||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
// 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 changeEntries = Object.keys(changes).filter((key) => settingsKeys.includes(key)).map((key) => [key, changes[key].newValue])
|
||||||
.filter(k => Object.keys(initial).includes(k))
|
if (changeEntries.length === 0) return // no changes; no use dispatching
|
||||||
.map(k => [k, changes[k].newValue])
|
dispatch(Object.fromEntries(changeEntries))
|
||||||
if (changeSet.length === 0) return // no changes; no use dispatching
|
|
||||||
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(settingsKeys, async (settings) => dispatch(settings))
|
||||||
|
|
||||||
return () => chrome.storage.onChanged.removeListener(changeListener)
|
return () => chrome.storage.onChanged.removeListener(changeListener)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Utilty to set a setting in the browser */
|
||||||
|
export const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
|
||||||
|
|
||||||
|
|
||||||
/** A hook to read watch on lbry settings from local storage */
|
/** A hook to read watch on lbry settings from local storage */
|
||||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)
|
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)
|
32
src/common/yt/auth.ts
Normal file
32
src/common/yt/auth.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
function generateKeys() {
|
||||||
|
// The `generateKeyPairSync` method accepts two arguments:
|
||||||
|
// 1. The type ok keys we want, which in this case is "rsa"
|
||||||
|
// 2. An object with the properties of the key
|
||||||
|
const keys = crypto.generateKeyPairSync("rsa", {
|
||||||
|
// The standard secure default length for RSA keys is 2048 bits
|
||||||
|
modulusLength: 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeKeys(keys: { publicKey: Buffer, privateKey: Buffer }) {
|
||||||
|
return JSON.stringify({ publicKey: keys.publicKey.toString('base64'), privateKey: keys.privateKey.toString('base64') })
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeKeys(encodedKeys: string) {
|
||||||
|
const keysBase64 = JSON.parse(encodedKeys)
|
||||||
|
return {
|
||||||
|
publicKey: Buffer.from(keysBase64.publicKey),
|
||||||
|
privateKey: Buffer.from(keysBase64.privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(data: string, privateKey: Buffer) {
|
||||||
|
return crypto.sign("sha256", Buffer.from(data), {
|
||||||
|
key: privateKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||||
|
})
|
||||||
|
}
|
|
@ -37,8 +37,7 @@ async function clearExpired() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearAll()
|
async function clearAll() {
|
||||||
{
|
|
||||||
return await new Promise<void>((resolve, reject) => {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
const store = db?.transaction("store", "readwrite").objectStore("store")
|
const store = db?.transaction("store", "readwrite").objectStore("store")
|
||||||
if (!store) return resolve()
|
if (!store) return resolve()
|
||||||
|
|
|
@ -1,105 +1,81 @@
|
||||||
import { chunk, groupBy } from "lodash"
|
import { chunk } from "lodash"
|
||||||
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from "../settings"
|
import { sign } from "../crypto"
|
||||||
|
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
|
||||||
import { LbryPathnameCache } from "./urlCache"
|
import { LbryPathnameCache } from "./urlCache"
|
||||||
|
|
||||||
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
|
|
||||||
const QUERY_CHUNK_SIZE = 100
|
const QUERY_CHUNK_SIZE = 100
|
||||||
|
|
||||||
export interface YtIdResolverDescriptor {
|
export type YtUrlResolveItem = { type: 'video' | 'channel', id: string }
|
||||||
id: string
|
type Results = Record<string, YtUrlResolveItem>
|
||||||
type: 'channel' | 'video'
|
type Paramaters = YtUrlResolveItem[]
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
channels?: Record<string, string>
|
||||||
|
videos?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
|
||||||
* @param descriptorsWithIndex YT resource IDs to check
|
const { urlResolver: urlResolverSettingName, privateKey, publicKey } = await getExtensionSettingsAsync()
|
||||||
* @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 urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||||
|
|
||||||
const url = new URL(`https://${urlResolverSetting.hostname}`)
|
async function requestChunk(params: Paramaters) {
|
||||||
|
const results: Results = {}
|
||||||
|
|
||||||
function followResponsePath<T>(response: any, responsePath: YtUrlResolveResponsePath) {
|
// Check for cache first, add them to the results if there are any cache
|
||||||
for (const path of responsePath) {
|
// And remove them from the params, so we dont request for them
|
||||||
switch (typeof path) {
|
params = (await Promise.all(params.map(async (item) => {
|
||||||
case 'string': case 'number': response = response[path]; continue
|
const cachedLbryUrl = await LbryPathnameCache.get(item.id)
|
||||||
}
|
|
||||||
switch (path) {
|
// Cache can be null, if there is no lbry url yet
|
||||||
case Keys: response = Object.keys(response); continue
|
if (cachedLbryUrl !== undefined) {
|
||||||
case Values: response = Object.values(response); continue
|
// Null values shouldn't be in the results
|
||||||
}
|
if (cachedLbryUrl !== null) results[item.id] = { id: cachedLbryUrl, type: item.type }
|
||||||
}
|
return null
|
||||||
return response as T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsPayload) {
|
// No cache found
|
||||||
url.pathname = urlResolverFunction.pathname
|
return item
|
||||||
Object.entries(urlResolverFunction.defaultParams).forEach(([name, value]) => url.searchParams.set(name, value.toString()))
|
}))).filter((o) => o) as Paramaters
|
||||||
|
console.log(params)
|
||||||
|
|
||||||
if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) {
|
if (params.length === 0) return results
|
||||||
await Promise.all(descriptorsGroup.map(async (descriptor) => {
|
|
||||||
url.searchParams.set(urlResolverFunction.valueParamName, descriptor.id)
|
const url = new URL(`${urlResolverSetting.href}`)
|
||||||
|
url.searchParams.set('video_ids', params.filter((item) => item.type === 'video').map((item) => item.id).join(','))
|
||||||
|
url.searchParams.set('channel_ids', params.filter((item) => item.type === 'channel').map((item) => item.id).join(','))
|
||||||
|
if (urlResolverSetting.signRequest && publicKey && privateKey)
|
||||||
|
url.searchParams.set('keys', JSON.stringify({
|
||||||
|
signature: await sign(url.searchParams.toString(), privateKey),
|
||||||
|
publicKey
|
||||||
|
}))
|
||||||
|
|
||||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||||
if (apiResponse.ok) {
|
if (apiResponse.ok) {
|
||||||
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
|
const response: ApiResponse = await apiResponse.json()
|
||||||
if (value) results[descriptor.index] = value
|
for (const item of params)
|
||||||
await LbryPathnameCache.put(value, descriptor.id)
|
{
|
||||||
}
|
const lbryUrl = ((item.type === 'channel' ? response.channels : response.videos) ?? {})[item.id] ?? null
|
||||||
else if (apiResponse.status === 404) await LbryPathnameCache.put(null, descriptor.id)
|
// we cache it no matter if its null or not
|
||||||
|
await LbryPathnameCache.put(lbryUrl, item.id)
|
||||||
|
|
||||||
progressCount++
|
if (lbryUrl) results[item.id] = { id: lbryUrl, type: item.type }
|
||||||
if (progressCallback) progressCallback(progressCount / descriptorsPayload.length)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
url.searchParams.set(urlResolverFunction.valueParamName, 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'])
|
return results
|
||||||
if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video'])
|
}
|
||||||
}))
|
|
||||||
|
const results: Results = {}
|
||||||
|
const chunks = chunk(params, QUERY_CHUNK_SIZE)
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
if (progressCallback) progressCallback(0)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (progressCallback) progressCallback(++i / (chunks.length + 1))
|
||||||
|
Object.assign(results, await requestChunk(chunk))
|
||||||
|
}
|
||||||
|
console.log(results)
|
||||||
|
|
||||||
if (progressCallback) progressCallback(1)
|
if (progressCallback) progressCallback(1)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
"https://lbry.tv/",
|
"https://lbry.tv/",
|
||||||
"https://odysee.com/",
|
"https://odysee.com/",
|
||||||
"https://madiator.com/",
|
"https://madiator.com/",
|
||||||
"https://scrap.madiator.com/",
|
"https://finder.madiator.com/",
|
||||||
"tabs",
|
"tabs",
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
"scripts/storageSetup.js",
|
"scripts/storageSetup.js",
|
||||||
"scripts/background.js"
|
"scripts/background.js"
|
||||||
],
|
],
|
||||||
"persistent": false
|
"persistent": true
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_title": "Watch on LBRY",
|
"default_title": "Watch on LBRY",
|
||||||
|
|
150
src/popup/popup.css
Normal file
150
src/popup/popup.css
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
:root {
|
||||||
|
--color-master: #499375;
|
||||||
|
--color-slave: #43889d;
|
||||||
|
--color-error: rgb(245, 81, 69);
|
||||||
|
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
|
||||||
|
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);
|
||||||
|
--color-dark: #0e1117;
|
||||||
|
--color-light: rgb(235, 237, 241);
|
||||||
|
--gradient-animation: gradient-animation 5s linear infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
letter-spacing: .2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--color-light);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(19, 19, 19, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 1.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: grid;
|
||||||
|
gap: .5em;
|
||||||
|
padding: .75em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(19, 19, 19, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
gap: 2em;
|
||||||
|
padding: 1.5em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup {
|
||||||
|
width: 35em;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: .5em 1em;
|
||||||
|
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: var(--color-light);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filled {
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
font-weight: bold;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.active {
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-gradient-0);
|
||||||
|
filter: blur(.5em);
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.disabled {
|
||||||
|
filter: saturate(0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.go-back {
|
||||||
|
color: currentColor;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
|
||||||
|
justify-content: center;
|
||||||
|
gap: .25em;
|
||||||
|
padding: 0 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #0e1117;
|
||||||
|
opacity: .75;
|
||||||
|
}
|
|
@ -6,9 +6,7 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
|
||||||
<div id="root" />
|
<div id="root" />
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,16 +0,0 @@
|
||||||
.radio-label
|
|
||||||
font-size: 1.1rem
|
|
||||||
display: block
|
|
||||||
|
|
||||||
.container
|
|
||||||
display: grid
|
|
||||||
grid-auto-flow: row
|
|
||||||
gap: 1.5em
|
|
||||||
|
|
||||||
.container > section
|
|
||||||
display: grid
|
|
||||||
grid-auto-flow: row
|
|
||||||
gap: 1em
|
|
||||||
|
|
||||||
button
|
|
||||||
cursor: pointer
|
|
|
@ -1,56 +1,171 @@
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { useState } from 'preact/hooks'
|
import { useState } from 'preact/hooks'
|
||||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
|
import '../common/common.css'
|
||||||
import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings'
|
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
|
||||||
import { useLbrySettings } from '../common/useSettings'
|
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
|
||||||
|
import { setSetting, useLbrySettings } from '../common/useSettings'
|
||||||
import { LbryPathnameCache } from '../common/yt/urlCache'
|
import { LbryPathnameCache } from '../common/yt/urlCache'
|
||||||
import './popup.sass'
|
import './popup.css'
|
||||||
|
|
||||||
/** 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 })
|
|
||||||
|
|
||||||
/** 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 targetPlatforms = getTargetPlatfromSettingsEntiries()
|
||||||
.map(([value, { displayName: display }]) => ({ value, display }))
|
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
|
||||||
|
|
||||||
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
|
||||||
.map(([value, { name: display }]) => ({ value, display }))
|
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
|
||||||
|
let [loading, updateLoading] = useState(() => false)
|
||||||
|
let [popupRoute, updateRoute] = useState<string | null>(() => null)
|
||||||
|
|
||||||
function WatchOnLbryPopup() {
|
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
|
||||||
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
|
|
||||||
let [clearingCache, updateClearingCache] = useState(() => false)
|
|
||||||
|
|
||||||
return <div className='container'>
|
async function startAsyncOperation<T>(operation: Promise<T>) {
|
||||||
|
try {
|
||||||
|
updateLoading(true)
|
||||||
|
await operation
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
updateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div id='popup'>
|
||||||
|
|
||||||
|
{
|
||||||
|
publicKey
|
||||||
|
? <header>
|
||||||
<section>
|
<section>
|
||||||
<label className='radio-label'>Enable Redirection:</label>
|
<label>{nickname}</label>
|
||||||
<ButtonRadio value={redirect ? 'YES' : 'NO'} options={['YES', 'NO']}
|
<p>{friendlyPublicKey(publicKey)}</p>
|
||||||
onChange={redirect => setSetting('redirect', redirect.toLowerCase() === 'yes')} />
|
<span><b>Score: {params.profile?.score ?? '...'}</b> - <a target='_blank' href="https://finder.madiator.com/leaderboard" class="filled">🔗Leaderboard</a></span>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<label className='radio-label'>Where would you like to redirect?</label>
|
{
|
||||||
<ButtonRadio value={targetPlatform} options={platformOptions}
|
popupRoute === 'profile'
|
||||||
onChange={(platform: TargetPlatformName) => setSetting('targetPlatform', platform)} />
|
? <a onClick={() => updateRoute('')} className="button filled">⇐ Back</a>
|
||||||
|
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
: <header><a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
popupRoute === 'profile' ?
|
||||||
|
publicKey ?
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<div className='options'>
|
||||||
|
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
|
||||||
|
Change Nickname
|
||||||
|
</a>
|
||||||
|
<a onClick={() => confirm("This will delete your keypair from this device.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && resetProfileSettings() && renderPopup()} className={`button`}>
|
||||||
|
Forget/Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<label className='radio-label'>Resolve URL with:</label>
|
<label>Backup your account</label>
|
||||||
<ButtonRadio value={urlResolver} options={ytUrlResolverOptions}
|
<p>Import and export your unique keypair.</p>
|
||||||
onChange={(urlResolver: YTUrlResolverName) => setSetting('urlResolver', urlResolver)} />
|
<div className='options'>
|
||||||
|
<a onClick={() => exportProfileKeysAsFile()} className={`button active`}>
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
<a onClick={() => confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} className={`button`}>
|
||||||
|
Import
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<a onClick={async () => {
|
<label>Purge your profile and data!</label>
|
||||||
await LbryPathnameCache.clearAll()
|
<p>Purge your profile data online and offline.</p>
|
||||||
alert('Cleared Cache.')
|
<div className='options'>
|
||||||
}}>
|
<a className="button filled">(╯°□°)╯︵ ┻━┻</a>
|
||||||
<button type='button' className='btn1 button is-primary'>{clearingCache ? 'Clearing Cache...' : 'Clear Cache'}</button>
|
<a onClick={() => startAsyncOperation(purgeProfile()).then(() => renderPopup())} className={`button`}>
|
||||||
|
Purge Everything!!
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label>Generate new profile</label>
|
||||||
|
<p>Generate a new keypair.</p>
|
||||||
|
<div className='options'>
|
||||||
|
<a onClick={() => confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.") && startAsyncOperation(generateProfileAndSetNickname(true)).then(() => renderPopup())} className={`button`}>
|
||||||
|
Generate New Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
:
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<label>You don't have a profile.</label>
|
||||||
|
<p>You can either import keypair for an existing profile or generate a new profile keypair.</p>
|
||||||
|
<div className='options'>
|
||||||
|
<a onClick={() => startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} className={`button`}>
|
||||||
|
Import
|
||||||
|
</a>
|
||||||
|
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
|
||||||
|
Generate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
:
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<label>Pick a mode:</label>
|
||||||
|
<div className='options'>
|
||||||
|
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
|
||||||
|
Redirect
|
||||||
|
</a>
|
||||||
|
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
|
||||||
|
Show a button
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label>Which platform you would like to redirect?</label>
|
||||||
|
<div className='options'>
|
||||||
|
{targetPlatforms.map(([name, value]) =>
|
||||||
|
<a onClick={() => setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
|
||||||
|
{value.displayName}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label>Which resolver API you want to use?</label>
|
||||||
|
<div className='options'>
|
||||||
|
{ytUrlResolverOptions.map(([name, value]) =>
|
||||||
|
<a onClick={() => setSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
|
||||||
|
{value.name}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a onClick={() => startAsyncOperation(LbryPathnameCache.clearAll()).then(() => alert("Cleared Cache!"))} className={`button active`}>
|
||||||
|
Clear Resolver Cache
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<label className='radio-label'>Other useful tools:</label>
|
<label>Tools</label>
|
||||||
<a href='/tools/YTtoLBRY.html' target='_blank'>
|
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
|
||||||
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
|
Subscription Converter
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
{loading && <div class="overlay">
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<WatchOnLbryPopup />, document.getElementById('root')!)
|
function renderPopup() {
|
||||||
|
render(<WatchOnLbryPopup profile={null} />, document.getElementById('root')!)
|
||||||
|
getProfile().then((profile) => render(<WatchOnLbryPopup profile={profile} />, document.getElementById('root')!))
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPopup()
|
|
@ -1,7 +1,7 @@
|
||||||
import { resolveById, YtIdResolverDescriptor } from '../common/yt/urlResolve'
|
import { resolveById, YtUrlResolveItem } from '../common/yt/urlResolve'
|
||||||
|
|
||||||
async function resolveYT(descriptor: YtIdResolverDescriptor) {
|
async function resolveYT(item: YtUrlResolveItem) {
|
||||||
const lbryProtocolUrl: string | null = (await resolveById([descriptor]).then(a => a[0])) ?? null
|
const lbryProtocolUrl: string | null = (await resolveById([item]).then((items) => items[item.id]))?.id ?? null
|
||||||
if (!lbryProtocolUrl) return null
|
if (!lbryProtocolUrl) return null
|
||||||
return lbryProtocolUrl.replaceAll('#', ':')
|
return lbryProtocolUrl.replaceAll('#', ':')
|
||||||
/* const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true })
|
/* const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true })
|
||||||
|
@ -25,8 +25,7 @@ async function lbryPathnameFromVideoId(videoId: string): Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => {
|
||||||
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) =>
|
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) => {
|
||||||
{
|
|
||||||
sendResponse('error')
|
sendResponse('error')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,7 +6,7 @@ async function initSettings() {
|
||||||
|
|
||||||
// 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] === undefined || settings[k] === null)
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
|
@ -131,12 +131,10 @@ async function requestLbryPathname(videoId: string) {
|
||||||
// On redirect with app, people might choose to cancel browser's dialog
|
// On redirect with app, people might choose to cancel browser's dialog
|
||||||
// So we dont destroy the current window automatically for them
|
// So we dont destroy the current window automatically for them
|
||||||
// And also we are keeping the same window for less distiraction
|
// And also we are keeping the same window for less distiraction
|
||||||
if (settings.redirect)
|
if (settings.redirect) {
|
||||||
{
|
|
||||||
location.replace(url.toString())
|
location.replace(url.toString())
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
{
|
|
||||||
open(url.toString(), '_blank')
|
open(url.toString(), '_blank')
|
||||||
if (window.history.length === 1) window.close()
|
if (window.history.length === 1) window.close()
|
||||||
else window.history.back()
|
else window.history.back()
|
||||||
|
|
|
@ -18,12 +18,14 @@ async function lbryChannelsFromFile(file: File) {
|
||||||
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
|
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
|
||||||
ext === 'csv' ? getSubsFromCsv :
|
ext === 'csv' ? getSubsFromCsv :
|
||||||
getSubsFromJson)(await getFileContent(file)))
|
getSubsFromJson)(await getFileContent(file)))
|
||||||
const lbryPathnames = await resolveById(
|
|
||||||
|
const items = 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()
|
const { targetPlatform: platform } = await getExtensionSettingsAsync()
|
||||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
||||||
return lbryPathnames.map(channel => urlPrefix + channel)
|
return Object.values(items).map((item) => urlPrefix + item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
|
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue