From 7954c29482dc1c7b37abb10b5002216cb11df174 Mon Sep 17 00:00:00 2001 From: Kevin Raoofi Date: Wed, 28 Oct 2020 05:21:11 -0400 Subject: [PATCH] Watch-on-Lbry button * Created a content script for YouTube that injects a styled button * Automatically pause the video when redirecting to the app The button location is rather finicky as certain polymer components seem to move around, causing random DOM elements to appear all over the place if it's not a "singleton" component. squash --- package.json | 2 +- src/manifest.json | 13 ++++- src/scripts/tabOnUpdated.ts | 52 +++++++---------- src/scripts/ytContent.tsx | 108 ++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 src/scripts/ytContent.tsx diff --git a/package.json b/package.json index 88e583d..b55d925 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "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 --no-minify \"src/scripts/*.{js,ts}\" \"src/**/*.html\"", - "watch:parcel": "parcel watch --no-hmr --no-source-maps \"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/manifest.json b/src/manifest.json index 4cb0da7..42132e7 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", 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..3d65f95 --- /dev/null +++ b/src/scripts/ytContent.tsx @@ -0,0 +1,108 @@ +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
+ +
+} + + +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)); +});