/* content script running on youtube.com */ 'use strict'; const downloadIcon = ` `; const checkmarkIcon = ` `; const defaultIcon = `minus-thick`; let browserType = getBrowser(); // boilerplate to dedect browser type api function getBrowser() { if (typeof chrome !== 'undefined') { if (typeof browser !== 'undefined') { console.log('detected firefox'); return browser; } else { console.log('detected chrome'); return chrome; } } else { console.log('failed to dedect browser'); throw 'browser detection error'; } } function getChannelContainers() { const elements = document.querySelectorAll('.yt-flexible-actions-view-model-wiz, #owner'); const channelContainerNodes = []; elements.forEach(element => { if (isElementVisible(element)) { channelContainerNodes.push(element); } }); return channelContainerNodes; } function isElementVisible(element) { return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0; } function ensureTALinks() { let channelContainerNodes = getChannelContainers(); for (let channelContainer of channelContainerNodes) { channelContainer = adjustOwner(channelContainer); if (channelContainer.hasTA) continue; let channelButton = buildChannelButton(channelContainer); channelContainer.appendChild(channelButton); channelContainer.hasTA = true; } let titleContainerNodes = getTitleContainers(); for (let titleContainer of titleContainerNodes) { let parent = getNearestH3(titleContainer); if (!parent) continue; if (parent.hasTA) continue; let videoButton = buildVideoButton(titleContainer); if (videoButton == null) continue; processTitle(parent); parent.appendChild(videoButton); parent.hasTA = true; } } ensureTALinks = throttled(ensureTALinks, 700); function adjustOwner(channelContainer) { return channelContainer.querySelector('#buttons') || channelContainer; } function buildChannelButton(channelContainer) { let channelHandle = getChannelHandle(channelContainer); channelContainer.taDerivedHandle = channelHandle; let buttonDiv = buildChannelButtonDiv(); let channelSubButton = buildChannelSubButton(channelHandle); buttonDiv.appendChild(channelSubButton); channelContainer.taSubButton = channelSubButton; let spacer = buildSpacer(); buttonDiv.appendChild(spacer); let channelDownloadButton = buildChannelDownloadButton(); buttonDiv.appendChild(channelDownloadButton); channelContainer.taDownloadButton = channelDownloadButton; if (!channelContainer.taObserver) { function updateButtonsIfNecessary() { let newHandle = getChannelHandle(channelContainer); if (channelContainer.taDerivedHandle === newHandle) return; console.log(`updating handle from ${channelContainer.taDerivedHandle} to ${newHandle}`); channelContainer.taDerivedHandle = newHandle; let channelSubButton = buildChannelSubButton(newHandle); channelContainer.taSubButton.replaceWith(channelSubButton); channelContainer.taSubButton = channelSubButton; let channelDownloadButton = buildChannelDownloadButton(); channelContainer.taDownloadButton.replaceWith(channelDownloadButton); channelContainer.taDownloadButton = channelDownloadButton; } channelContainer.taObserver = new MutationObserver(throttled(updateButtonsIfNecessary, 100)); channelContainer.taObserver.observe(channelContainer, { attributes: true, childList: true, subtree: true, }); } return buttonDiv; } function getChannelHandle(channelContainer) { let channelHandle; const videoOwnerRenderer = channelContainer.querySelector('.ytd-video-owner-renderer'); if (!videoOwnerRenderer) { const channelHandleContainer = document.querySelector( '.yt-content-metadata-view-model-wiz__metadata-text' ); channelHandle = channelHandleContainer ? channelHandleContainer.innerText : null; } else { const href = videoOwnerRenderer.href; if (href) { const urlObj = new URL(href); channelHandle = urlObj.pathname.split('/')[1]; } } return channelHandle; } function buildChannelButtonDiv() { let buttonDiv = document.createElement('div'); buttonDiv.classList.add('ta-channel-button'); Object.assign(buttonDiv.style, { display: 'flex', alignItems: 'center', backgroundColor: '#00202f', color: '#fff', fontSize: '14px', padding: '5px', 'margin-left': '8px', borderRadius: '18px', }); return buttonDiv; } function buildChannelSubButton(channelHandle) { let channelSubButton = document.createElement('span'); channelSubButton.innerText = 'Checking...'; channelSubButton.title = `TA Subscribe: ${channelHandle}`; channelSubButton.setAttribute('data-id', channelHandle); channelSubButton.setAttribute('data-type', 'channel'); channelSubButton.addEventListener('click', e => { e.preventDefault(); if (channelSubButton.innerText === 'Subscribe') { console.log(`subscribe to: ${channelHandle}`); sendUrl(channelHandle, 'subscribe', channelSubButton); } else if (channelSubButton.innerText === 'Unsubscribe') { console.log(`unsubscribe from: ${channelHandle}`); sendUrl(channelHandle, 'unsubscribe', channelSubButton); } else { console.log('Unknown state'); } }); Object.assign(channelSubButton.style, { padding: '5px', cursor: 'pointer', }); checkChannelSubscribed(channelSubButton); return channelSubButton; } function checkChannelSubscribed(channelSubButton) { function handleResponse(message) { if (!message || (typeof message === 'object' && message.channel_subscribed === false)) { channelSubButton.innerText = 'Subscribe'; } else if (typeof message === 'object' && message.channel_subscribed === true) { channelSubButton.innerText = 'Unsubscribe'; } else { console.log('Unknown state'); } } function handleError(e) { buttonError(channelSubButton); channelSubButton.innerText = 'Error'; console.error('error', e); } let channelHandle = channelSubButton.dataset.id; let message = { type: 'getChannel', channelHandle }; let sending = sendMessage(message); sending.then(handleResponse, handleError); } function buildSpacer() { let spacer = document.createElement('span'); spacer.innerText = '|'; return spacer; } function buildChannelDownloadButton() { let channelDownloadButton = document.createElement('span'); let currentLocation = window.location.href; let urlObj = new URL(currentLocation); if (urlObj.pathname.startsWith('/watch')) { let params = new URLSearchParams(document.location.search); let videoId = params.get('v'); channelDownloadButton.setAttribute('data-type', 'video'); channelDownloadButton.setAttribute('data-id', videoId); channelDownloadButton.title = `TA download video: ${videoId}`; checkVideoExists(channelDownloadButton); } else { channelDownloadButton.setAttribute('data-id', currentLocation); channelDownloadButton.setAttribute('data-type', 'channel'); channelDownloadButton.title = `TA download channel ${currentLocation}`; } channelDownloadButton.innerHTML = downloadIcon; channelDownloadButton.addEventListener('click', e => { e.preventDefault(); console.log(`download: ${currentLocation}`); sendDownload(channelDownloadButton); }); Object.assign(channelDownloadButton.style, { filter: 'invert()', width: '20px', padding: '0 5px', cursor: 'pointer', }); return channelDownloadButton; } function getTitleContainers() { let elements = document.querySelectorAll('#video-title'); let videoNodes = []; elements.forEach(element => { if (isElementVisible(element)) { videoNodes.push(element); } }); return elements; } function getVideoId(titleContainer) { if (!titleContainer) return undefined; let href = getNearestLink(titleContainer); if (!href) return; let videoId; if (href.startsWith('/watch?v')) { let params = new URLSearchParams(href); videoId = params.get('/watch?v'); } else if (href.startsWith('/shorts/')) { videoId = href.split('/')[2]; } return videoId; } function buildVideoButton(titleContainer) { let videoId = getVideoId(titleContainer); if (!videoId) return; const dlButton = document.createElement('a'); dlButton.classList.add('ta-button'); dlButton.href = '#'; Object.assign(dlButton.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#00202f', color: '#fff', fontSize: '1.4rem', textDecoration: 'none', borderRadius: '8px', cursor: 'pointer', height: 'fit-content', opacity: 0, }); let dlIcon = document.createElement('span'); dlIcon.innerHTML = defaultIcon; Object.assign(dlIcon.style, { filter: 'invert()', width: '15px', height: '15px', padding: '7px 8px', }); dlButton.appendChild(dlIcon); dlButton.addEventListener('click', e => { e.preventDefault(); sendDownload(dlButton); e.stopPropagation(); }); return dlButton; } function getNearestLink(element) { // Check siblings let sibling = element; while (sibling) { sibling = sibling.previousElementSibling; if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') { return sibling.getAttribute('href'); } } sibling = element; while (sibling) { sibling = sibling.nextElementSibling; if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') { return sibling.getAttribute('href'); } } // Check parent elements for (let i = 0; i < 5 && element && element !== document; i++) { if (element.tagName === 'A' && element.getAttribute('href') !== '#') { return element.getAttribute('href'); } element = element.parentNode; } return null; } function getNearestH3(element) { for (let i = 0; i < 5 && element && element !== document; i++) { if (element.tagName === 'H3') { return element; } element = element.parentNode; } return null; } function processTitle(titleContainer) { if (titleContainer.hasListener) return; Object.assign(titleContainer.style, { display: 'flex', gap: '15px', }); titleContainer.classList.add('title-container'); titleContainer.addEventListener('mouseenter', () => { const taButton = titleContainer.querySelector('.ta-button'); if (!taButton) return; if (!taButton.isChecked) checkVideoExists(taButton); taButton.style.opacity = 1; }); titleContainer.addEventListener('mouseleave', () => { const taButton = titleContainer.querySelector('.ta-button'); if (!taButton) return; taButton.style.opacity = 0; }); titleContainer.hasListener = true; } function checkVideoExists(taButton) { function handleResponse(message) { let buttonSpan = taButton.querySelector('span') || taButton; if (message !== false) { buttonSpan.innerHTML = checkmarkIcon; buttonSpan.title = 'Open in TA'; buttonSpan.addEventListener('click', () => { let win = window.open(message, '_blank'); win.focus(); }); } else { buttonSpan.innerHTML = downloadIcon; } taButton.isChecked = true; } function handleError(e) { buttonError(taButton); let videoId = taButton.dataset.id; console.log(`error: failed to get info from TA for video ${videoId}`); console.error(e); } let videoId = taButton.dataset.id; if (!videoId) { videoId = getVideoId(taButton); if (videoId) { taButton.setAttribute('data-id', videoId); taButton.setAttribute('data-type', 'video'); taButton.title = `TA download video: ${taButton.parentElement.innerText} [${videoId}]`; } } let message = { type: 'videoExists', videoId }; let sending = sendMessage(message); sending.then(handleResponse, handleError); } function sendDownload(button) { let url = button.dataset.id; if (!url) return; sendUrl(url, 'download', button); } function buttonError(button) { let buttonSpan = button.querySelector('span'); if (buttonSpan === null) { buttonSpan = button; } buttonSpan.style.filter = 'invert(19%) sepia(93%) saturate(7472%) hue-rotate(359deg) brightness(105%) contrast(113%)'; buttonSpan.style.color = 'red'; button.style.opacity = 1; button.addEventListener('mouseout', () => { Object.assign(button.style, { opacity: 1, }); }); } function buttonSuccess(button) { let buttonSpan = button.querySelector('span'); if (buttonSpan === null) { buttonSpan = button; } if (buttonSpan.innerHTML === 'Subscribe') { buttonSpan.innerHTML = 'Success'; setTimeout(() => { buttonSpan.innerHTML = 'Unsubscribe'; }, 2000); } else { buttonSpan.innerHTML = checkmarkIcon; } } function sendUrl(url, action, button) { function handleResponse(message) { console.log('sendUrl response: ' + JSON.stringify(message)); if (message === null || message.detail === 'Invalid token.') { buttonError(button); } else { buttonSuccess(button); } } function handleError(e) { console.log('error', e); buttonError(button); } let message = { type: action, url }; console.log('youtube link: ' + JSON.stringify(message)); let sending = sendMessage(message); sending.then(handleResponse, handleError); } async function sendMessage(message) { let { success, value } = await browserType.runtime.sendMessage(message); if (!success) { throw value; } return value; } function cleanButtons() { console.log('trigger clean buttons'); document.querySelectorAll('.ta-button').forEach(button => { button.parentElement.hasTA = false; button.remove(); }); document.querySelectorAll('.ta-channel-button').forEach(button => { button.parentElement.hasTA = false; button.remove(); }); } let oldHref = document.location.href; function throttled(callback, time) { let throttleBlock = false; let lastArgs; return (...args) => { lastArgs = args; if (throttleBlock) return; throttleBlock = true; setTimeout(() => { throttleBlock = false; callback(...lastArgs); }, time); }; } let observer = new MutationObserver(list => { const currentHref = document.location.href; if (currentHref !== oldHref) { cleanButtons(); oldHref = currentHref; } if (list.some(i => i.type === 'childList' && i.addedNodes.length > 0)) { ensureTALinks(); } }); observer.observe(document.body, { attributes: false, childList: true, subtree: true });