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
This commit is contained in:
Kevin Raoofi 2020-10-28 05:21:11 -04:00
parent 40036013c2
commit 7954c29482
4 changed files with 139 additions and 36 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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 });
});

108
src/scripts/ytContent.tsx Normal file
View file

@ -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<HTMLVideoElement>('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<void> {
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<HTMLDivElement | void> {
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 <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
<a href={url} onClick={pauseVideo} role='button' children='Watch on Lbry'
style={{
borderRadius: '2px',
backgroundColor: '#075656',
border: '0',
color: 'whitesmoke',
padding: '10px 16px',
marginRight: '5px',
fontSize: '14px',
textDecoration: 'none',
}} />
</div>
}
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(<WatchOnLbryButton url={url} />, 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(<WatchOnLbryButton />, mountPoint))
if (!req.url) return;
handle(new URL(req.url));
});