diff --git a/src/common/settings.ts b/src/common/settings.ts index de9065a..c69c832 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -1,50 +1,81 @@ -export interface ExtensionSettings { +import { JSX } from "preact" + +export interface ExtensionSettings +{ redirect: boolean targetPlatform: TargetPlatformName urlResolver: YTUrlResolverName } -export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' }; +export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' } -export function getExtensionSettingsAsync(): Promise { - return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any))); +export function getExtensionSettingsAsync(): Promise +{ + return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any))) } - -export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app' -export interface TargetPlatformSettings { +export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app' +export interface TargetPlatform +{ domainPrefix: string displayName: string theme: string + button: { + text: string + icon: string + style?: + { + icon?: JSX.CSSProperties + button?: JSX.CSSProperties + } + } } -export const targetPlatformSettings: Record = { - 'madiator.com': { - domainPrefix: 'https://madiator.com/', - displayName: 'Madiator.com', - theme: '#075656' +export const targetPlatformSettings: Record = { + 'madiator.com': { + domainPrefix: 'https://madiator.com/', + displayName: 'Madiator.com', + theme: '#075656', + button: { + text: 'Watch on', + icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'), + style: { + button: { flexDirection: 'row-reverse' }, + icon: { transform: 'scale(1.2)' } + } + } }, - odysee: { - domainPrefix: 'https://odysee.com/', - displayName: 'Odysee', - theme: '#1e013b' + odysee: { + domainPrefix: 'https://odysee.com/', + displayName: 'Odysee', + theme: '#1e013b', + button: { + text: 'Watch on Odysee', + icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg') + } }, - app: { - domainPrefix: 'lbry://', - displayName: 'LBRY App', - theme: '#075656' + app: { + domainPrefix: 'lbry://', + displayName: 'LBRY App', + theme: '#075656', + button: { + text: 'Watch on LBRY', + icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') + } }, -}; +} -export const getTargetPlatfromSettingsEntiries = () => { - return Object.entries(targetPlatformSettings) as any as [Extract, TargetPlatformSettings][] +export const getTargetPlatfromSettingsEntiries = () => +{ + return Object.entries(targetPlatformSettings) as any as [Extract, TargetPlatform][] } export type SourcePlatfromName = 'youtube.com' | 'yewtu.be' -export interface SourcePlatfromSettings { +export interface SourcePlatfrom +{ hostnames: string[] htmlQueries: { mountButtonBefore: string, @@ -52,7 +83,7 @@ export interface SourcePlatfromSettings { } } -export const sourcePlatfromSettings: Record = { +export const sourcePlatfromSettings: Record = { "yewtu.be": { hostnames: ['yewtu.be'], htmlQueries: { @@ -69,7 +100,8 @@ export const sourcePlatfromSettings: Record = name: "LBRY Inc.", hostname: "api.odysee.com", functions: { - getChannelId : { + getChannelId: { pathname: "/yt/resolve", paramName: "channel_ids", paramArraySeperator: ',', @@ -139,6 +171,7 @@ export const ytUrlResolversSettings: Record = } } -export const getYtUrlResolversSettingsEntiries = () => { +export const getYtUrlResolversSettingsEntiries = () => +{ return Object.entries(ytUrlResolversSettings) as any as [Extract, YTUrlResolver][] } \ No newline at end of file diff --git a/src/common/yt/index.ts b/src/common/yt/index.ts index d7ca1da..31f0440 100644 --- a/src/common/yt/index.ts +++ b/src/common/yt/index.ts @@ -1,7 +1,7 @@ import chunk from 'lodash/chunk' import groupBy from 'lodash/groupBy' import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from '../settings' -import { LbryURLCache } from './urlCache' +import { LbryPathnameCache } from './urlCache' // const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS const QUERY_CHUNK_SIZE = 300 @@ -57,6 +57,29 @@ export function getChannelId(channelURL: string) return match ? match[1] : new URL(channelURL).searchParams.get('channel_id') } +export function parseYouTubeURLTimeString(timeString: string) +{ + const signs = timeString.replace(/[0-9]/g, '') + if (signs.length === 0) return timeString + const numbers = timeString.replace(/[^0-9]/g, '-').split('-') + let total = 0 + for (let i = 0; i < signs.length; i++) + { + let t = parseInt(numbers[i]) + switch (signs[i]) + { + case 'd': t *= 24 + case 'h': t *= 60 + case 'm': t *= 60 + case 's': break + default: return '0' + } + total += t + } + return total.toString() +} + + /** * Reads the array of YT channels from an OPML file * @@ -113,7 +136,7 @@ export async function resolveById(descriptors: YtIdResolverDescriptor[], progres await Promise.all(descriptorsWithIndex.map(async (descriptor, index) => { if (!descriptor) return - const cache = await LbryURLCache.get(descriptor.id) + const cache = await LbryPathnameCache.get(descriptor.id) // Cache can be null, if there is no lbry url yet if (cache !== undefined) @@ -180,13 +203,13 @@ export async function resolveById(descriptors: YtIdResolverDescriptor[], progres if (!apiResponse.ok) { // Some API might not respond with 200 if it can't find the url - if (apiResponse.status === 404) await LbryURLCache.put(null, descriptor.id) + if (apiResponse.status === 404) await LbryPathnameCache.put(null, descriptor.id) break } const value = followResponsePath(await apiResponse.json(), urlResolverFunction.responsePath) if (value) results[descriptor.index] = value - await LbryURLCache.put(value, descriptor.id) + await LbryPathnameCache.put(value, descriptor.id) } progressCount++ if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length) @@ -213,7 +236,7 @@ export async function resolveById(descriptors: YtIdResolverDescriptor[], progres { const descriptor = descriptorsGroup[index] if (value) results[descriptor.index] = value - await LbryURLCache.put(value, descriptor.id) + await LbryPathnameCache.put(value, descriptor.id) })) } progressCount += descriptorsGroup.length diff --git a/src/common/yt/urlCache.ts b/src/common/yt/urlCache.ts index b598340..8b3b6f6 100644 --- a/src/common/yt/urlCache.ts +++ b/src/common/yt/urlCache.ts @@ -1,14 +1,20 @@ +// This should only work in background -const openRequest = self.indexedDB?.open("yt-url-resolver-cache") +let db: IDBDatabase | null = null -if (openRequest) +// Throw if its not in the background +if (chrome.extension.getBackgroundPage() !== self) throw new Error() + +if (typeof self.indexedDB !== 'undefined') { + const openRequest = indexedDB.open("yt-url-resolver-cache") openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt")) // Delete Expired openRequest.addEventListener('success', () => { - const transaction = openRequest.result.transaction("store", "readwrite") + db = openRequest.result + const transaction = db.transaction("store", "readwrite") const range = IDBKeyRange.upperBound(new Date()) const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range) @@ -23,22 +29,24 @@ if (openRequest) } else console.warn(`IndexedDB not supported`) + async function put(url: string | null, id: string): Promise { return await new Promise((resolve, reject) => { - const store = openRequest.result.transaction("store", "readwrite").objectStore("store") + const store = db?.transaction("store", "readwrite").objectStore("store") if (!store) return resolve() const request = store.put({ value: url, expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, id) request.addEventListener('success', () => resolve()) request.addEventListener('error', () => reject(request.error)) }) } + async function get(id: string): Promise { return (await new Promise((resolve, reject) => { - const store = openRequest.result.transaction("store", "readonly").objectStore("store") + const store = db?.transaction("store", "readonly").objectStore("store") if (!store) return resolve(null) const request = store.get(id) request.addEventListener('success', () => resolve(request.result)) @@ -46,5 +54,5 @@ async function get(id: string): Promise }) as any)?.value } -export const LbryURLCache = { put, get } +export const LbryPathnameCache = { put, get } diff --git a/src/manifest.json b/src/manifest.json index 15df083..c34f56f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -25,7 +25,7 @@ "background": { "scripts": [ "scripts/storageSetup.js", - "scripts/tabOnUpdated.js" + "scripts/background.js" ], "persistent": false }, diff --git a/src/scripts/background.ts b/src/scripts/background.ts new file mode 100644 index 0000000..a86ef0e --- /dev/null +++ b/src/scripts/background.ts @@ -0,0 +1,22 @@ +import { parseProtocolUrl } from '../common/lbry-url' +import { resolveById, YtIdResolverDescriptor } from '../common/yt' +async function resolveYT(descriptor: YtIdResolverDescriptor) { + const lbryProtocolUrl: string | null = await resolveById([descriptor]).then(a => a[0]); + const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true }); + if (segments.length === 0) return; + return segments.join('/'); +} + +const onGoingLbryPathnameRequest: Record> = {} +async function lbryPathnameFromVideoId(videoId: string): Promise { + // Don't create a new Promise for same ID until on going one is over. + const promise = onGoingLbryPathnameRequest[videoId] ?? (onGoingLbryPathnameRequest[videoId] = resolveYT({ id: videoId, type: 'video' })) + await promise + delete onGoingLbryPathnameRequest[videoId] + return await promise +} + +chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => { + lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)) + return true; +}) \ No newline at end of file diff --git a/src/scripts/tabOnUpdated.ts b/src/scripts/tabOnUpdated.ts deleted file mode 100644 index 17e1bb5..0000000 --- a/src/scripts/tabOnUpdated.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { TargetPlatformName } from '../common/settings' -import { YtIdResolverDescriptor } from '../common/yt' -export interface UpdateContext { - descriptor: YtIdResolverDescriptor - /** LBRY URL fragment */ - lbryPathname: string - redirect: boolean - targetPlatform: TargetPlatformName -} \ No newline at end of file diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx index 7ae21a5..d5bcffa 100644 --- a/src/scripts/ytContent.tsx +++ b/src/scripts/ytContent.tsx @@ -1,55 +1,30 @@ -import { h, JSX, render } from 'preact' -import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatformName, targetPlatformSettings } from '../common/settings' -import { resolveById, YtIdResolverDescriptor } from '../common/yt' -import type { UpdateContext } from '../scripts/tabOnUpdated' +import { h, render } from 'preact' +import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings' +import { parseYouTubeURLTimeString } from '../common/yt' const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)); function pauseAllVideos() { document.querySelectorAll('video').forEach(v => v.pause()); } -interface ButtonSettings { - text: string - icon: string - style?: - { - icon?: JSX.CSSProperties - button?: JSX.CSSProperties - } -} - -const buttonSettings: Record = { - app: { - text: 'Watch on LBRY', - icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') - }, - 'madiator.com': { - text: 'Watch on', - icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'), - style: { - button: { flexDirection: 'row-reverse' }, - icon: { transform: 'scale(1.2)' } - } - }, - odysee: { - text: 'Watch on Odysee', - icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg') - }, -}; - -interface ButtonParameters +interface WatchOnLbryButtonParameters { - targetPlatform?: TargetPlatformName + targetPlatform?: TargetPlatform lbryPathname?: string time?: number } -export function WatchOnLbryButton({ targetPlatform = 'app', lbryPathname, time }: ButtonParameters = {}) { - if (!lbryPathname || !targetPlatform) return null; - const targetPlatformSetting = targetPlatformSettings[targetPlatform]; - const buttonSetting = buttonSettings[targetPlatform]; +interface Target +{ + platfrom: TargetPlatform + lbryPathname: string + time: number | null +} - const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`) - if (time) url.searchParams.append('t', time.toFixed(0)) +export function WatchOnLbryButton({ targetPlatform, lbryPathname, time }: WatchOnLbryButtonParameters) { + if (!lbryPathname || !targetPlatform) return null; + + const url = new URL(`${targetPlatform.domainPrefix}${lbryPathname}`) + if (time) url.searchParams.set('t', time.toFixed(0)) return ; } -let mountPoint: HTMLDivElement | null = null +function updateButton(mountPoint: HTMLDivElement, target: Target | null): void { + if (!target) return render(, mountPoint) + render(, mountPoint) +} + +function redirectTo({ lbryPathname, platfrom }: Target) +{ + const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`) + const time = new URL(location.href).searchParams.get('t') + + if (time) url.searchParams.set('t', parseYouTubeURLTimeString(time)) + + if (platfrom === targetPlatformSettings.app) + { + pauseAllVideos(); + location.assign(url); + return + } + location.replace(url.toString()); +} + /** Returns a mount point for the button */ -async function findButtonMountPoint(): Promise { +async function findButtonMountPoint(): Promise { let mountBefore: HTMLDivElement | null = null const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname) if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`) @@ -88,82 +83,24 @@ async function findButtonMountPoint(): Promise { div.style.display = 'flex'; div.style.alignItems = 'center' mountBefore.parentElement?.insertBefore(div, mountBefore) - mountPoint = div + + return div } -let videoElement: HTMLVideoElement | null = null; async function findVideoElement() { const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname) if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`) + let videoElement: HTMLVideoElement | null = null; while(!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200) - videoElement.addEventListener('timeupdate', () => updateButton(ctxCache)) -} - -/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */ -let ctxCache: UpdateContext | null = null -function handleURLChange (ctx: UpdateContext | null): void { - ctxCache = ctx - updateButton(ctx) - if (ctx?.redirect) redirectTo(ctx) -} - -function updateButton(ctx: UpdateContext | null): void { - if (!mountPoint) return - if (!ctx) return render(, mountPoint) - if (ctx.descriptor.type !== 'video') return; - const lbryPathname = ctx.lbryPathname - const targetPlatform = ctx.targetPlatform - let time: number = videoElement?.currentTime ?? 0 - if (time < 3) time = 0 - if (time >= (videoElement?.duration ?? 0) - 1) time = 0 - - render(, mountPoint) -} - -function redirectTo({ targetPlatform, lbryPathname }: UpdateContext): void { - - const parseYouTubeTime = (timeString: string) => { - const signs = timeString.replace(/[0-9]/g, '') - if (signs.length === 0) return timeString - const numbers = timeString.replace(/[^0-9]/g, '-').split('-') - let total = 0 - for (let i = 0; i < signs.length; i++) { - let t = parseInt(numbers[i]) - switch (signs[i]) { - case 'd': t *= 24; case 'h': t *= 60; case 'm': t *= 60; case 's': break - default: return '0' - } - total += t - } - return total.toString() - } - - const targetPlatformSetting = targetPlatformSettings[targetPlatform]; - const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`) - const time = new URL(location.href).searchParams.get('t') - - if (time) url.searchParams.append('t', parseYouTubeTime(time)) - - if (targetPlatform === 'app') - { - pauseAllVideos(); - location.assign(url); - return - } - location.replace(url.toString()); + return videoElement } window.addEventListener('load', async () => { - // Listen History.pushState - { - const originalPushState = history.pushState - history.pushState = function(...params) { onPushState(); return originalPushState(...params) } - } - const settings = await getExtensionSettingsAsync() + const [buttonMountPoint, videoElement] = await Promise.all([findButtonMountPoint(), findVideoElement()]) // Listen Settings Change chrome.storage.onChanged.addListener(async (changes, areaName) => { @@ -171,24 +108,38 @@ window.addEventListener('load', async () => Object.assign(settings, changes) }); + // Listen History.pushState + { + const originalPushState = history.pushState + history.pushState = function(...params) { originalPushState(...params); afterPushState(); } + } + + // Request Lbry pathname from background + // We should get this from background, so the caching works and we don't get erros in the future if yt decides to impliment CORS + const requestLbryPathname = async (videoId: string) => await new Promise((resolve) => chrome.runtime.sendMessage({ videoId }, resolve)) + + let target: Target | null = null async function updateByURL(url: URL) { if (url.pathname !== '/watch') return + const videoId = url.searchParams.get('v') if (!videoId) return - const descriptor: YtIdResolverDescriptor = { id: videoId, type: 'video' } - const lbryPathname = (await resolveById([descriptor]))[0] + const lbryPathname = await requestLbryPathname(videoId) if (!lbryPathname) return - updateButton({ descriptor, lbryPathname, redirect: settings.redirect, targetPlatform: settings.targetPlatform }) + const time = videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null + target = { lbryPathname, platfrom: targetPlatformSettings[settings.targetPlatform], time } + + if (settings.redirect) redirectTo(target) + else updateButton(buttonMountPoint, target) } - async function onPushState() + videoElement.addEventListener('timeupdate', () => updateButton(buttonMountPoint, target)) + + async function afterPushState() { await updateByURL(new URL(location.href)) } await updateByURL(new URL(location.href)) - - findButtonMountPoint().then(() => updateButton(ctxCache)) - findVideoElement().then(() => updateButton(ctxCache)) }) \ No newline at end of file diff --git a/src/tools/YTtoLBRY.tsx b/src/tools/YTtoLBRY.tsx index 7ebbe01..ab120a1 100644 --- a/src/tools/YTtoLBRY.tsx +++ b/src/tools/YTtoLBRY.tsx @@ -19,12 +19,12 @@ async function lbryChannelsFromFile(file: File) { ext === 'xml' || ext == 'opml' ? getSubsFromOpml : ext === 'csv' ? getSubsFromCsv : getSubsFromJson)(await getFileContent(file))) - const lbryUrls = await resolveById( + const lbryPathnames = await resolveById( Array.from(ids).map(id => ({ id, type: 'channel' } as const)), (progress) => render(, document.getElementById('root')!)); const { targetPlatform: platform } = await getExtensionSettingsAsync(); const urlPrefix = targetPlatformSettings[platform].domainPrefix; - return lbryUrls.map(channel => urlPrefix + channel); + return lbryPathnames.map(channel => urlPrefix + channel); } function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise | void, progress: number }) {