mirror of
https://github.com/LBRYFoundation/Watch-on-LBRY.git
synced 2025-08-23 17:47:26 +00:00
Merge pull request #39 from Aenigma/feature/watch-on-lbry-button
Watch on LBRY button
This commit is contained in:
commit
991a37c367
10 changed files with 269 additions and 72 deletions
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
30
src/common/useSettings.ts
Normal 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);
|
|
@ -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
79
src/icons/lbry-logo.svg
Normal 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 |
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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
118
src/scripts/ytContent.tsx
Normal 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))
|
||||
});
|
Loading…
Add table
Reference in a new issue