Merge pull request #39 from Aenigma/feature/watch-on-lbry-button

Watch on LBRY button
This commit is contained in:
kodxana 2020-11-09 22:23:08 +01:00 committed by GitHub
commit 991a37c367
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 269 additions and 72 deletions

View file

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

View file

@ -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<K extends Array<keyof LbrySettings>>(...keys: K): Promise<Pick<LbrySettings, K[number]>> {
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<T extends object>(initial: T) {
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
// register change listeners, gets current values, and cleans up the listeners on unload
useEffect(() => {
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, 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<T>));
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);

View file

@ -9,6 +9,7 @@ body
text-align: center
background-color: $background-color
color: $text-color
font-family: sans-serif
.container
display: block

30
src/common/useSettings.ts Normal file
View file

@ -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<T extends object>(initial: T) {
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
// register change listeners, gets current values, and cleans up the listeners on unload
useEffect(() => {
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, 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<T>));
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);

View file

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

79
src/icons/lbry-logo.svg Normal file
View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="80.207558mm"
height="58.081333mm"
viewBox="0 0 284.20001 205.8"
id="svg3479"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="lbry-white-logo-only.svg">
<defs
id="defs3481" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-470.0429"
inkscape:cy="-5.6714247"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata3484">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-363.61433,-320.89078)">
<g
id="g3396"
transform="translate(363.61433,320.89078)">
<g
id="g3398">
<polygon
style="fill:#ffffff"
points="138.8,155.2 271,74 271,68.2 146.2,8 7,94.1 7,132.6 138.8,197.8 276.4,113.4 280.3,119.4 139.2,205.8 0,137 0,90.2 145.8,0 278,63.8 278,77.9 139.2,163.2 34.6,111.9 34.8,104 "
id="polygon3400" />
</g>
<g
id="g3402">
<polygon
style="fill:#ffffff"
points="276.5,128.5 278.5,115.9 266.3,113.8 267.1,108.9 284.2,111.8 281.4,129.3 "
id="polygon3404" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

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",
@ -24,7 +33,8 @@
},
"web_accessible_resources": [
"popup.html",
"tools/YTtoLBRY.html"
"tools/YTtoLBRY.html",
"icons/lbry-logo.svg"
],
"icons": {
"16": "icons/icon16.png",

View file

@ -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';

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

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

@ -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<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={<div>
<img src={chrome.runtime.getURL('icons/lbry-logo.svg')} height={10} width={14}
style={{ marginRight: 12, transform: 'scale(1.75)' }} />
Watch on LBRY
</div>}
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));
});
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'local' || !changes.redirect) return;
handle(new URL(location.href))
});