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
|
||||
targetPlatform: TargetPlatformName
|
||||
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> {
|
||||
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
|
||||
}
|
||||
|
||||
|
||||
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
|
||||
export interface TargetPlatform {
|
||||
const targetPlatform = (o: {
|
||||
domainPrefix: string
|
||||
displayName: string
|
||||
theme: string
|
||||
|
@ -27,10 +33,14 @@ export interface TargetPlatform {
|
|||
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: Record<TargetPlatformName, TargetPlatform> = {
|
||||
'madiator.com': {
|
||||
export const targetPlatformSettings = {
|
||||
'madiator.com': targetPlatform({
|
||||
domainPrefix: 'https://madiator.com/',
|
||||
displayName: 'Madiator.com',
|
||||
theme: '#075656',
|
||||
|
@ -42,8 +52,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
|||
icon: { transform: 'scale(1.2)' }
|
||||
}
|
||||
}
|
||||
},
|
||||
odysee: {
|
||||
}),
|
||||
odysee: targetPlatform({
|
||||
domainPrefix: 'https://odysee.com/',
|
||||
displayName: 'Odysee',
|
||||
theme: '#1e013b',
|
||||
|
@ -51,8 +61,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
|||
text: 'Watch on Odysee',
|
||||
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
|
||||
}
|
||||
},
|
||||
app: {
|
||||
}),
|
||||
app: targetPlatform({
|
||||
domainPrefix: 'lbry://',
|
||||
displayName: 'LBRY App',
|
||||
theme: '#075656',
|
||||
|
@ -60,118 +70,60 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
|
|||
text: 'Watch on LBRY',
|
||||
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const getTargetPlatfromSettingsEntiries = () => {
|
||||
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type SourcePlatformName = 'youtube.com' | 'yewtu.be'
|
||||
export interface SourcePlatform {
|
||||
const sourcePlatform = (o: {
|
||||
hostnames: string[]
|
||||
htmlQueries: {
|
||||
mountButtonBefore: string,
|
||||
videoPlayer: string
|
||||
}
|
||||
}
|
||||
|
||||
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
|
||||
"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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}) => o
|
||||
export type SourcePlatform = ReturnType<typeof sourcePlatform>
|
||||
export type SourcePlatformName = Extract<keyof typeof sourcePlatfromSettings, string>
|
||||
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
|
||||
defaultParams: Record<string, string | number>
|
||||
valueParamName: string
|
||||
paramArraySeperator: string | typeof SingleValueAtATime
|
||||
responsePath: YtUrlResolveResponsePath
|
||||
export const sourcePlatfromSettings = {
|
||||
"yewtu.be": sourcePlatform({
|
||||
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
|
||||
htmlQueries: {
|
||||
mountButtonBefore: '#watch-on-youtube',
|
||||
videoPlayer: '#player-container video'
|
||||
}
|
||||
}),
|
||||
"youtube.com": sourcePlatform({
|
||||
hostnames: ['www.youtube.com'],
|
||||
htmlQueries: {
|
||||
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
|
||||
videoPlayer: '#ytd-player video'
|
||||
}
|
||||
})
|
||||
}
|
||||
export interface YTUrlResolver {
|
||||
|
||||
const ytUrlResolver = (o: {
|
||||
name: string
|
||||
hostname: string
|
||||
functions: {
|
||||
getChannelId: YtUrlResolveFunction
|
||||
getVideoId: YtUrlResolveFunction
|
||||
}
|
||||
}
|
||||
|
||||
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
|
||||
lbryInc: {
|
||||
href: string
|
||||
signRequest: boolean
|
||||
}) => o
|
||||
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 = {
|
||||
odyseeApi: ytUrlResolver({
|
||||
name: "Odysee",
|
||||
hostname: "api.odysee.com",
|
||||
functions: {
|
||||
getChannelId: {
|
||||
pathname: "/yt/resolve",
|
||||
defaultParams: {},
|
||||
valueParamName: "channel_ids",
|
||||
paramArraySeperator: ',',
|
||||
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][]
|
||||
href: "https://api.odysee.com/yt/resolve",
|
||||
signRequest: false
|
||||
}),
|
||||
madiatorFinder: ytUrlResolver({
|
||||
name: "Madiator Finder",
|
||||
href: "https://finder.madiator.com/api/v1/resolve",
|
||||
signRequest: true
|
||||
})
|
||||
}
|
|
@ -1,30 +1,35 @@
|
|||
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
|
||||
*
|
||||
* @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) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial)
|
||||
function useSettings(defaultSettings: ExtensionSettings) {
|
||||
const [state, dispatch] = useReducer((state, nstate: Partial<ExtensionSettings>) => ({ ...state, ...nstate }), defaultSettings)
|
||||
const settingsKeys = Object.keys(defaultSettings)
|
||||
// register change listeners, gets current values, and cleans up the listeners on unload
|
||||
useEffect(() => {
|
||||
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== 'local') return
|
||||
const changeSet = Object.keys(changes)
|
||||
.filter(k => Object.keys(initial).includes(k))
|
||||
.map(k => [k, changes[k].newValue])
|
||||
if (changeSet.length === 0) return // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeSet))
|
||||
const changeEntries = Object.keys(changes).filter((key) => settingsKeys.includes(key)).map((key) => [key, changes[key].newValue])
|
||||
if (changeEntries.length === 0) return // no changes; no use dispatching
|
||||
dispatch(Object.fromEntries(changeEntries))
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener(changeListener)
|
||||
chrome.storage.local.get(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 state
|
||||
}
|
||||
|
||||
/** Utilty to set a setting in the browser */
|
||||
export const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
|
||||
|
||||
|
||||
/** A hook to read watch on lbry settings from local storage */
|
||||
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)
|
||||
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) => {
|
||||
const store = db?.transaction("store", "readwrite").objectStore("store")
|
||||
if (!store) return resolve()
|
||||
|
|
|
@ -1,105 +1,81 @@
|
|||
import { chunk, groupBy } from "lodash"
|
||||
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from "../settings"
|
||||
import { chunk } from "lodash"
|
||||
import { sign } from "../crypto"
|
||||
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
|
||||
import { LbryPathnameCache } from "./urlCache"
|
||||
|
||||
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
|
||||
const QUERY_CHUNK_SIZE = 100
|
||||
|
||||
export interface YtIdResolverDescriptor {
|
||||
id: string
|
||||
type: 'channel' | 'video'
|
||||
export type YtUrlResolveItem = { type: 'video' | 'channel', id: string }
|
||||
type Results = Record<string, YtUrlResolveItem>
|
||||
type Paramaters = YtUrlResolveItem[]
|
||||
|
||||
interface ApiResponse {
|
||||
channels?: Record<string, string>
|
||||
videos?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* @param descriptorsWithIndex YT resource IDs to check
|
||||
* @returns a promise with the list of channels that were found on lbry
|
||||
*/
|
||||
export async function resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<(string | null)[]> {
|
||||
let descriptorsPayload: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({ ...descriptor, index }))
|
||||
descriptors = null as any
|
||||
const results: (string | null)[] = []
|
||||
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
|
||||
const { urlResolver: urlResolverSettingName, privateKey, publicKey } = await getExtensionSettingsAsync()
|
||||
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||
|
||||
async function requestChunk(params: Paramaters) {
|
||||
const results: Results = {}
|
||||
|
||||
descriptorsPayload = (await Promise.all(descriptorsPayload.map(async (descriptor, index) => {
|
||||
if (!descriptor?.id) return
|
||||
const cache = await LbryPathnameCache.get(descriptor.id)
|
||||
// Check for cache first, add them to the results if there are any cache
|
||||
// And remove them from the params, so we dont request for them
|
||||
params = (await Promise.all(params.map(async (item) => {
|
||||
const cachedLbryUrl = await LbryPathnameCache.get(item.id)
|
||||
|
||||
// Cache can be null, if there is no lbry url yet
|
||||
if (cache !== undefined) {
|
||||
// Null values shouldn't be in the results
|
||||
if (cache) results[index] = cache
|
||||
return
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}))).filter((descriptor) => descriptor) as any
|
||||
|
||||
const descriptorsPayloadChunks = chunk(descriptorsPayload, QUERY_CHUNK_SIZE)
|
||||
let progressCount = 0
|
||||
await Promise.all(descriptorsPayloadChunks.map(async (descriptorChunk) => {
|
||||
const descriptorsGroupedByType: Record<YtIdResolverDescriptor['type'], typeof descriptorsPayload | null> = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any
|
||||
|
||||
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync()
|
||||
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
|
||||
|
||||
const url = new URL(`https://${urlResolverSetting.hostname}`)
|
||||
|
||||
function followResponsePath<T>(response: any, responsePath: YtUrlResolveResponsePath) {
|
||||
for (const path of responsePath) {
|
||||
switch (typeof path) {
|
||||
case 'string': case 'number': response = response[path]; continue
|
||||
}
|
||||
switch (path) {
|
||||
case Keys: response = Object.keys(response); continue
|
||||
case Values: response = Object.values(response); continue
|
||||
}
|
||||
// Cache can be null, if there is no lbry url yet
|
||||
if (cachedLbryUrl !== undefined) {
|
||||
// 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) {
|
||||
url.pathname = urlResolverFunction.pathname
|
||||
Object.entries(urlResolverFunction.defaultParams).forEach(([name, value]) => url.searchParams.set(name, value.toString()))
|
||||
// No cache found
|
||||
return item
|
||||
}))).filter((o) => o) as Paramaters
|
||||
console.log(params)
|
||||
|
||||
if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) {
|
||||
await Promise.all(descriptorsGroup.map(async (descriptor) => {
|
||||
url.searchParams.set(urlResolverFunction.valueParamName, descriptor.id)
|
||||
if (params.length === 0) return results
|
||||
|
||||
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
|
||||
if (apiResponse.ok) {
|
||||
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
|
||||
if (value) results[descriptor.index] = value
|
||||
await LbryPathnameCache.put(value, descriptor.id)
|
||||
}
|
||||
else if (apiResponse.status === 404) await LbryPathnameCache.put(null, descriptor.id)
|
||||
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
|
||||
}))
|
||||
|
||||
progressCount++
|
||||
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 response: ApiResponse = await apiResponse.json()
|
||||
for (const item of params)
|
||||
{
|
||||
const lbryUrl = ((item.type === 'channel' ? response.channels : response.videos) ?? {})[item.id] ?? null
|
||||
// we cache it no matter if its null or not
|
||||
await LbryPathnameCache.put(lbryUrl, item.id)
|
||||
|
||||
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 (lbryUrl) results[item.id] = { id: lbryUrl, type: item.type }
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel'])
|
||||
if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video'])
|
||||
}))
|
||||
return results
|
||||
}
|
||||
|
||||
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)
|
||||
return results
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"https://lbry.tv/",
|
||||
"https://odysee.com/",
|
||||
"https://madiator.com/",
|
||||
"https://scrap.madiator.com/",
|
||||
"https://finder.madiator.com/",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
|
@ -34,7 +34,7 @@
|
|||
"scripts/storageSetup.js",
|
||||
"scripts/background.js"
|
||||
],
|
||||
"persistent": false
|
||||
"persistent": true
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "Watch on LBRY",
|
||||
|
@ -53,4 +53,4 @@
|
|||
"128": "icons/wol/icon128.png"
|
||||
},
|
||||
"manifest_version": 2
|
||||
}
|
||||
}
|
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>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="root" />
|
||||
</div>
|
||||
<div id="root" />
|
||||
</body>
|
||||
|
||||
</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 { useState } from 'preact/hooks'
|
||||
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
|
||||
import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings'
|
||||
import { useLbrySettings } from '../common/useSettings'
|
||||
import '../common/common.css'
|
||||
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
|
||||
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
|
||||
import { setSetting, useLbrySettings } from '../common/useSettings'
|
||||
import { LbryPathnameCache } from '../common/yt/urlCache'
|
||||
import './popup.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 */
|
||||
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
|
||||
.map(([value, { displayName: display }]) => ({ value, display }))
|
||||
const targetPlatforms = getTargetPlatfromSettingsEntiries()
|
||||
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
|
||||
|
||||
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
|
||||
.map(([value, { name: display }]) => ({ value, display }))
|
||||
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
|
||||
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
|
||||
let [loading, updateLoading] = useState(() => false)
|
||||
let [popupRoute, updateRoute] = useState<string | null>(() => null)
|
||||
|
||||
function WatchOnLbryPopup() {
|
||||
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
|
||||
let [clearingCache, updateClearingCache] = useState(() => false)
|
||||
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
|
||||
|
||||
return <div className='container'>
|
||||
<section>
|
||||
<label className='radio-label'>Enable Redirection:</label>
|
||||
<ButtonRadio value={redirect ? 'YES' : 'NO'} options={['YES', 'NO']}
|
||||
onChange={redirect => setSetting('redirect', redirect.toLowerCase() === 'yes')} />
|
||||
</section>
|
||||
<section>
|
||||
<label className='radio-label'>Where would you like to redirect?</label>
|
||||
<ButtonRadio value={targetPlatform} options={platformOptions}
|
||||
onChange={(platform: TargetPlatformName) => setSetting('targetPlatform', platform)} />
|
||||
</section>
|
||||
<section>
|
||||
<label className='radio-label'>Resolve URL with:</label>
|
||||
<ButtonRadio value={urlResolver} options={ytUrlResolverOptions}
|
||||
onChange={(urlResolver: YTUrlResolverName) => setSetting('urlResolver', urlResolver)} />
|
||||
</section>
|
||||
<section>
|
||||
<a onClick={async () => {
|
||||
await LbryPathnameCache.clearAll()
|
||||
alert('Cleared Cache.')
|
||||
}}>
|
||||
<button type='button' className='btn1 button is-primary'>{clearingCache ? 'Clearing Cache...' : 'Clear Cache'}</button>
|
||||
</a>
|
||||
</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>
|
||||
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>
|
||||
<label>{nickname}</label>
|
||||
<p>{friendlyPublicKey(publicKey)}</p>
|
||||
<span><b>Score: {params.profile?.score ?? '...'}</b> - <a target='_blank' href="https://finder.madiator.com/leaderboard" class="filled">🔗Leaderboard</a></span>
|
||||
</section>
|
||||
<section>
|
||||
{
|
||||
popupRoute === 'profile'
|
||||
? <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>
|
||||
<label>Backup your account</label>
|
||||
<p>Import and export your unique keypair.</p>
|
||||
<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>
|
||||
<label>Purge your profile and data!</label>
|
||||
<p>Purge your profile data online and offline.</p>
|
||||
<div className='options'>
|
||||
<a className="button filled">(╯°□°)╯︵ ┻━┻</a>
|
||||
<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>
|
||||
</section>
|
||||
<section>
|
||||
<label>Tools</label>
|
||||
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
|
||||
Subscription Converter
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
{loading && <div class="overlay">
|
||||
<span>Loading...</span>
|
||||
</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) {
|
||||
const lbryProtocolUrl: string | null = (await resolveById([descriptor]).then(a => a[0])) ?? null
|
||||
async function resolveYT(item: YtUrlResolveItem) {
|
||||
const lbryProtocolUrl: string | null = (await resolveById([item]).then((items) => items[item.id]))?.id ?? null
|
||||
if (!lbryProtocolUrl) return null
|
||||
return lbryProtocolUrl.replaceAll('#', ':')
|
||||
/* 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) => {
|
||||
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) =>
|
||||
{
|
||||
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) => {
|
||||
sendResponse('error')
|
||||
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
|
||||
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
|
||||
if (invalidEntries.length > 0) {
|
||||
|
|
|
@ -117,32 +117,30 @@ async function requestLbryPathname(videoId: string) {
|
|||
|
||||
async function redirectTo({ lbryPathname, platfrom, time }: Target) {
|
||||
const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`)
|
||||
|
||||
|
||||
if (time) url.searchParams.set('t', time.toFixed(0))
|
||||
|
||||
|
||||
findVideoElement().then((videoElement) => {
|
||||
videoElement.addEventListener('play', () => videoElement.pause(), { once: true })
|
||||
videoElement.pause()
|
||||
})
|
||||
|
||||
|
||||
if (platfrom === targetPlatformSettings.app) {
|
||||
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
|
||||
|
||||
// On redirect with app, people might choose to cancel browser's dialog
|
||||
// So we dont destroy the current window automatically for them
|
||||
// And also we are keeping the same window for less distiraction
|
||||
if (settings.redirect)
|
||||
{
|
||||
if (settings.redirect) {
|
||||
location.replace(url.toString())
|
||||
}
|
||||
else
|
||||
{
|
||||
else {
|
||||
open(url.toString(), '_blank')
|
||||
if (window.history.length === 1) window.close()
|
||||
else window.history.back()
|
||||
}
|
||||
}
|
||||
else
|
||||
else
|
||||
location.replace(url.toString())
|
||||
}
|
||||
|
||||
|
|
|
@ -18,12 +18,14 @@ async function lbryChannelsFromFile(file: File) {
|
|||
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
|
||||
ext === 'csv' ? getSubsFromCsv :
|
||||
getSubsFromJson)(await getFileContent(file)))
|
||||
const lbryPathnames = await resolveById(
|
||||
|
||||
const items = await resolveById(
|
||||
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
|
||||
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!))
|
||||
|
||||
const { targetPlatform: platform } = await getExtensionSettingsAsync()
|
||||
const urlPrefix = targetPlatformSettings[platform].domainPrefix
|
||||
return 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 }) {
|
||||
|
|
Loading…
Add table
Reference in a new issue