You are on page 1of 14

// ==UserScript==

// @name Local YouTube Downloader


// @name:zh-TW ?? YouTube ???
// @name:zh-HK ?? YouTube ???
// @name:zh-CN ?? YouTube ???
// @name:ja ???? YouTube ???????
// @name:kr ?? YouTube ????
// @namespace https://blog.maple3142.net/
// @version 0.9.33
// @description Download YouTube videos without external service.
// @description:zh-TW ????????????? YouTube ???
// @description:zh-HK ????????????? YouTube ???
// @description:zh-CN ??????????????? YouTube ???
// @description:ja ????????? YouTube ?????????
// @description:kr ?? ????? YouTube ???? ????
// @description:fr Obtenez un lien brut YouTube sans service externe.
// @author maple3142
// @match https://*.youtube.com/*
// @require https://unpkg.com/vue@2.6.10/dist/vue.js
// @require https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js
// @require https://unpkg.com/@ffmpeg/ffmpeg@0.6.1/dist/ffmpeg.min.js
// @require https://bundle.run/p-queue@6.3.0
// @grant GM_xmlhttpRequest
// @connect googlevideo.com
// @compatible firefox >=52
// @compatible chrome >=55
// @license MIT
// ==/UserScript==

;(function () {
'use strict'
const DEBUG = true
const RESTORE_ORIGINAL_TITLE_FOR_CURRENT_VIDEO = true
const createLogger = (console, tag) =>
Object.keys(console)
.map(k => [k, (...args) => (DEBUG ? console[k](tag + ': ' +
args[0], ...args.slice(1)) : void 0)])
.reduce((acc, [k, fn]) => ((acc[k] = fn), acc), {})
const logger = createLogger(console, 'YTDL')
const sleep = ms => new Promise(res => setTimeout(res, ms))

const LANG_FALLBACK = 'en'


const LOCALE = {
en: {
togglelinks: 'Show/Hide Links',
stream: 'Stream',
adaptive: 'Adaptive',
videoid: 'Video ID: ',
inbrowser_adaptive_merger: 'Online Adaptive Video & Audio Merger
(FFmpeg)',
dlmp4: 'Download high-resolution mp4 in one click',
get_video_failed:
'You seems to have ad-blocking extension installed, which
blocks %s.\nPlease add the following rule to the rule set, or it will prevent Local
YouTube Downloader from working.\n\nP.S.: If adding of the rule is being refused,
you should uninstall it and use �uBlock Origin� instead.\nIf you still don�t
understand what I am saying, just disable or uninstall all your ad-blockers...',
live_stream_disabled_message: 'Local YouTube Downloader is not
available for live stream'
},
'zh-tw': {
togglelinks: '?? / ????',
stream: '?? Stream',
adaptive: '??? Adaptive',
videoid: '?? ID: ',
inbrowser_adaptive_merger: '?????????????? (FFmpeg)',
dlmp4: '??????? mp4',
get_video_failed:
'????????????? %s ?????\n?????????????????,????
YouTube ??????????\n\nPS: ?????????,?????????? "uBlock
Origin"?\n?????????????????,????????????????????',
live_stream_disabled_message: '????????,?? YouTube ???????????'
},
'zh-hk': {
togglelinks: '??/????',
stream: '?? Stream',
adaptive: '???? Adaptive',
videoid: '?? ID: ',
inbrowser_adaptive_merger: '??????????????? (FFmpeg)',
dlmp4: '??click?????? mp4',
get_video_failed:
'????????????????? %s?\n?????????????????,????
YouTube ???????????\n\nP.S.: ?????????,?????????????????uBlock
Origin??\n???????????,???????????????????',
live_stream_disabled_message: '?? YouTube ??????????'
},
zh: {
togglelinks: '??/????',
stream: '?? Stream',
adaptive: '??? Adaptive',
videoid: '?? ID: ',
inbrowser_adaptive_merger: '?????????????? (FFmpeg)',
dlmp4: '??????? mp4',
get_video_failed:
'????????????????,??? %s ?????\n?????????????????,????
YouTube ??????????\n\nP.S.: ????????,??????????�uBlock
Origin�?\n?????????????,????????????????????...',
live_stream_disabled_message: '?????,?? YouTube ???????????'
},
ja: {
togglelinks: '?????�???',
stream: '???????',
adaptive: '??????',
videoid: '??? ID: ',
inbrowser_adaptive_merger: '?????????????????????? (FFmpeg)',
dlmp4: '???????????? mp4 ???????',
get_video_failed:
'%s ??????????????????????????????????\n???????????????????
??????????????? YouTube ??????????????????\n\nP.S.: ???????????????????????????
uBlock Origin???????????????\n???????????????????????????????????????????????',
live_stream_disabled_message: '?????????????
YouTube ??????????????????'
},
kr: {
togglelinks: '?? ??? � ???',
stream: '????',
adaptive: '?? (????)',
videoid: '??? ID: ',
inbrowser_adaptive_merger: '???? ???? ???? ??? ?? (FFmpeg)',
dlmp4: '? ?? ???? ???? mp4 ????',
get_video_failed:
'%s ? ???? ?? ?? ?? ??? ?????? ? ????.\n??? ??? ?? ??? ????
??. ???? ??? ?? YouTube ????? ???? ????.\n\nP.S.: ??? ??? ?? ? ?? ???? "uBlock
Origin"? ?? ??????.\n??? ??? ??? ?? ?? ?? ???? ??????? ??????.'
},
es: {
togglelinks: 'Mostrar/Ocultar Links',
stream: 'Stream',
adaptive: 'Adaptable',
videoid: 'Id del Video: ',
inbrowser_adaptive_merger: 'Acoplar Audio a Video (FFmpeg)'
},
he: {
togglelinks: '???/???? ???????',
stream: '?????',
adaptive: '???????',
videoid: '???? ?????: '
},
fr: {
togglelinks: 'Afficher/Masquer les liens',
stream: 'Stream',
adaptive: 'Adaptative',
videoid: 'ID vid�o: ',
inbrowser_adaptive_merger: 'Fusionner vid�os et audios adaptatifs
dans le navigateur (FFmpeg)',
dlmp4: 'T�l�chargez la plus haute r�solution mp4 en un clic',
get_video_failed:
'Il semble qu\'une extension de blocage de pubs soit
install�e, ce qui bloque %s.\nVeuillez ajouter la r�gle suivante au jeu de r�gles,
ou cela emp�chera Local YouTube Downloader de fonctionner.\n\nPS: Si votre bloqueur
refuse d\'ajouter cette r�gle, vous devez le d�sinstaller et utiliser plut�t
"uBlock Origin".\nSi vous ne comprenez toujours pas ce que je dis, d�sinstallez ou
d�sactivez simplement votre bloqueur de pubs ...'
},
pl: {
togglelinks: 'Pokaz/Ukryj Linki',
stream: 'Stream',
adaptive: 'Adaptywne',
videoid: 'ID filmu: ',
inbrowser_adaptive_merger: 'Polacz audio i wideo adaptywne w
przegladarce (FFmpeg)',
dlmp4: 'Pobierz .mp4 w najwyzszej jakosci'
},
hi: {
togglelinks: '???? ???? ????',
stream: '?????????? (Stream)',
adaptive: '??????? (Adaptive)',
videoid: '?????? ????: {{id}}'
}
}
for (const [lang, data] of Object.entries(LOCALE)) {
if (lang === LANG_FALLBACK) continue
for (const key of Object.keys(LOCALE[LANG_FALLBACK])) {
if (!(key in data)) {
data[key] = LOCALE[LANG_FALLBACK][key]
}
}
}

const findLang = l => {


// language resolution logic: zh-tw --(if not exists)--> zh --(if not
exists)--> LANG_FALLBACK(en)
l = l.toLowerCase().replace('_', '-')
if (l in LOCALE) return l
else if (l.length > 2) return findLang(l.split('-')[0])
else return LANG_FALLBACK
}
const $ = (s, x = document) => x.querySelector(s)
const $el = (tag, opts) => {
const el = document.createElement(tag)
Object.assign(el, opts)
return el
}
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const parseDecsig = data => {
try {
if (data.startsWith('var script')) {
// they inject the script via script tag
const obj = {}
const document = {
createElement: () => obj,
head: { appendChild: () => {} }
}
eval(data)
data = obj.innerHTML
}
const fnnameresult = /=([a-zA-Z0-9\$]+?)\
(decodeURIComponent/.exec(data)
const fnname = fnnameresult[1]
const _argnamefnbodyresult = new RegExp(escapeRegExp(fnname) +
'=function\\((.+?)\\){(.+?)}').exec(data)
const [_, argname, fnbody] = _argnamefnbodyresult
const helpernameresult = /;(.+?)\..+?\(/.exec(fnbody)
const helpername = helpernameresult[1]
const helperresult = new RegExp('var ' + escapeRegExp(helpername)
+ '={[\\s\\S]+?};').exec(data)
const helper = helperresult[0]
logger.log(`parsedecsig result: %s=>{%s\n%s}`, argname, helper,
fnbody)
return new Function([argname], helper + '\n' + fnbody)
} catch (e) {
logger.error('parsedecsig error: %o', e)
logger.info('script content: %s', data)
logger.info(
'If you encounter this error, please copy the full "script
content" to https://pastebin.com/ for me.'
)
}
}
const parseQuery = s => [...new URLSearchParams(s).entries()].reduce((acc,
[k, v]) => ((acc[k] = v), acc), {})
const getVideo = async (id, decsig) => {
const data = await xf
.get(`https://www.youtube.com/get_video_info?video_id=$
{id}&el=detailpage`)
.text()
.catch(err => null)
if (!data) return 'Adblock conflict'
const obj = parseQuery(data)
const playerResponse = JSON.parse(obj.player_response)
logger.log(`video %s data: %o`, id, obj)
logger.log(`video %s playerResponse: %o`, id, playerResponse)
if (obj.status === 'fail') {
throw obj
}
let stream = []
if (playerResponse.streamingData.formats) {
stream = playerResponse.streamingData.formats.map(x =>
Object.assign({}, x, parseQuery(x.cipher ||
x.signatureCipher))
)
logger.log(`video %s stream: %o`, id, stream)
if (stream[0].sp && stream[0].sp.includes('sig')) {
for (const obj of stream) {
obj.s = decsig(obj.s)
obj.url += `&sig=${obj.s}`
}
}
}

let adaptive = []
if (playerResponse.streamingData.adaptiveFormats) {
adaptive = playerResponse.streamingData.adaptiveFormats.map(x =>
Object.assign({}, x, parseQuery(x.cipher ||
x.signatureCipher))
)
logger.log(`video %s adaptive: %o`, id, adaptive)
if (adaptive[0].sp && adaptive[0].sp.includes('sig')) {
for (const obj of adaptive) {
obj.s = decsig(obj.s)
obj.url += `&sig=${obj.s}`
}
}
}
logger.log(`video %s result: %o`, id, { stream, adaptive })
return { stream, adaptive, meta: obj, playerResponse }
}
const workerMessageHandler = async e => {
const decsig = await xf.get(e.data.path).text(parseDecsig)
try {
const result = await getVideo(e.data.id, decsig)
self.postMessage(result)
} catch (e) {
self.postMessage(e)
}
}
const ytdlWorkerCode = `
importScripts('https://unpkg.com/xfetch-js@0.3.4/xfetch.min.js')
const DEBUG=${DEBUG}
const logger=(${createLogger})(console, 'YTDL')
const escapeRegExp=${escapeRegExp}
const parseQuery=${parseQuery}
const parseDecsig=${parseDecsig}
const getVideo=${getVideo}
self.onmessage=${workerMessageHandler}`
const ytdlWorker = new Worker(URL.createObjectURL(new
Blob([ytdlWorkerCode])))
const workerGetVideo = (id, path) => {
logger.log(`workerGetVideo start: %s %s`, id, path)
return new Promise((res, rej) => {
const callback = e => {
ytdlWorker.removeEventListener('message', callback)
if (e.data === 'Adblock conflict') {
return rej(e.data)
}
logger.log('workerGetVideo end: %o', e.data)
res(e.data)
}
ytdlWorker.addEventListener('message', callback)
ytdlWorker.postMessage({ id, path })
})
}

const determineChunksNum = size => {


const n = Math.ceil(size / (1024 * 1024 * 3)) // 3 MB
return n
}
// video downloader
const xhrDownloadUint8Array = async ({ url, contentLength }, progressCb) => {
if (typeof contentLength === 'string') contentLength =
parseInt(contentLength)
progressCb({
loaded: 0,
total: contentLength,
speed: 0
})
const chunkSize = Math.floor(contentLength /
determineChunksNum(contentLength))
const getBuffer = (start, end) =>
new Promise((res, rej) => {
const xhr = {}
xhr.responseType = 'arraybuffer'
xhr.method = 'GET'
xhr.url = url
xhr.headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.124 Safari/537.36',
Range: `bytes=${start}-${end ? end - 1 : ''}`,
'Accept-Encoding': 'identity',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7'
}
xhr.onload = obj => res(obj.response)
GM_xmlhttpRequest(xhr)
})
const data = new Uint8Array(contentLength)
let downloaded = 0
const queue = new pQueue.default({ concurrency: 5 })
const startTime = Date.now()
const ps = []
for (let start = 0; start < contentLength; start += chunkSize) {
const exceeded = start + chunkSize > contentLength
const curChunkSize = exceeded ? contentLength - start : chunkSize
const end = exceeded ? null : start + chunkSize
const p = queue.add(() =>
getBuffer(start, end).then(buf => {
downloaded += curChunkSize
data.set(new Uint8Array(buf), start)
const ds = (Date.now() - startTime + 1) / 1000
progressCb({
loaded: downloaded,
total: contentLength,
speed: downloaded / ds
})
})
)
ps.push(p)
}
await Promise.all(ps)
return data
}

const ffWorker = FFmpeg.createWorker({


logger: DEBUG ? m => logger.log(m.message) : () => {}
})
let ffWorkerLoaded = false
const mergeVideo = async (video, audio) => {
if (!ffWorkerLoaded) await ffWorker.load()
await ffWorker.write('video.mp4', video)
await ffWorker.write('audio.mp4', audio)
await ffWorker.run('-i video.mp4 -i audio.mp4 -c copy output.mp4', {
input: ['video.mp4', 'audio.mp4'],
output: 'output.mp4'
})
const { data } = await ffWorker.read('output.mp4')
await ffWorker.remove('output.mp4')
return data
}
const triggerDownload = (url, filename) => {
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
}
const dlModalTemplate = `
<div style="width: 100%; height: 100%;">
<div v-if="merging" style="height: 100%; width: 100%; display: flex; justify-
content: center; align-items: center; font-size: 24px;">Merging video, please
wait...</div>
<div v-else style="height: 100%; width: 100%; display: flex; flex-direction:
column;">
<div style="flex: 1; margin: 10px;">
<p style="font-size: 24px;">Video</p>
<progress style="width: 100%;" :value="video.progress" min="0"
max="100"></progress>
<div style="display: flex; justify-content: space-between;">
<span>{{video.speed}} kB/s</span>
<span>{{video.loaded}}/{{video.total}} MB</span>
</div>
</div>
<div style="flex: 1; margin: 10px;">
<p style="font-size: 24px;">Audio</p>
<progress style="width: 100%;" :value="audio.progress" min="0"
max="100"></progress>
<div style="display: flex; justify-content: space-between;">
<span>{{audio.speed}} kB/s</span>
<span>{{audio.loaded}}/{{audio.total}} MB</span>
</div>
</div>
</div>
</div>
`
function openDownloadModel(adaptive, title) {
const win = open(
'',
'Video Download',
`toolbar=no,height=${screen.height / 2},width=${screen.width /
2},left=${screenLeft},top=${screenTop}`
)
const div = win.document.createElement('div')
win.document.body.appendChild(div)
win.document.title = `Downloading "${title}"`
const dlModalApp = new Vue({
template: dlModalTemplate,
data() {
return {
video: {
progress: 0,
total: 0,
loaded: 0,
speed: 0
},
audio: {
progress: 0,
total: 0,
loaded: 0,
speed: 0
},
merging: false
}
},
methods: {
async start(adaptive, title) {
win.onbeforeunload = () => true
// YouTube's default order is descending by video
quality
const videoObj = adaptive
.filter(x => x.mimeType.includes('video/mp4'))
.map(v => {
const [_, quality, fps] =
/(\d+)p(\d*)/.exec(v.qualityLabel)
v.qualityNum = parseInt(quality)
v.fps = fps ? parseInt(fps) : 30
return v
})
.sort((a, b) => {
if (a.qualityNum === b.qualityNum) return
b.fps - a.fps // ex: 30-60=-30, then a will be put before b
return b.qualityNum - a.qualityNum
})[0]
const audioObj = adaptive.find(x =>
x.mimeType.includes('audio/mp4'))
const vPromise = xhrDownloadUint8Array(videoObj, e =>
{
this.video.progress = (e.loaded / e.total) *
100
this.video.loaded = (e.loaded / 1024 /
1024).toFixed(2)
this.video.total = (e.total / 1024 /
1024).toFixed(2)
this.video.speed = (e.speed / 1024).toFixed(2)
})
const aPromise = xhrDownloadUint8Array(audioObj, e =>
{
this.audio.progress = (e.loaded / e.total) *
100
this.audio.loaded = (e.loaded / 1024 /
1024).toFixed(2)
this.audio.total = (e.total / 1024 /
1024).toFixed(2)
this.audio.speed = (e.speed / 1024).toFixed(2)
})
const [varr, aarr] = await Promise.all([vPromise,
aPromise])
this.merging = true
win.onunload = () => {
// trigger download when user close it
const bvurl = URL.createObjectURL(new
Blob([varr]))
const baurl = URL.createObjectURL(new
Blob([aarr]))
triggerDownload(bvurl, title + '-
videoonly.mp4')
triggerDownload(baurl, title + '-
audioonly.mp4')
}
const result = await Promise.race([mergeVideo(varr,
aarr), sleep(1000 * 25).then(() => null)])
if (!result) {
alert('An error has occurred when merging
video')
const bvurl = URL.createObjectURL(new
Blob([varr]))
const baurl = URL.createObjectURL(new
Blob([aarr]))
triggerDownload(bvurl, title + '-
videoonly.mp4')
triggerDownload(baurl, title + '-
audioonly.mp4')
return this.close()
}
this.merging = false
const url = URL.createObjectURL(new Blob([result]))
triggerDownload(url, title + '.mp4')
win.onbeforeunload = null
win.onunload = null
win.close()
}
}
}).$mount(div)
dlModalApp.start(adaptive, title)
}

const template = `
<div class="box" :class="{'dark':dark}">
<template v-if="!isLiveStream">
<div v-if="adaptive.length" class="of-h t-center c-pointer lh-20">
<a class="fs-14px" @click="dlmp4" v-text="strings.dlmp4"></a>
</div>
<div @click="hide=!hide" class="box-toggle div-a t-center fs-14px c-pointer lh-
20" v-text="strings.togglelinks"></div>
<div :class="{'hide':hide}">
<div class="t-center fs-14px" v-text="strings.videoid+id"></div>
<div class="d-flex">
<div class="f-1 of-h">
<div class="t-center fs-14px" v-text="strings.stream"></div>
<a class="ytdl-link-btn fs-14px" target="_blank" v-for="vid in stream"
:href="vid.url" :title="vid.type" v-text="formatStreamText(vid)"></a>
</div>
<div class="f-1 of-h">
<div class="t-center fs-14px" v-text="strings.adaptive"></div>
<a class="ytdl-link-btn fs-14px" target="_blank" v-for="vid in
adaptive" :href="vid.url" :title="vid.type" v-text="formatAdaptiveText(vid)"></a>
</div>
</div>
<div class="of-h t-center">
<a class="fs-14px" href="https://maple3142.github.io/mergemp4/"
target="_blank" v-text="strings.inbrowser_adaptive_merger"></a>
</div>
</div>
</template>
<template v-else>
<div class="t-center fs-14px lh-20" v-
text="strings.live_stream_disabled_message"></div>
</template>
</div>
`.slice(1)
const app = new Vue({
data() {
return {
hide: true,
id: '',
isLiveStream: false,
stream: [],
adaptive: [],
meta: null,
dark: false,
lang: findLang(navigator.language)
}
},
computed: {
strings() {
return LOCALE[this.lang.toLowerCase()]
}
},
methods: {
dlmp4() {
const r = JSON.parse(this.meta.player_response)
openDownloadModel(this.adaptive, r.videoDetails.title)
},
formatStreamText(vid) {
return [vid.qualityLabel, vid.quality].filter(x =>
x).join(': ')
},
formatAdaptiveText(vid) {
let str = [vid.qualityLabel, vid.mimeType].filter(x =>
x).join(': ')
if (vid.mimeType.includes('audio')) {
str += ` ${Math.round(vid.bitrate / 1000)}kbps`
}
return str
}
},
template
})
logger.log(`default language: %s`, app.lang)

// attach element
const shadowHost = $el('div')
const shadow = shadowHost.attachShadow ? shadowHost.attachShadow({ mode:
'closed' }) : shadowHost // no shadow dom
logger.log('shadowHost: %o', shadowHost)
const container = $el('div')
shadow.appendChild(container)
app.$mount(container)

if (DEBUG && typeof unsafeWindow !== 'undefined') {


// expose some functions for debugging
unsafeWindow.$app = app
unsafeWindow.parseQuery = parseQuery
unsafeWindow.parseDecsig = parseDecsig
unsafeWindow.getVideo = getVideo
}

const getLangCode = () => {


if (typeof ytplayer !== 'undefined' && ytplayer.config) {
return ytplayer.config.args.host_language
} else if (typeof yt !== 'undefined') {
return yt.config_.GAPI_LOCALE
} else {
return navigator.language
}
}
const applyOriginalTitle = meta => {
console.log(meta.player_response)
const data = eval(`(${meta.player_response})`).videoDetails // not a
valid json, so JSON.parse won't work
if ($('#eow-title')) {
// legacy youtube
$('#eow-title').textContent = data.title
} else if ($('h1.title')) {
// new youtube (polymer)
$('h1.title').textContent = data.title
}
}
const load = async id => {
try {
const basejs =
(typeof ytplayer !== 'undefined' && 'config' in ytplayer &&
ytplayer.config.assets
? 'https://' + location.host +
ytplayer.config.assets.js
: 'web_player_context_config' in ytplayer
? 'https://' + location.host +
ytplayer.web_player_context_config.jsUrl
: null) || $('script[src$="base.js"]').src
const data = await workerGetVideo(id, basejs)
logger.log('video loaded: %s', id)
app.isLiveStream =
data.playerResponse.playabilityStatus.liveStreamability != null
if (RESTORE_ORIGINAL_TITLE_FOR_CURRENT_VIDEO) {
try {
applyOriginalTitle(data.meta)
} catch (e) {
// just make sure the main function will work even if
original title applier doesn't work
}
}
app.id = id
app.stream = data.stream
app.adaptive = data.adaptive
app.meta = data.meta

const actLang = getLangCode()


if (actLang !== null) {
const lang = findLang(actLang)
logger.log('youtube ui lang: %s', actLang)
logger.log('ytdl lang:', lang)
app.lang = lang
}
} catch (err) {
if (err === 'Adblock conflict') {
const str = app.strings.get_video_failed.replace(
'%s',
`https://www.youtube.com/get_video_info?video_id=$
{id}&el=detailpage`
)
prompt(str, '@@||www.youtube.com/get_video_info?
*=detailpage$xhr,domain=youtube.com')
}
logger.error('load', err)
}
}
let prev = null
setInterval(() => {
const el =
$('#info-contents') ||
$('#watch-header') ||
$('.page-container:not([hidden]) ytm-item-section-renderer>lazy-
list')
if (el && !el.contains(shadowHost)) {
el.appendChild(shadowHost)
}
if (location.href !== prev) {
logger.log(`page change: ${prev} -> ${location.href}`)
prev = location.href
if (location.pathname === '/watch') {
shadowHost.style.display = 'block'
const id = parseQuery(location.search).v
logger.log('start loading new video: %s', id)
app.hide = true // fold it
load(id)
} else {
shadowHost.style.display = 'none'
}
}
}, 1000)

// listen to dark mode toggle


const $html = $('html')
new MutationObserver(() => {
app.dark = $html.getAttribute('dark') === 'true'
}).observe($html, { attributes: true })
app.dark = $html.getAttribute('dark') === 'true'

const css = `
.hide{
display: none;
}
.t-center{
text-align: center;
}
.d-flex{
display: flex;
}
.f-1{
flex: 1;
}
.fs-14px{
font-size: 14px;
}
.of-h{
overflow: hidden;
}
.box{
padding-top: .5em;
padding-bottom: .5em;
border-bottom: 1px solid var(--yt-border-color);
font-family: Arial;
}
.box-toggle{
margin: 3px;
user-select: none;
-moz-user-select: -moz-none;
}
.ytdl-link-btn{
display: block;
border: 1px solid !important;
border-radius: 3px;
text-decoration: none !important;
outline: 0;
text-align: center;
padding: 2px;
margin: 5px;
color: black;
}
a, .div-a{
text-decoration: none;
color: var(--yt-button-color, inherit);
}
a:hover, .div-a:hover{
color: var(--yt-spec-call-to-action, blue);
}
.box.dark{
color: var(--ytd-video-primary-info-renderer-title-color, var(--yt-primary-
text-color));
}
.box.dark .ytdl-link-btn{
color: var(--ytd-video-primary-info-renderer-title-color, var(--yt-primary-
text-color));
}
.box.dark .ytdl-link-btn:hover{
color: rgba(200, 200, 255, 0.8);
}
.box.dark .box-toggle:hover{
color: rgba(200, 200, 255, 0.8);
}
.c-pointer{
cursor: pointer;
}
.lh-20{
line-height: 20px;
}
`
shadow.appendChild($el('style', { textContent: css }))
})()

You might also like