/*
Youtube sticky Show Less button: Makes SHOW LESS button to be "sticky"
to the video description section, so you can easily fold a long description
without scrolling it all the way to its bottom.
Copyright (C) 2025 T1mL3arn
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// ==UserScript==
// @name Youtube sticky Show Less button
// @description Makes SHOW LESS button to be "sticky" to the video description section, so you can easily fold a long description without scrolling it all the way to its bottom.
// @description:RU Делает кнопку СВЕРНУТЬ в описании видео "липкой". Чтобы свернуть длинное описание теперь не нужно прокручивать это описание в самый низ.
// @namespace https://github.com/t1ml3arn-userscript-js
// @version 2.0.0
// @match https://www.youtube.com/*
// @match https://youtube.com/*
// @noframes
// @grant none
// @author T1mL3arn
// @homepageURL https://github.com/t1ml3arn-userscript-js/Youtube-sticky-SHOW-LESS-button
// @supportURL https://github.com/t1ml3arn-userscript-js/Youtube-sticky-SHOW-LESS-button/issues
// @license GPL-3.0-only
// ==/UserScript==
const SHOWLESS_BTN_WRAP_CLS = 'sticky-show-less-btn-wrap';
const STICKY_STYLE_ELT_ID = 'sticky-states-css'
const STICKY_STYLESHEET_CONTENT = `
.${SHOWLESS_BTN_WRAP_CLS} {
text-align: right;
position: fixed;
top: 50%;
pointer-events: none;
z-index: 999;
}
tp-yt-paper-button#collapse {
pointer-events: initial;
padding: 6px 16px;
background: darkseagreen;
color: white;
margin-top: 0 !important;
}
`;
let SETTINGS = {
videoDescriptionSelector: 'ytd-video-secondary-info-renderer',
videoTitleSelector: 'div#info.ytd-watch-flexy',
showLessBtnSelector: 'ytd-expander.ytd-video-secondary-info-renderer tp-yt-paper-button#less.ytd-expander',
}
function addCss(css, id) {
const style = document.head.appendChild(document.createElement('style'))
style.textContent = css;
style.id = id;
}
function getVisibleElt(selector) {
return Array.from(document.querySelectorAll(selector)).find(e => e.offsetParent != null)
}
function fixScroll() {
if (areCommentsVisible())
preserveCommentsOnScreen()
else if (isDescriptionTopEdgeOutView())
scrollDescriptionIntoView();
else {
// console.debug('do nothing with scroll')
}
}
function areCommentsVisible() {
const vpHeight = window.visualViewport.height
const commentsTop = getVisibleElt('ytd-comments').getBoundingClientRect().top
return commentsTop < vpHeight;
}
function preserveCommentsOnScreen() {
const descriptionElt = document.querySelector(SETTINGS.videoDescriptionSelector)
// scrollOffset must not be negative!
const scrollOffset = Math.abs(descriptionElt.getBoundingClientRect().height - descriptionHeight)
let { scrollX, scrollY } = window;
// console.debug('preserve comments:', scrollY, scrollOffset, scrollY - scrollOffset)
scrollY = scrollY - scrollOffset;
window.scrollTo(scrollX, scrollY)
}
function isDescriptionTopEdgeOutView() {
const descriptionElt = document.querySelector(SETTINGS.videoTitleSelector);
return descriptionElt.getBoundingClientRect().top < 0
}
function scrollDescriptionIntoView() {
// console.debug('scroll description into view')
document.querySelector(SETTINGS.videoTitleSelector).scrollIntoView({ behavior: 'smooth' })
}
let descriptionHeight;
function saveDescriptionHeight() {
// saving initial description elt height (it is needed to fix scroll position)
const descriptionElt = document.querySelector(SETTINGS.videoDescriptionSelector)
descriptionHeight = descriptionElt.getBoundingClientRect().height;
// at saveDescriptionHeight() call height might be not actual,
// and delaying the reading helps
setTimeout(() => {
const h = document.querySelector(SETTINGS.videoDescriptionSelector).getBoundingClientRect().height
// console.log(`description HEIGHT is (${h})`)
descriptionHeight = h
}, 0);
}
function enchance2025() {
// The main idea:
// query parents til #description
// move the button into it
// emulate sticky behavior with 'position: fixed' and js
// TODO query only visible elements ?
let expanders = document.querySelectorAll(SETTINGS.showLessBtnSelector)
// console.log('EXPANDERS COUNT:', expanders.length)
for (const showLessBtn of expanders ) {
// console.log('original button', showLessBtn)
const descriptionElt = showLessBtn.parentElement.parentElement
// console.log('description', descriptionElt)
// don't touch the wrong button
// (mb the wrong button will be used later in different way)
if (!descriptionElt.matches('#description-inner'))
continue
const btnWrap = document.createElement('div')
btnWrap.appendChild(showLessBtn)
// I use wrap to intercept clicks in CAPTURE phase
// to calcalute scroll offset BEFORE youtube hides the description
btnWrap.addEventListener('click', fixScroll, true)
const stickyWrap = document.createElement('div');
stickyWrap.classList.add(SHOWLESS_BTN_WRAP_CLS)
stickyWrap.appendChild(btnWrap);
// add sticky wrapper (with showless button) to video description element
descriptionElt.appendChild(stickyWrap)
emulateSticky(stickyWrap, descriptionElt)
descriptionElt.parentElement.addEventListener('click', () => {
// console.log('click correctioon');
correctPlacement(stickyWrap, descriptionElt)
})
}
}
/**
* Didn't figure it out how to make elt sticky on a new youtube design.
* ~Fuck this stupid css.~
* Let's emulate it with JS
*/
function emulateSticky(sticky, container) {
const crect = container.getBoundingClientRect()
sticky.style.left = `${crect.right}px`
sticky.style.transform = `translate(-100%, 0px)`
correctPlacement(sticky, container)
window.addEventListener('scroll', e => {
correctPlacement(sticky, container)
}, { passive: true })
window.addEventListener('scrollend', e => {
correctPlacement(sticky, container)
// console.log('scrollend');
}, { passive: true })
window.addEventListener('resize', e => {
requestAnimationFrame(() => {
sticky.style.left = `${container.getBoundingClientRect().right}px`
correctPlacement(sticky, container)
// console.log('resize');
})
})
}
function correctPlacement(sticky, container) {
const centerY = window.visualViewport.height*0.5
const offsetBottom = 60
let crect = container.getBoundingClientRect()
if (crect.top > centerY) {
sticky.style.top = `${crect.top}px`
requestAnimationFrame(() => {
// container's top value is the actual limit,
// (sticky must be BELOW that line)
// so just query the actual top value
// inside requestAnimationFrame(),
// that way I finally donot see layout tearing
let crect = container.getBoundingClientRect()
sticky.style.top = `${crect.top}px`
// console.log(crect.top, crect.bottom, centerY, crect.top > centerY, crect.bottom-offsetBottom < centerY)
// console.log('correct top')
})
// console.log('top overscroll', dy);
} else if ((crect.bottom - offsetBottom) < centerY) {
sticky.style.top = `${crect.bottom - offsetBottom}px`
requestAnimationFrame(() => {
// container's bottom(- offset) value is the actual limit,
// (sticky must be ABOVE that line)
// so just query the actual top value
// inside requestAnimationFrame(),
// that way I finally donot see layout tearing
let crect = container.getBoundingClientRect()
let newTop = `${crect.bottom - offsetBottom}px`
sticky.style.top = newTop
// console.log(crect.top, crect.bottom, centerY, crect.top > centerY, crect.bottom-offsetBottom < centerY)
// console.log('correct bottom')
})
// console.log('bottom overscroll', dy);
} else {
// removing inline style activates 50% for top from CSS rule
sticky.style.top = ''
// console.log('clear top')
// requestAnimationFrame(() => sticky.style.top = '')
}
}
/** For debug purpose */
function drawVerticalCenterLine() {
let a = document.createElement('div')
a.style.boxSizing = 'border-box'
a.style.width = '100%'
a.style.height = '3px'
a.style.background = 'red'
a.style.position = 'fixed'
a.style.top = '50%'
a.style.left = 0
a.id = 'sticky-grid'
document.body.appendChild(a)
}
function init() {
// Looks like 'yt-page-data-updated' is the event I need to listen
// to know exactly when youtube markup is ready to be queried.
document.addEventListener('yt-page-data-updated', _ => {
// console.log('YT EVENT yt-page-data-updated');
// Script should work only for pages with a video,
// such pages have url like https://www.youtube.com/watch?v=25YbRHAc_h4
if (window.location.search.includes('v=')) {
// settings for the actual design
SETTINGS = {
videoDescriptionSelector: '#above-the-fold.ytd-watch-metadata',
videoTitleSelector: '#above-the-fold.ytd-watch-metadata',
showLessBtnSelector: '#collapse.button.ytd-text-inline-expander',
css: STICKY_STYLESHEET_CONTENT,
}
addCss(SETTINGS.css, STICKY_STYLE_ELT_ID)
// Wait a little to get ALL the buttons initialized
// (better use MutationObserver ?)
setTimeout(() => {
try {
saveDescriptionHeight();
enchance2025()
} catch(e) {
console.log('Something went wrong, probably page layot has changed')
console.error(e)
}
// drawVerticalCenterLine()
}, 125);
}
})
}
init()