diff --git a/package.json b/package.json index f64f754..4ec6e97 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "license": "GPL-3.0", "scripts": { "build:assets": "cpx \"src/**/*.{json,png}\" dist/", - "watch:assets": "cpx --watch -v \"src/**/*.{json,png}\" dist/", - "build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps \"src/scripts/*.{js,ts}\" \"src/**/*.html\"", - "watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,ts}\" \"src/**/*.html\"", + "watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/", + "build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/scripts/*.{js,ts}\" \"src/**/*.html\"", + "watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"", "build": "npm-run-all -l -p build:parcel build:assets", "watch": "npm-run-all -l -p watch:parcel watch:assets", "start:chrome": "web-ext run -t chromium --source-dir ./dist", diff --git a/src/common/settings.ts b/src/common/settings.ts index 7e039af..f333767 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -1,5 +1,3 @@ -import { useEffect, useReducer } from 'preact/hooks' - export interface LbrySettings { enabled: boolean redirect: keyof typeof redirectDomains @@ -16,31 +14,3 @@ export const redirectDomains = { export function getSettingsAsync>(...keys: K): Promise> { return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any))); } - -/** - * A hook to read the settings from local storage - * - * @param initial the default value. Must have all relevant keys present and should not change - */ -export function useSettings(initial: T) { - const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial); - // register change listeners, gets current values, and cleans up the listeners on unload - useEffect(() => { - const changeListener = (changes: Record, 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)); - }; - chrome.storage.onChanged.addListener(changeListener); - chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)); - return () => chrome.storage.onChanged.removeListener(changeListener); - }, []); - - return state; -} - -/** A hook to read watch on lbry settings from local storage */ -export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS); diff --git a/src/common/style.sass b/src/common/style.sass index 9347c50..960e230 100644 --- a/src/common/style.sass +++ b/src/common/style.sass @@ -9,6 +9,7 @@ body text-align: center background-color: $background-color color: $text-color + font-family: sans-serif .container display: block diff --git a/src/common/useSettings.ts b/src/common/useSettings.ts new file mode 100644 index 0000000..b1d9113 --- /dev/null +++ b/src/common/useSettings.ts @@ -0,0 +1,30 @@ +import { useReducer, useEffect } from 'preact/hooks'; +import { DEFAULT_SETTINGS } 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 + */ +export function useSettings(initial: T) { + const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial); + // register change listeners, gets current values, and cleans up the listeners on unload + useEffect(() => { + const changeListener = (changes: Record, 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)); + }; + chrome.storage.onChanged.addListener(changeListener); + chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)); + return () => chrome.storage.onChanged.removeListener(changeListener); + }, []); + + return state; + } + + /** A hook to read watch on lbry settings from local storage */ + export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS); diff --git a/src/common/yt.ts b/src/common/yt.ts index 6a38534..9ee3e53 100644 --- a/src/common/yt.ts +++ b/src/common/yt.ts @@ -1,4 +1,6 @@ -import { chunk, groupBy, pickBy } from 'lodash'; +import chunk from 'lodash/chunk'; +import groupBy from 'lodash/groupBy'; +import pickBy from 'lodash/pickBy'; const LBRY_API_HOST = 'https://api.lbry.com'; const QUERY_CHUNK_SIZE = 300; @@ -87,7 +89,7 @@ export const ytService = { video_ids: groups['video']?.map(s => s.id).join(','), channel_ids: groups['channel']?.map(s => s.id).join(','), })); - return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`) + return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`, {cache: 'force-cache'}) .then(rsp => rsp.ok ? rsp.json() : null); })); diff --git a/src/icons/lbry-logo.svg b/src/icons/lbry-logo.svg new file mode 100644 index 0000000..9f2352f --- /dev/null +++ b/src/icons/lbry-logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/src/manifest.json b/src/manifest.json index 4cb0da7..b17d426 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,8 +2,7 @@ "name": "Watch on LBRY", "version": "1.6.0", "permissions": [ - "https://www.youtube.com/watch?v=*", - "https://www.youtube.com/channel/*", + "https://www.youtube.com/", "https://invidio.us/channel/*", "https://invidio.us/watch?v=*", "https://api.lbry.com/*", @@ -11,6 +10,16 @@ "tabs", "storage" ], + "content_scripts": [ + { + "matches": [ + "https://www.youtube.com/*" + ], + "js": [ + "scripts/ytContent.js" + ] + } + ], "background": { "scripts": [ "scripts/storageSetup.js", @@ -24,7 +33,8 @@ }, "web_accessible_resources": [ "popup.html", - "tools/YTtoLBRY.html" + "tools/YTtoLBRY.html", + "icons/lbry-logo.svg" ], "icons": { "16": "icons/icon16.png", diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index 5f350a4..9874700 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -1,7 +1,8 @@ import { h, render } from 'preact'; import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'; -import { redirectDomains, useLbrySettings } from '../common/settings'; +import { redirectDomains } from '../common/settings'; +import { useLbrySettings } from '../common/useSettings'; import './popup.sass'; diff --git a/src/scripts/tabOnUpdated.ts b/src/scripts/tabOnUpdated.ts index bb75ef9..4570d1a 100644 --- a/src/scripts/tabOnUpdated.ts +++ b/src/scripts/tabOnUpdated.ts @@ -1,43 +1,29 @@ -import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'; -import { getSettingsAsync, redirectDomains } from '../common/settings'; -import { ytService } from '../common/yt'; +import { appRedirectUrl } from '../common/lbry-url'; +import { getSettingsAsync } from '../common/settings'; -function openApp(tabId: number, url: string) { +// 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://lbry.tv/')) return; + + const url = appRedirectUrl(tabUrl, { encode: true }); + if (!url) return; chrome.tabs.update(tabId, { url }); alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed - // Close tab if it lacks history and go back if it does chrome.tabs.executeScript(tabId, { code: `if (window.history.length === 1) { window.close(); } else { window.history.back(); - }` + } + document.querySelectorAll('video').forEach(v => v.pause()); + ` }); -} - -async function resolveYT(ytUrl: string) { - const descriptor = ytService.getId(ytUrl); - if (!descriptor) return; // can't parse YT url; may not be one - - 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('/'); -} - -chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => { - const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect'); - const urlPrefix = redirectDomains[redirect].prefix; - - if (!enabled || !changeInfo.url || !tabUrl) return; - - const url = redirect === 'app' && tabUrl.match(/\b(https:\/\/lbry.tv|lbry:\/\/)/g) ? appRedirectUrl(tabUrl, { encode: true }) - : await resolveYT(tabUrl); - - if (!url) return; - if (redirect === 'app') { - openApp(tabId, urlPrefix + url); - return; - } - chrome.tabs.executeScript(tabId, { code: `location.replace("${urlPrefix + url}")` }); +}); + +// relay youtube link changes to the content script +chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => { + if (!changeInfo.url || !url || + !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return; + chrome.tabs.sendMessage(tabId, { url }); }); diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx new file mode 100644 index 0000000..5c4fbe0 --- /dev/null +++ b/src/scripts/ytContent.tsx @@ -0,0 +1,118 @@ +import { h, render } from 'preact'; + +import { parseProtocolUrl } from '../common/lbry-url'; +import { getSettingsAsync, LbrySettings, redirectDomains } from '../common/settings'; +import { YTDescriptor, ytService } from '../common/yt'; + +interface UpdaterOptions { + /** invoked if a redirect should be performed */ + onRedirect?(ctx: UpdateContext): void + /** invoked if a URL is found */ + onURL?(ctx: UpdateContext): void +} + +interface UpdateContext { + descriptor: YTDescriptor + url: string + enabled: boolean + redirect: LbrySettings['redirect'] +} + +function pauseVideo() { document.querySelectorAll('video').forEach(v => v.pause()); } + +function openApp(url: string) { + pauseVideo(); + location.assign(url); +} + +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('/'); +} + +/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */ +async function handleURLChange(url: URL | Location, { onRedirect, onURL }: UpdaterOptions): Promise { + const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect'); + const urlPrefix = redirectDomains[redirect].prefix; + const descriptor = ytService.getId(url.href); + if (!descriptor) return; // couldn't get the ID, so we're done + const res = await resolveYT(descriptor); + if (!res) return; // couldn't find it on lbry, so we're done + + const ctx = { descriptor, url: urlPrefix + res, enabled, redirect }; + if (onURL) onURL(ctx); + if (enabled && onRedirect) onRedirect(ctx); +} + +/** 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({ url }: { url?: string }) { + if (!url) return null; + return } + style={{ + borderRadius: '2px', + backgroundColor: '#075656', + border: '0', + color: 'whitesmoke', + padding: '10px 16px', + marginRight: '5px', + fontSize: '14px', + textDecoration: 'none', + }} /> + +} + + +const mountPointPromise = findMountPoint(); + +const handle = (url: URL | Location) => handleURLChange(url, { + async onURL({ descriptor: { type }, url }) { + const mountPoint = await mountPointPromise; + if (type !== 'video' || !mountPoint) return; + render(, mountPoint) + }, + onRedirect({ redirect, url }) { + if (redirect === 'app') return openApp(url); + location.replace(url); + }, +}); + +// handle the location on load of the page +handle(location); + +/* + * 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 (req: { url: string }) => { + mountPointPromise.then(mountPoint => mountPoint && render(, mountPoint)) + if (!req.url) return; + handle(new URL(req.url)); +}); + +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local' || !changes.redirect) return; + handle(new URL(location.href)) +});