diff --git a/.gitignore b/.gitignore index a228be6..5aa10a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ dist node_modules web-ext-artifacts +yarn-error.log +.devcontainer .DS_Store diff --git a/src/common/settings.ts b/src/common/settings.ts index 795a95a..dfb4ea4 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -1,16 +1,28 @@ -export interface LbrySettings { - enabled: boolean - redirect: keyof typeof redirectDomains +export type PlatformName = 'madiator.com' | 'odysee' | 'app' + +export interface PlatformSettings +{ + domainPrefix: string + display: string } -export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, redirect: 'lbry.tv' }; - -export const redirectDomains = { - 'madiator.com': { prefix: 'https://madiator.com/', display: 'madiator.com' }, - odysee: { prefix: 'https://odysee.com/', display: 'odysee' }, - app: { prefix: 'lbry://', display: 'App' }, +export const platformSettings: Record = { + 'madiator.com': { domainPrefix: 'https://madiator.com/', display: 'madiator.com' }, + odysee: { domainPrefix: 'https://odysee.com/', display: 'odysee' }, + app: { domainPrefix: 'lbry://', display: 'App' }, }; +export const getPlatfromSettingsEntiries = () => { + return Object.entries(platformSettings) as any as [Extract, PlatformSettings][] +} + +export interface LbrySettings { + enabled: boolean + platform: PlatformName +} + +export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, platform: 'odysee' }; + export function getSettingsAsync>(...keys: K): Promise> { return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any))); } diff --git a/src/common/yt.ts b/src/common/yt.ts index c72565b..1e2f08d 100644 --- a/src/common/yt.ts +++ b/src/common/yt.ts @@ -57,7 +57,7 @@ export const ytService = { */ readOpml(opmlContents: string): string[] { const opml = new DOMParser().parseFromString(opmlContents, 'application/xml'); - + opmlContents = '' return Array.from(opml.querySelectorAll('outline > outline')) .map(outline => outline.getAttribute('xmlUrl')) .filter((url): url is string => !!url) @@ -73,9 +73,22 @@ export const ytService = { */ readJson(jsonContents: string): string[] { const subscriptions: YtSubscription[] = JSON.parse(jsonContents); + jsonContents = '' return subscriptions.map(sub => sub.snippet.resourceId.channelId); }, + /** + * Reads an array of YT channel IDs from the YT subscriptions CSV file + * + * @param csvContent a CSV file as a string + * @returns the channel IDs + */ + readCsv(csvContent: string): string[] { + const rows = csvContent.split('\n') + csvContent = '' + return rows.map((row) => row.substr(0, row.indexOf(','))) + }, + /** * Extracts the channelID from a YT URL. * diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index 9874700..c7ef962 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -1,28 +1,28 @@ -import { h, render } from 'preact'; +import { h, render } from 'preact' +import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio' +import { getPlatfromSettingsEntiries, LbrySettings, PlatformName } from '../common/settings' +import { useLbrySettings } from '../common/useSettings' +import './popup.sass' -import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'; -import { redirectDomains } from '../common/settings'; -import { useLbrySettings } from '../common/useSettings'; -import './popup.sass'; /** Utilty to set a setting in the browser */ -const setSetting = (setting: string, value: any) => chrome.storage.local.set({ [setting]: value }); +const setSetting = (setting: K, value: LbrySettings[K]) => chrome.storage.local.set({ [setting]: value }); /** Gets all the options for redirect destinations as selection options */ -const redirectOptions: SelectionOption[] = Object.entries(redirectDomains) +const platformOptions: SelectionOption[] = getPlatfromSettingsEntiries() .map(([value, { display }]) => ({ value, display })); function WatchOnLbryPopup() { - const { enabled, redirect } = useLbrySettings(); + const { enabled, platform } = useLbrySettings(); return
setSetting('enabled', enabled.toLowerCase() === 'yes')} /> - setSetting('redirect', redirect)} /> + setSetting('platform', platform)} /> diff --git a/src/scripts/tabOnUpdated.ts b/src/scripts/tabOnUpdated.ts index 053d919..aeb8316 100644 --- a/src/scripts/tabOnUpdated.ts +++ b/src/scripts/tabOnUpdated.ts @@ -1,12 +1,12 @@ -import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'; -import { getSettingsAsync, LbrySettings } from '../common/settings'; -import { YTDescriptor, ytService } from '../common/yt'; +import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url' +import { getSettingsAsync, PlatformName } from '../common/settings' +import { YTDescriptor, ytService } from '../common/yt' export interface UpdateContext { descriptor: YTDescriptor /** LBRY URL fragment */ - url: string + pathname: string enabled: boolean - redirect: LbrySettings['redirect'] + platform: PlatformName } async function resolveYT(descriptor: YTDescriptor) { @@ -16,26 +16,26 @@ async function resolveYT(descriptor: YTDescriptor) { return segments.join('/'); } -const urlCache: Record = {}; +const pathnameCache: Record = {}; async function ctxFromURL(url: string): Promise { if (!url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return; url = new URL(url).href; - const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect'); + const { enabled, platform } = await getSettingsAsync('enabled', 'platform'); const descriptor = ytService.getId(url); if (!descriptor) return; // couldn't get the ID, so we're done - const res = url in urlCache ? urlCache[url] : await resolveYT(descriptor); - urlCache[url] = res; + const res = url in pathnameCache ? pathnameCache[url] : await resolveYT(descriptor); + pathnameCache[url] = res; if (!res) return; // couldn't find it on lbry, so we're done - return { descriptor, url: res, enabled, redirect }; + return { descriptor, pathname: res, enabled, platform }; } // handles lbry.tv -> lbry app redirect chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => { - const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect'); - if (!enabled || redirect !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return; + const { enabled, platform } = await getSettingsAsync('enabled', 'platform'); + if (!enabled || platform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return; const url = appRedirectUrl(tabUrl, { encode: true }); if (!url) return; diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx index 814aa22..a53d45e 100644 --- a/src/scripts/ytContent.tsx +++ b/src/scripts/ytContent.tsx @@ -1,16 +1,8 @@ -import { h, JSX, render } from 'preact'; +import { PlatformName, platformSettings } from '../common/settings' +import type { UpdateContext } from '../scripts/tabOnUpdated' +import { h, JSX, render } from 'preact' -import { parseProtocolUrl } from '../common/lbry-url'; -import { LbrySettings, redirectDomains } from '../common/settings'; -import { YTDescriptor, ytService } from '../common/yt'; -import { UpdateContext } from './tabOnUpdated'; - -interface UpdaterOptions { - /** invoked if a redirect should be performed */ - onRedirect?(ctx: UpdateContext): void - /** invoked if a URL is found */ - onURL?(ctx: UpdateContext): void -} +const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)); interface ButtonSettings { text: string @@ -18,57 +10,38 @@ interface ButtonSettings { style?: JSX.CSSProperties } -const buttonSettings: Record = { - app: { text: 'Watch on LBRY', icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') }, - 'madiator.com': { text: 'Watch on LBRY', icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') }, +const buttonSettings: Record = { + app: { + text: 'Watch on LBRY', + icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') + }, + 'madiator.com': { + text: 'Watch on LBRY', + icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') + }, odysee: { text: 'Watch on Odysee', icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg'), style: { backgroundColor: '#1e013b' }, }, }; -function pauseVideo() { document.querySelectorAll('video').forEach(v => v.pause()); } - -function openApp(url: string) { - pauseVideo(); - location.assign(url); +interface ButtonParameters +{ + platform?: PlatformName + pathname?: string + time?: number } -async function resolveYT(descriptor: YTDescriptor) { - const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]); - const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true }); - if (segments.length === 0) return; - return segments.join('/'); -} +export function WatchOnLbryButton({ platform = 'app', pathname, time }: ButtonParameters) { + if (!pathname || !platform) return null; + const platformSetting = platformSettings[platform]; + const buttonSetting = buttonSettings[platform]; -/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */ -async function handleURLChange(ctx: UpdateContext, { onRedirect, onURL }: UpdaterOptions): Promise { - if (onURL) onURL(ctx); - if (ctx.enabled && onRedirect) onRedirect(ctx); -} + const url = new URL(`${platformSetting.domainPrefix}${pathname}`) + if (time) url.searchParams.append('t', time.toFixed(0)) -/** Returns a mount point for the button */ -async function findMountPoint(): Promise { - const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)); - let ownerBar = document.querySelector('ytd-video-owner-renderer'); - for (let i = 0; !ownerBar && i < 50; i++) { - await sleep(200); - ownerBar = document.querySelector('ytd-video-owner-renderer'); - } - - if (!ownerBar) return; - const div = document.createElement('div'); - div.style.display = 'flex'; - ownerBar.insertAdjacentElement('afterend', div); - return div; -} - -function WatchOnLbryButton({ redirect = 'app', url }: { redirect?: LbrySettings['redirect'], url?: string }) { - if (!url) return null; - const domain = redirectDomains[redirect]; - const buttonSetting = buttonSettings[redirect]; return ; } +let mountPoint: HTMLDivElement | null = null +/** Returns a mount point for the button */ +async function findButtonMountPoint(): Promise { + let ownerBar = document.querySelector('ytd-video-owner-renderer'); + for (let i = 0; !ownerBar && i < 50; i++) { + await sleep(200); + ownerBar = document.querySelector('ytd-video-owner-renderer'); + } -const mountPointPromise = findMountPoint(); + if (!ownerBar) return; + const div = document.createElement('div'); + div.style.display = 'flex'; + ownerBar.insertAdjacentElement('afterend', div); -const handle = (ctx: UpdateContext) => handleURLChange(ctx, { - async onURL({ descriptor: { type }, url, redirect }) { - const mountPoint = await mountPointPromise; - if (type !== 'video' || !mountPoint) return; - render(, mountPoint); - }, - onRedirect({ redirect, url }) { - const domain = redirectDomains[redirect]; - if (redirect === 'app') return openApp(domain.prefix + url); - location.replace(domain.prefix + url); - }, -}); + mountPoint = div +} -// handle the location on load of the page -chrome.runtime.sendMessage({ url: location.href }, async (ctx: UpdateContext) => handle(ctx)); +let videoElement: HTMLVideoElement | null = null; +async function findVideoElement() { + while(!(videoElement = document.querySelector('#ytd-player video'))) await sleep(200) + videoElement.addEventListener('timeupdate', () => updateButton(ctxCache)) +} + +function pauseVideo() { document.querySelectorAll('video').forEach(v => v.pause()); } + +function openApp(url: string) { + pauseVideo(); + location.assign(url); +} + +/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */ +let ctxCache: UpdateContext | null = null +function handleURLChange (ctx: UpdateContext | null) { + ctxCache = ctx + updateButton(ctx) + if (ctx?.enabled) redirectTo(ctx) +} + +function updateButton(ctx: UpdateContext | null) { + if (!mountPoint) return + if (!ctx) return render(, mountPoint) + if (ctx.descriptor.type !== 'video') return; + const time = videoElement?.currentTime ?? 0 + const pathname = ctx.pathname + const platform = ctx.platform + + render(, mountPoint) +} + +function redirectTo({ platform, pathname }: UpdateContext) { + + 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 platformSetting = platformSettings[platform]; + const url = new URL(`${platformSetting.domainPrefix}${pathname}`) + const time = new URL(location.href).searchParams.get('t') + + if (time) url.searchParams.append('t', parseYouTubeTime(time)) + + if (platform === 'app') return openApp(url.toString()); + location.replace(url.toString()); +} + + + +findButtonMountPoint().then(() => updateButton(ctxCache)) +findVideoElement().then(() => updateButton(ctxCache)) + + +/** Request UpdateContext from background */ +const requestCtxFromUrl = async (url: string) => await new Promise((resolve) => chrome.runtime.sendMessage({ url }, resolve)) + +/** Handle the location on load of the page */ +requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx)) /* * Gets messages from background script which relays tab update events. This is because there's no sensible way to detect * history.pushState changes from a content script */ -chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => { - mountPointPromise.then(mountPoint => mountPoint && render(, mountPoint)) - if (!ctx.url) return; - handle(ctx); -}); +chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChange(ctx)); -chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== 'local' || !changes.redirect) return; - chrome.runtime.sendMessage({ url: location.href }, async (ctx: UpdateContext) => handle(ctx)); -}); +/** On settings change */ +chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName !== 'local') return; + if (changes.platform) handleURLChange(await requestCtxFromUrl(location.href)) +}); \ No newline at end of file diff --git a/src/tools/YTtoLBRY.tsx b/src/tools/YTtoLBRY.tsx index f998af5..107c30d 100644 --- a/src/tools/YTtoLBRY.tsx +++ b/src/tools/YTtoLBRY.tsx @@ -1,10 +1,10 @@ -import { Fragment, h, JSX, render } from 'preact'; -import { useState } from 'preact/hooks'; +import { h, render } from 'preact' +import { useState } from 'preact/hooks' +import { getSettingsAsync, platformSettings } from '../common/settings' +import { getFileContent, ytService } from '../common/yt' +import readme from './README.md' -import { getSettingsAsync, redirectDomains } from '../common/settings'; -import { getFileContent, ytService } from '../common/yt'; -import readme from './README.md'; /** * Parses the subscription file and queries the API for lbry channels @@ -14,12 +14,14 @@ import readme from './README.md'; */ async function lbryChannelsFromFile(file: File) { const ext = file.name.split('.').pop()?.toLowerCase(); - const content = await getFileContent(file); - - const ids = new Set((ext === 'xml' || ext == 'opml' ? ytService.readOpml(content) : ytService.readJson(content))) + + const ids = new Set(( + ext === 'xml' || ext == 'opml' ? ytService.readOpml : + ext === 'csv' ? ytService.readCsv : + ytService.readJson)(await getFileContent(file))) const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const))); - const { redirect } = await getSettingsAsync('redirect'); - const urlPrefix = redirectDomains[redirect].prefix; + const { platform } = await getSettingsAsync('platform'); + const urlPrefix = platformSettings[platform].domainPrefix; return lbryUrls.map(channel => urlPrefix + channel); }