mirror of
https://github.com/LBRYFoundation/Watch-on-LBRY.git
synced 2025-08-23 17:47:26 +00:00
364 lines
No EOL
13 KiB
TypeScript
364 lines
No EOL
13 KiB
TypeScript
import { h, render } from 'preact'
|
|
import { parseYouTubeURLTimeString } from '../modules/yt'
|
|
import type { resolveById, ResolveUrlTypes } from '../modules/yt/urlResolve'
|
|
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, getTargetPlatfromSettingsEntiries, SourcePlatform, sourcePlatfromSettings, TargetPlatform, targetPlatformSettings } from '../settings';
|
|
|
|
(async () => {
|
|
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
|
|
|
|
interface Target {
|
|
platform: TargetPlatform
|
|
lbryPathname: string
|
|
type: ResolveUrlTypes
|
|
time: number | null
|
|
}
|
|
|
|
interface Source {
|
|
platform: SourcePlatform
|
|
id: string
|
|
type: ResolveUrlTypes
|
|
url: URL
|
|
time: number | null
|
|
}
|
|
|
|
const targetPlatforms = getTargetPlatfromSettingsEntiries()
|
|
const settings = await getExtensionSettingsAsync()
|
|
// Listen Settings Change
|
|
chrome.storage.onChanged.addListener(async (changes, areaName) => {
|
|
if (areaName !== 'local') return
|
|
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])))
|
|
})
|
|
|
|
const buttonMountPoint = document.createElement('div')
|
|
buttonMountPoint.style.display = 'inline-flex'
|
|
|
|
const playerButtonMountPoint = document.createElement('div')
|
|
playerButtonMountPoint.style.display = 'inline-flex'
|
|
|
|
function WatchOnLbryButton({ source, target }: { source?: Source, target?: Target }) {
|
|
if (!target || !source) return null
|
|
const url = getLbryUrlByTarget(target)
|
|
|
|
return <div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateRows: '36px',
|
|
gridAutoColumns: 'auto',
|
|
alignContent: 'center'
|
|
}}
|
|
>
|
|
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button'
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '7px',
|
|
borderRadius: '2px',
|
|
padding: '0 16px',
|
|
margin: '0 4px',
|
|
|
|
fontWeight: 'bold',
|
|
border: '0',
|
|
color: 'whitesmoke',
|
|
fontSize: '14px',
|
|
textDecoration: 'none',
|
|
backgroundColor: target.platform.theme,
|
|
backgroundImage: target.platform.theme,
|
|
...target.platform.button.style?.button,
|
|
}}
|
|
onClick={() => findVideoElementAwait(source).then((videoElement) => {
|
|
videoElement.pause()
|
|
})}
|
|
>
|
|
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
|
|
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
|
|
</a>
|
|
</div>
|
|
}
|
|
|
|
function WatchOnLbryPlayerButton({ source, target }: { source?: Source, target?: Target }) {
|
|
if (!target || !source) return null
|
|
const url = getLbryUrlByTarget(target)
|
|
|
|
return <div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateRows: '36px',
|
|
gridAutoColumns: 'auto',
|
|
alignContent: 'center'
|
|
}}
|
|
>
|
|
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button'
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '7px',
|
|
borderRadius: '2px',
|
|
paddingRight: '10px',
|
|
|
|
fontWeight: 'bold',
|
|
border: '0',
|
|
color: 'whitesmoke',
|
|
fontSize: '14px',
|
|
textDecoration: 'none',
|
|
...target.platform.button.style?.button,
|
|
}}
|
|
onClick={() => findVideoElementAwait(source).then((videoElement) => {
|
|
videoElement.pause()
|
|
})}
|
|
>
|
|
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
|
|
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
|
|
</a>
|
|
</div>
|
|
}
|
|
|
|
function updateButtons(params: { source: Source, target: Target } | null): void {
|
|
if (!params) {
|
|
render(<WatchOnLbryButton />, buttonMountPoint)
|
|
render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
|
|
return
|
|
}
|
|
|
|
{
|
|
const mountPlayerButtonBefore = settings.buttonVideoPlayer ?
|
|
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountPlayerButtonBefore) :
|
|
null
|
|
if (!mountPlayerButtonBefore) render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
|
|
else {
|
|
if (playerButtonMountPoint.getAttribute('data-id') !== params.source.id) {
|
|
mountPlayerButtonBefore.parentElement?.insertBefore(playerButtonMountPoint, mountPlayerButtonBefore)
|
|
playerButtonMountPoint.setAttribute('data-id', params.source.id)
|
|
}
|
|
render(<WatchOnLbryPlayerButton target={params.target} source={params.source} />, playerButtonMountPoint)
|
|
}
|
|
}
|
|
|
|
{
|
|
const mountButtonBefore = settings[(`button${params.source.type[0].toUpperCase() + params.source.type.substring(1)}Sub`) as 'buttonVideoSub' | 'buttonChannelSub'] ?
|
|
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountButtonBefore[params.source.type]) :
|
|
null
|
|
if (!mountButtonBefore) render(<WatchOnLbryButton />, buttonMountPoint)
|
|
else {
|
|
if (buttonMountPoint.getAttribute('data-id') !== params.source.id) {
|
|
mountButtonBefore.parentElement?.insertBefore(buttonMountPoint, mountButtonBefore)
|
|
buttonMountPoint.setAttribute('data-id', params.source.id)
|
|
}
|
|
render(<WatchOnLbryButton target={params.target} source={params.source} />, buttonMountPoint)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findVideoElementAwait(source: Source) {
|
|
let videoElement: HTMLVideoElement | null = null
|
|
while (!(videoElement = document.querySelector(source.platform.htmlQueries.videoPlayer))) await sleep(200)
|
|
return videoElement
|
|
}
|
|
|
|
async function getSourceByUrl(url: URL): Promise<Source | null> {
|
|
const platform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
|
|
if (!platform) return null
|
|
|
|
if (url.pathname === '/watch' && url.searchParams.has('v')) {
|
|
return {
|
|
id: url.searchParams.get('v')!,
|
|
platform,
|
|
time: url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null,
|
|
type: 'video',
|
|
url
|
|
}
|
|
}
|
|
else if (url.pathname.startsWith('/channel/')) {
|
|
return {
|
|
id: url.pathname.substring("/channel/".length),
|
|
platform,
|
|
time: null,
|
|
type: 'channel',
|
|
url
|
|
}
|
|
}
|
|
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/')) {
|
|
// We have to download the page content again because these parts of the page are not responsive
|
|
// yt front end sucks anyway
|
|
const content = await (await fetch(location.href)).text()
|
|
const prefix = `https://www.youtube.com/feeds/videos.xml?channel_id=`
|
|
const suffix = `"`
|
|
const startsAt = content.indexOf(prefix) + prefix.length
|
|
const endsAt = content.indexOf(suffix, startsAt)
|
|
const id = content.substring(startsAt, endsAt)
|
|
return {
|
|
id,
|
|
platform,
|
|
time: null,
|
|
type: 'channel',
|
|
url
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function getTargetsBySources(...sources: Source[]) {
|
|
const params: Parameters<typeof requestResolveById>[0] = sources.map((source) => ({ id: source.id, type: source.type }))
|
|
const platform = targetPlatformSettings[settings.targetPlatform]
|
|
|
|
const results = await requestResolveById(params) ?? []
|
|
const targets: Record<string, Target | null> = Object.fromEntries(
|
|
sources.map((source) => {
|
|
const result = results[source.id]
|
|
if (!result) return [
|
|
source.id,
|
|
null
|
|
]
|
|
|
|
return [
|
|
source.id,
|
|
{
|
|
type: result.type,
|
|
lbryPathname: result.id,
|
|
platform,
|
|
time: source.time
|
|
}
|
|
]
|
|
})
|
|
)
|
|
|
|
return targets
|
|
}
|
|
// We should get this from background, so the caching works and we don't get errors in the future if yt decides to impliment CORS
|
|
async function requestResolveById(...params: Parameters<typeof resolveById>): ReturnType<typeof resolveById> {
|
|
const response = await new Promise<string | null | 'error'>((resolve) => chrome.runtime.sendMessage({ method: 'resolveUrl', data: JSON.stringify(params) }, resolve))
|
|
if (response?.startsWith('error:')) {
|
|
console.error("Background error on:", params)
|
|
throw new Error(`Background error. ${response ?? ''}`)
|
|
}
|
|
console.log(response)
|
|
return response ? JSON.parse(response) : null
|
|
}
|
|
|
|
// Request new tab
|
|
async function openNewTab(url: URL) {
|
|
chrome.runtime.sendMessage({ method: 'openTab', data: JSON.stringify({ href: url.href }) })
|
|
}
|
|
|
|
function findTargetFromSourcePage(source: Source): Target | null {
|
|
const linksContainer =
|
|
source.type === 'video' ?
|
|
document.querySelector(source.platform.htmlQueries.videoDescription) :
|
|
source.platform.htmlQueries.channelLinks ? document.querySelector(source.platform.htmlQueries.channelLinks) : null
|
|
|
|
if (linksContainer) {
|
|
const anchors = Array.from(linksContainer.querySelectorAll<HTMLAnchorElement>('a'))
|
|
|
|
for (const anchor of anchors) {
|
|
if (!anchor.href) continue
|
|
const url = new URL(anchor.href)
|
|
let lbryURL: URL | null = null
|
|
|
|
// Extract real link from youtube's redirect link
|
|
if (source.platform === sourcePlatfromSettings['youtube.com']) {
|
|
if (!targetPlatforms.some(([key, platform]) => url.searchParams.get('q')?.startsWith(platform.domainPrefix))) continue
|
|
lbryURL = new URL(url.searchParams.get('q')!)
|
|
}
|
|
// Just directly use the link itself on other platforms
|
|
else {
|
|
if (!targetPlatforms.some(([key, platform]) => url.href.startsWith(platform.domainPrefix))) continue
|
|
lbryURL = new URL(url.href)
|
|
}
|
|
|
|
if (lbryURL) {
|
|
return {
|
|
lbryPathname: lbryURL.pathname.substring(1),
|
|
time: null,
|
|
type: lbryURL.pathname.substring(1).includes('/') ? 'video' : 'channel',
|
|
platform: targetPlatformSettings[settings.targetPlatform]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getLbryUrlByTarget(target: Target) {
|
|
const url = new URL(`${target.platform.domainPrefix}${target.lbryPathname}`)
|
|
if (target.time) url.searchParams.set('t', target.time.toFixed(0))
|
|
|
|
return url
|
|
}
|
|
|
|
|
|
// Master Loop
|
|
for (
|
|
let url = new URL(location.href),
|
|
urlHrefCache: string | null = null;
|
|
;
|
|
urlHrefCache = url.href,
|
|
url = new URL(location.href)
|
|
) {
|
|
await sleep(500)
|
|
try {
|
|
const source = await getSourceByUrl(new URL(location.href))
|
|
if (!source) {
|
|
updateButtons(null)
|
|
continue
|
|
}
|
|
const target = (await getTargetsBySources(source))[source.id] ?? findTargetFromSourcePage(source)
|
|
if (!target) {
|
|
updateButtons(null)
|
|
continue
|
|
}
|
|
|
|
// Update Buttons
|
|
if (urlHrefCache !== url.href) updateButtons(null)
|
|
// If target is a video target add timestampt to it
|
|
if (target.type === 'video') {
|
|
const videoElement = document.querySelector<HTMLVideoElement>(source.platform.htmlQueries.videoPlayer)
|
|
if (videoElement) target.time = videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null
|
|
}
|
|
updateButtons({ target, source })
|
|
|
|
// Redirect
|
|
if (
|
|
source.type === target.type &&
|
|
(
|
|
(
|
|
settings.redirectVideo &&
|
|
source.type === 'video' && !source.url.searchParams.has('list')
|
|
) ||
|
|
(
|
|
settings.redirectVideoPlaylist &&
|
|
source.type === 'video' && source.url.searchParams.has('list')
|
|
) ||
|
|
(
|
|
settings.redirectChannel &&
|
|
source.type === 'channel'
|
|
)
|
|
)
|
|
) {
|
|
if (url.href === urlHrefCache) continue
|
|
|
|
const lbryURL = getLbryUrlByTarget(target)
|
|
|
|
if (source.type === 'video') {
|
|
findVideoElementAwait(source).then((videoElement) => videoElement.pause())
|
|
}
|
|
|
|
if (target.platform === targetPlatformSettings.app) {
|
|
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
|
|
// Replace is being used so browser doesnt start an empty window
|
|
// Its not gonna be able to replace anyway, since its a LBRY Uri
|
|
location.replace(lbryURL)
|
|
}
|
|
else {
|
|
openNewTab(lbryURL)
|
|
if (window.history.length === 1)
|
|
window.close()
|
|
else
|
|
window.history.back()
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
})() |