Restore YouTube Subscription List View
// ==UserScript==
// @name YouTube Subscription List View
// @namespace xyz.flaflo.yslv
// @version 1.4.5
// @author Flaflo
// @license MIT
// @homepageURL https://flaflo.xyz/yslv
// @description Restore YouTube Subscription List View
// @match https://www.youtube.com/*
// @run-at document-end
// @grant none
// ==/UserScript==
;(() => {
"use strict"
const CFG = {
storageKey: "yslv",
defaultView: "grid", // or "list"
toggleMountSelector:
'ytd-browse[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container #subscribe-button,' +
'ytd-two-column-browse-results-renderer[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container #subscribe-button',
descStore: {
key: "yslv_desc_cache_v1",
ttlMs: 60 * 60 * 1000,
maxEntries: 1200,
saveDebounceMs: 250,
},
list: {
maxWidth: 1120, // or for non centered "90%"
rowPadY: 22,
separator: true,
thumbW: 240,
thumbRadius: 14,
shorts: {
enabled: true,
cardW: 170,
},
titleClamp: 2,
descClamp: 2,
rowHead: {
enabled: true,
gap: 12,
marginBottom: 20,
avatarSize: 32,
},
metaRow: {
gap: 8,
},
desc: {
marginTop: 10,
skeleton: {
enabled: true,
lines: 2,
lineGap: 6,
lineHeights: [12, 12, 12],
lineWidthsPct: [82, 74, 58],
radius: 9,
maxW: 520,
animMs: 5000,
},
},
descFetch: {
enabled: true,
maxTotalFetchesPerNav: 60,
maxConcurrent: 1,
sentenceCount: 2,
maxChars: 260,
},
},
perf: {
maxItemsPerTick: 60,
descQueueIntervalMs: 350,
},
ids: {
style: "yslv-subs-style",
toggle: "yslv-subs-toggle",
},
cls: {
rowHead: "yslv-subs-rowhead",
rowHeadName: "yslv-subs-rowhead-name",
metaRow: "yslv-subs-mrow",
metaCh: "yslv-subs-mch",
metaRt: "yslv-subs-mrt",
desc: "yslv-subs-desc",
descSkel: "yslv-subs-desc-skel",
btn: "yslv-btn",
btnIcon: "yslv-btn-ic",
isShort: "yslv-is-short",
},
attr: {
view: "data-yslv-subs-view",
},
cssVars: {
shimmerX: "--yslvSkelX",
shortW: "--yslvShortW",
},
}
const STATE = {
active: false,
view: "grid",
styleEl: null,
q: [],
qSet: new Set(),
processing: false,
processedItems: new WeakSet(),
movedAvatars: new WeakMap(),
movedMetaAnchors: new WeakMap(),
mo: null,
observedTarget: null,
pmMo: null,
descCache: new Map(),
descInFlight: new Map(),
descFetches: 0,
descActive: 0,
descQueue: [],
descQueued: new Set(),
descTimer: 0,
descPumpRunning: false,
lastQueueSig: "",
lastPageSig: "",
}
const SHIMMER = {
raf: 0,
running: false,
t0: 0,
}
const DESC_STORE = {
obj: null,
dirty: false,
saveT: 0,
}
function clearChildren(el) {
if (!el) return
while (el.firstChild) el.removeChild(el.firstChild)
}
function cloneInto(dest, src) {
if (!dest) return
clearChildren(dest)
if (!src) return
const frag = document.createDocumentFragment()
for (const n of Array.from(src.childNodes || [])) frag.appendChild(n.cloneNode(true))
for (const host of Array.from(frag.querySelectorAll?.(".ytIconWrapperHost, .yt-icon-shape") || [])) {
if (!host.querySelector("svg")) host.remove()
}
dest.appendChild(frag)
}
function setTextOnly(dest, txt) {
if (!dest) return
clearChildren(dest)
dest.textContent = normalizeText(txt)
}
function isSubsPage() {
return location.pathname === "/feed/subscriptions"
}
function getActiveSubsBrowse() {
return (
document.querySelector('ytd-page-manager ytd-browse[page-subtype="subscriptions"]:not([hidden])') ||
document.querySelector('ytd-browse[page-subtype="subscriptions"]:not([hidden])') ||
null
)
}
function getActiveSubsRoot() {
const b = getActiveSubsBrowse()
if (!b) return null
return b.querySelector("ytd-rich-grid-renderer #contents") || b.querySelector("ytd-rich-grid-renderer") || b
}
function getActiveSubsDoc() {
return getActiveSubsBrowse() || document
}
function normalizeText(s) {
return String(s || "")
.replace(/\u200B/g, "")
.replace(/\s+/g, " ")
.trim()
}
function loadView() {
try {
const v = localStorage.getItem(CFG.storageKey)
return v === "list" || v === "grid" ? v : CFG.defaultView
} catch {
return CFG.defaultView
}
}
function saveView(v) {
try {
localStorage.setItem(CFG.storageKey, v)
} catch {}
}
function applyViewAttr(v) {
STATE.view = v
saveView(v)
document.documentElement.setAttribute(CFG.attr.view, v)
paintToggle()
}
function clearViewAttr() {
document.documentElement.removeAttribute(CFG.attr.view)
}
function svgEl(paths, viewBox) {
const NS = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(NS, "svg")
svg.setAttribute("viewBox", viewBox || "0 0 24 24")
svg.setAttribute("aria-hidden", "true")
for (const d of paths) {
const p = document.createElementNS(NS, "path")
p.setAttribute("d", d)
svg.appendChild(p)
}
return svg
}
function skNorm() {
const s = CFG.list.desc.skeleton || {}
const lines = Math.max(1, Math.min(3, Number(s.lines) || 1))
const gap = Math.max(0, Number(s.lineGap) || 6)
const heights = Array.isArray(s.lineHeights) ? s.lineHeights : [12, 12, 12]
const widths = Array.isArray(s.lineWidthsPct) ? s.lineWidthsPct : [82, 74, 58]
const h = i => Math.max(10, Number(heights[i] ?? heights[0] ?? 12))
const w = i => Math.max(35, Math.min(100, Number(widths[i] ?? widths[0] ?? 82)))
const r = Math.max(6, Number(s.radius) || 9)
const maxW = Math.max(160, Number(s.maxW) || 520)
const ms = Math.max(650, Number(s.animMs) || 5000)
return { enabled: !!s.enabled, lines, gap, h, w, r, maxW, ms }
}
function nowMs() {
return Date.now()
}
function ensureDescStoreLoaded() {
if (DESC_STORE.obj) return
let raw = ""
try {
raw = localStorage.getItem(CFG.descStore.key) || ""
} catch {
raw = ""
}
let obj = {}
if (raw) {
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === "object") obj = parsed
} catch {
obj = {}
}
}
DESC_STORE.obj = obj
pruneDescStore()
}
function scheduleDescStoreSave() {
if (DESC_STORE.saveT) return
DESC_STORE.saveT = setTimeout(() => {
DESC_STORE.saveT = 0
if (!DESC_STORE.dirty) return
DESC_STORE.dirty = false
try {
localStorage.setItem(CFG.descStore.key, JSON.stringify(DESC_STORE.obj || {}))
} catch {}
}, Math.max(0, Number(CFG.descStore.saveDebounceMs) || 250))
}
function pruneDescStore() {
ensureDescStoreLoaded()
const ttl = Math.max(1, Number(CFG.descStore.ttlMs) || 3600000)
const maxEntries = Math.max(50, Number(CFG.descStore.maxEntries) || 1200)
const tNow = nowMs()
const obj = DESC_STORE.obj || {}
const entries = []
for (const k of Object.keys(obj)) {
const e = obj[k]
const t = Number(e?.t || 0)
if (!t || tNow - t >= ttl) {
delete obj[k]
DESC_STORE.dirty = true
continue
}
entries.push([k, t])
}
if (entries.length > maxEntries) {
entries.sort((a, b) => a[1] - b[1])
const drop = entries.length - maxEntries
for (let i = 0; i < drop; i++) {
delete obj[entries[i][0]]
DESC_STORE.dirty = true
}
}
if (DESC_STORE.dirty) scheduleDescStoreSave()
}
function getStoredDesc(vid) {
if (!vid) return null
ensureDescStoreLoaded()
const ttl = Math.max(1, Number(CFG.descStore.ttlMs) || 3600000)
const tNow = nowMs()
const obj = DESC_STORE.obj || {}
const e = obj[vid]
if (!e) return null
const t = Number(e.t || 0)
const d = typeof e.d === "string" ? e.d : ""
if (!t || tNow - t >= ttl) {
delete obj[vid]
DESC_STORE.dirty = true
scheduleDescStoreSave()
return null
}
return d
}
function setStoredDesc(vid, desc) {
if (!vid) return
ensureDescStoreLoaded()
const obj = DESC_STORE.obj || {}
obj[vid] = { t: nowMs(), d: String(desc || "") }
DESC_STORE.dirty = true
pruneDescStore()
scheduleDescStoreSave()
}
function ensureStyle() {
if (STATE.styleEl) return
const L = CFG.list
const maxW = Math.max(0, Number(L.maxWidth)) || L.maxWidth || 1120
const thumbW = Math.max(240, Number(L.thumbW) || 420)
const radius = Math.max(0, Number(L.thumbRadius) || 14)
const rowPadY = Math.max(8, Number(L.rowPadY) || 22)
const titleClamp = Math.max(1, Number(L.titleClamp) || 2)
const descClamp = Math.max(1, Number(L.descClamp) || 2)
const descMt = Math.max(4, Number(L.desc.marginTop) || 10)
const headGap = Math.max(6, Number(L.rowHead.gap) || 12)
const headMb = Math.max(6, Number(L.rowHead.marginBottom) || 20)
const headAv = Math.max(20, Number(L.rowHead.avatarSize) || 32)
const metaGap = Math.max(6, Number(L.metaRow.gap) || 8)
const shortW = Math.max(110, Number(L.shorts.cardW) || 170)
const S = skNorm()
const attr = CFG.attr.view
const skLineRules = Array.from({ length: 3 })
.map((_, i) => {
const n = i + 1
return `
html[${attr}="list"] .${CFG.cls.desc}.${CFG.cls.descSkel} > span:nth-child(${n}){
height:${S.h(i)}px !important;
width:min(${S.maxW}px, ${S.w(i)}%) !important;
margin-top:${i === 0 ? 0 : S.gap}px !important;
}`.trim()
})
.join("\n")
const el = document.createElement("style")
el.id = CFG.ids.style
el.textContent = `
html[${attr}="list"] #content.ytd-rich-section-renderer{ margin:0 !important; }
ytd-masthead{ position:sticky !important; top:0 !important; z-index:3000 !important; }
#title-text{ position:relative !important; z-index:1 !important; }
#${CFG.ids.toggle}{
display:inline-flex;align-items:center;gap:10px;margin-left:12px;
position:relative;
}
#${CFG.ids.toggle} .${CFG.cls.btn}{
width:36px;height:36px;border-radius:10px;
border:1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,.12));
background:rgba(255,255,255,.06);
color:var(--yt-spec-text-primary, #fff);
display:inline-flex;align-items:center;justify-content:center;
padding:0;cursor:pointer;
box-sizing:border-box;
user-select:none;
-webkit-user-select:none;
touch-action:manipulation;
}
#${CFG.ids.toggle} .${CFG.cls.btn}:hover{ background:rgba(255,255,255,.10); border-color:rgba(255,255,255,.18) }
#${CFG.ids.toggle} .${CFG.cls.btn}[data-active]{ background:rgba(255,255,255,.16); border-color:rgba(255,255,255,.28) }
#${CFG.ids.toggle} .${CFG.cls.btn} .${CFG.cls.btnIcon}{ width:20px;height:20px;display:block; pointer-events:none; }
#${CFG.ids.toggle} .${CFG.cls.btn} svg{ width:20px;height:20px;fill:currentColor;opacity:.95;display:block; pointer-events:none; }
ytd-browse[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container,
ytd-two-column-browse-results-renderer[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container{
display:flex !important;align-items:center !important;
position:relative !important;
}
ytd-browse[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container #spacer,
ytd-two-column-browse-results-renderer[page-subtype="subscriptions"] ytd-shelf-renderer .grid-subheader #title-container #spacer{
flex:1 1 auto !important;
}
html[${attr}="list"] ytd-rich-grid-renderer{ --ytd-rich-grid-item-max-width: 100% !important; }
html[${attr}="list"] ytd-rich-grid-renderer #contents{
display:block !important;
max-width: ${maxW + (Number(maxW) ? "px" : "")} !important;
margin:0 auto !important;
width:100% !important;
}
html[${attr}="list"] ytd-rich-item-renderer{
display:block !important;width:100% !important;
padding:${rowPadY}px 0 !important;margin:0 !important;
${L.separator ? "border-bottom:1px solid var(--yt-spec-10-percent-layer, var(--yslv-sep, rgba(0,0,0,.10))) !important;" : ""}
}
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort}{
display:inline-block !important;
width:auto !important;
padding:0 10px 0 0 !important;
margin:0 !important;
border-bottom:0 !important;
vertical-align:top !important;
}
html[${attr}="list"] ytd-rich-item-renderer:first-child{ padding-top:0 !important; }
html[${attr}="list"] ytd-rich-item-renderer:last-child{ border-bottom:0 !important; }
html[${attr}="list"] .${CFG.cls.rowHead}{
display:flex !important;align-items:center !important;
gap:${headGap}px !important;margin:0 0 ${headMb}px 0 !important;
}
html[${attr}="list"] .${CFG.cls.rowHead} .yt-lockup-metadata-view-model__avatar{
display:flex !important;
width:${headAv}px !important;height:${headAv}px !important;min-width:${headAv}px !important;
margin:0 !important;
}
html[${attr}="list"] .${CFG.cls.rowHead} yt-avatar-shape,
html[${attr}="list"] .${CFG.cls.rowHead} .yt-spec-avatar-shape{
width:${headAv}px !important;height:${headAv}px !important;
}
html[${attr}="list"] .${CFG.cls.rowHeadName}{
color:var(--yt-spec-text-primary) !important;
text-decoration:none !important;
font-weight:700 !important;
font-size:18px !important;
line-height:1.15 !important;
display:inline-flex !important;
align-items:center !important;
}
html[${attr}="list"] .yt-lockup-view-model.yt-lockup-view-model--vertical{
display:grid !important;
grid-template-columns:${thumbW}px minmax(0,1fr) auto !important;
grid-template-rows:auto auto !important;
column-gap:18px !important;row-gap:10px !important;
align-items:start !important;
}
html[${attr}="list"] .yt-lockup-view-model__content-image{
grid-column:1 !important;grid-row:1 / span 2 !important;
width:${thumbW}px !important;min-width:${thumbW}px !important;max-width:${thumbW}px !important;
}
html[${attr}="list"] .yt-lockup-view-model__content-image img{
width:100% !important;height:auto !important;border-radius:${radius}px !important;
object-fit:cover !important;display:block !important;
}
html[${attr}="list"] .yt-lockup-view-model__metadata{
grid-column:2 !important;grid-row:1 / span 2 !important;min-width:0 !important;
}
html[${attr}="list"] .yt-lockup-metadata-view-model__menu-button{
grid-column:3 !important;grid-row:1 !important;justify-self:end !important;align-self:start !important;
}
html[${attr}="list"] .yt-lockup-metadata-view-model__title{
display:-webkit-box !important;-webkit-box-orient:vertical !important;-webkit-line-clamp:${titleClamp} !important;
overflow:hidden !important;white-space:normal !important;line-height:1.35 !important;font-weight:600 !important;
}
html[${attr}="list"] .yt-lockup-view-model__metadata .yt-lockup-metadata-view-model__avatar{ display:none !important; }
html[${attr}="list"] .yt-lockup-metadata-view-model__metadata yt-content-metadata-view-model{
display:block !important;
position:absolute !important;
left:-99999px !important;
top:auto !important;
width:1px !important;
height:1px !important;
overflow:hidden !important;
opacity:0 !important;
pointer-events:none !important;
}
html[${attr}="list"] .${CFG.cls.metaRow}{
display:flex !important;align-items:center !important;
gap:${metaGap}px !important;margin-top:6px !important;min-width:0 !important;
}
html[${attr}="list"] .${CFG.cls.metaCh}{
min-width:0 !important;
color:var(--yt-spec-text-secondary) !important;
font-size:12px !important;line-height:1.35 !important;
white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important;
flex:0 1 auto !important;
display:flex !important;
align-items:center !important;
}
html[${attr}="list"] .${CFG.cls.metaCh} a{
color:inherit !important;
text-decoration:none !important;
display:inline-flex !important;
align-items:center !important;
min-width:0 !important;
}
html[${attr}="list"] .${CFG.cls.metaCh} a > span{
display:inline-flex !important;
align-items:center !important;
}
html[${attr}="list"] .${CFG.cls.metaCh} .yt-icon-shape.ytSpecIconShapeHost{
margin-right:2px !important;
}
html[${attr}="list"] .${CFG.cls.metaRt}{
color:var(--yt-spec-text-secondary) !important;
font-size:12px !important;line-height:1.35 !important;
white-space:nowrap !important;
opacity:.95 !important;
flex:0 0 auto !important;
}
html[${attr}="list"] .${CFG.cls.desc}{
margin-top:${descMt}px !important;
font-size:12px !important;line-height:1.45 !important;
color:var(--yt-spec-text-secondary) !important;
display:-webkit-box !important;-webkit-box-orient:vertical !important;-webkit-line-clamp:${descClamp} !important;
overflow:hidden !important;white-space:normal !important;
}
html[${attr}="list"] .${CFG.cls.desc}.${CFG.cls.descSkel}{
display:${S.enabled ? "block" : "none"} !important;
-webkit-line-clamp:unset !important;
overflow:hidden !important;
color:transparent !important;
position:relative !important;
}
html[${attr}="list"] .${CFG.cls.desc}.${CFG.cls.descSkel} > span{
display:block !important;
border-radius:${S.r}px !important;
opacity:1 !important;
pointer-events:none !important;
background-color:var(--yslv-skel-base, rgba(0,0,0,.10)) !important;
background-image:linear-gradient(
90deg,
var(--yslv-skel-base, rgba(0,0,0,.10)) 0%,
var(--yt-spec-10-percent-layer, rgba(255,255,255,.20)) 50%,
var(--yslv-skel-base, rgba(0,0,0,.10)) 100%
) !important;
background-size:220% 100% !important;
background-position:var(${CFG.cssVars.shimmerX}, 200%) 0 !important;
transform:translateZ(0) !important;
will-change:background-position !important;
}
${skLineRules}
html[${attr}="list"]{
${CFG.cssVars.shortW}:${shortW}px;
}
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} #content{
width:var(${CFG.cssVars.shortW}) !important;
}
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} a.reel-item-endpoint,
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} a.shortsLockupViewModelHostEndpoint.reel-item-endpoint{
width:var(${CFG.cssVars.shortW}) !important;
display:block !important;
}
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} yt-thumbnail-view-model,
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} .ytThumbnailViewModelImage,
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} .shortsLockupViewModelHostThumbnailParentContainer{
width:var(${CFG.cssVars.shortW}) !important;
aspect-ratio:2/3 !important;
}
html[${attr}="list"] ytd-rich-item-renderer.${CFG.cls.isShort} img.ytCoreImageHost{
width:100% !important;
height:100% !important;
object-fit:cover !important;
border-radius:${radius}px !important;
display:block !important;
}
html[data-yslv-subs-view="list"]
ytd-rich-shelf-renderer #dismissible{
margin-top:2rem !important;
}
html[data-yslv-subs-view="list"]
ytd-rich-item-renderer.yslv-is-short > #content{
padding-bottom:3rem !important;
}
html[dark][${attr}="list"]{ --yslv-sep: rgba(255,255,255,.18); --yslv-skel-base: rgba(255,255,255,.10); }
html:not([dark])[${attr}="list"]{ --yslv-sep: rgba(0,0,0,.10); --yslv-skel-base: rgba(0,0,0,.10); }
// "Notify Me" buttons for upcoming videos are full width fix by: https://greasyfork.org/en/users/1568138-boxfriend
html[${attr}="list"] .ytLockupAttachmentsViewModelHost{
width: fit-content !important;
}
html[${attr}="list"]
ytd-counterfactual-renderer.ytd-rich-section-renderer,
html[${attr}="list"]
#content.ytd-rich-section-renderer > ytd-rich-list-header-renderer.ytd-rich-section-renderer,
html[${attr}="list"]
#content.ytd-rich-section-renderer > ytd-shelf-renderer.ytd-rich-section-renderer,
html[${attr}="list"]
#content.ytd-rich-section-renderer > ytd-rich-shelf-renderer.ytd-rich-section-renderer,
html[${attr}="list"]
#content.ytd-rich-section-renderer > ytd-inline-survey-renderer.ytd-rich-section-renderer[is-dismissed]{
margin-bottom:1rem !important;
}
`.trim()
document.documentElement.appendChild(el)
STATE.styleEl = el
}
function ensureToggle() {
const existing = document.getElementById(CFG.ids.toggle)
if (existing && existing.isConnected) {
paintToggle()
return
}
const subscribeBtn = getActiveSubsDoc().querySelector(CFG.toggleMountSelector)
const titleContainer = subscribeBtn?.closest?.("#title-container") || null
if (!subscribeBtn || !titleContainer) return
document.querySelectorAll(`#${CFG.ids.toggle}`).forEach(n => n.remove())
const root = document.createElement("div")
root.id = CFG.ids.toggle
const mkBtn = (mode, label, svg) => {
const b = document.createElement("button")
b.className = CFG.cls.btn
b.type = "button"
b.setAttribute("data-mode", mode)
b.setAttribute("aria-label", label)
const ic = document.createElement("span")
ic.className = CFG.cls.btnIcon
ic.appendChild(svg)
b.appendChild(ic)
return b
}
const bGrid = mkBtn("grid", "Grid", svgEl(["M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z"]))
const bList = mkBtn(
"list",
"List",
svgEl(["M4 6h3v3H4V6zm5 0h11v3H9V6zM4 11h3v3H4v-3zm5 0h11v3H9v-3zM4 16h3v3H4v-3zm5 0h11v3H9v-3z"])
)
root.appendChild(bGrid)
root.appendChild(bList)
root.addEventListener("click", e => {
const btn = e.target?.closest?.("button[data-mode]")
if (!btn) return
const mode = btn.getAttribute("data-mode")
if (mode !== "grid" && mode !== "list") return
if (mode === STATE.view) return
if (STATE.view === "list") cleanupListArtifacts()
resetNavState()
applyViewAttr(mode)
attachObserver()
ensureDescQueueLoop()
if (mode === "list") {
enqueueAllOnce()
startShimmer()
} else {
stopShimmer()
}
})
subscribeBtn.insertAdjacentElement("afterend", root)
paintToggle()
}
function removeToggle() {
const root = document.getElementById(CFG.ids.toggle)
if (root) root.remove()
}
function paintToggle() {
const root = document.getElementById(CFG.ids.toggle)
if (!root) return
root.querySelectorAll("button[data-mode]").forEach(b => {
const m = b.getAttribute("data-mode")
if (m === STATE.view) b.setAttribute("data-active", "")
else b.removeAttribute("data-active")
})
}
function pickChannelDisplaySource(lockup) {
const a =
lockup.querySelector('yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row a[href^="/@"]') ||
lockup.querySelector('yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row a[href^="/channel/"]') ||
lockup.querySelector('a[href^="/@"]') ||
lockup.querySelector('a[href^="/channel/"]') ||
null
if (a) return a
return (
lockup.querySelector(
'yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row span.yt-content-metadata-view-model__metadata-text'
) || null
)
}
function pickChannelAnchor(lockup) {
return (
lockup.querySelector('yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row a[href^="/@"]') ||
lockup.querySelector(
'yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row a[href^="/channel/"]'
) ||
lockup.querySelector('a[href^="/@"]') ||
lockup.querySelector('a[href^="/channel/"]') ||
null
)
}
function getChannelHref(lockup) {
const a = pickChannelAnchor(lockup)
const href = String(a?.getAttribute?.("href") || "").trim()
if (!href) return ""
try {
return new URL(href, location.origin).href
} catch {
return ""
}
}
function getChannelName(lockup) {
const src = pickChannelDisplaySource(lockup)
return normalizeText(src?.textContent || "")
}
function isIconish(node) {
if (!node || node.nodeType !== 1) return false
if (node.matches("yt-icon-shape, .yt-icon-shape")) return true
if (node.querySelector("yt-icon-shape, .yt-icon-shape")) return true
if (node.querySelector("svg, img")) return true
if (node.getAttribute("role") === "img") return true
if (node.querySelector('[role="img"]')) return true
return false
}
function collectBadgeNodesFromAnchor(a) {
const out = []
if (!a) return out
const candidates = a.querySelectorAll(
".yt-core-attributed-string__image-element, .ytIconWrapperHost, .yt-core-attributed-string__image-element--image-alignment-vertical-center, yt-icon-shape, .yt-icon-shape"
)
const seen = new Set()
for (const el of candidates) {
if (!el) continue
let root =
el.closest(".yt-core-attributed-string__image-element") ||
el.closest(".ytIconWrapperHost") ||
el.closest(".yt-core-attributed-string__image-element--image-alignment-vertical-center") ||
el
if (!root || root === a) continue
if (!isIconish(root)) continue
const key =
root.tagName + "|" + (root.getAttribute("class") || "") + "|" + (root.getAttribute("aria-label") || "")
if (seen.has(key)) continue
seen.add(key)
out.push(root)
}
return out
}
function normalizeMetaAnchorInPlace(a, nameText) {
if (!a) return
const name = normalizeText(nameText || "")
if (!name) return
const badgeRoots = collectBadgeNodesFromAnchor(a)
const badges = []
for (const r of badgeRoots) {
if (!r || !r.isConnected) continue
badges.push(r)
}
for (const b of badges) {
try {
if (b.parentNode) b.parentNode.removeChild(b)
} catch {}
}
clearChildren(a)
a.appendChild(document.createTextNode(name))
for (const b of badges) {
if (!isIconish(b)) continue
const wrap = document.createElement("span")
wrap.style.display = "inline-flex"
wrap.style.alignItems = "center"
wrap.style.marginLeft = "4px"
wrap.appendChild(b)
a.appendChild(wrap)
}
for (const s of Array.from(a.querySelectorAll(":scope > span"))) {
if (!s.querySelector || !isIconish(s)) s.remove()
}
}
function detachMetaAnchorOnce(lockup) {
if (!lockup) return null
if (STATE.movedMetaAnchors.has(lockup)) return STATE.movedMetaAnchors.get(lockup)?.a || null
const a = pickChannelAnchor(lockup)
if (!a || !a.parentNode) return null
const parent = a.parentNode
const nextSibling = a.nextSibling
STATE.movedMetaAnchors.set(lockup, { a, parent, nextSibling })
return a
}
function restoreMovedMetaAnchors() {
const entries = []
document.querySelectorAll("yt-lockup-view-model").forEach(lockup => {
const info = STATE.movedMetaAnchors.get(lockup)
if (!info) return
entries.push(info)
})
for (const info of entries) {
const { a, parent, nextSibling } = info
if (!a || !parent) continue
if (!a.isConnected) continue
if (a.parentNode === parent) continue
try {
if (nextSibling && nextSibling.parentNode === parent) parent.insertBefore(a, nextSibling)
else parent.appendChild(a)
} catch {}
}
STATE.movedMetaAnchors = new WeakMap()
}
function setHeaderNameTextOnly(destLink, lockup) {
if (!destLink) return
const href = getChannelHref(lockup)
destLink.href = href || "javascript:void(0)"
const src = pickChannelDisplaySource(lockup)
setTextOnly(destLink, src?.textContent || "")
}
function moveAvatarToHeaderOnce(item, lockup, head) {
if (!item || !lockup || !head) return null
if (STATE.movedAvatars.has(item)) return STATE.movedAvatars.get(item)?.avatarEl || null
const avatarEl = lockup.querySelector(".yt-lockup-metadata-view-model__avatar")
if (!avatarEl || !avatarEl.parentNode) return null
const parent = avatarEl.parentNode
const nextSibling = avatarEl.nextSibling
STATE.movedAvatars.set(item, { avatarEl, parent, nextSibling })
try {
head.insertBefore(avatarEl, head.firstChild)
} catch {}
return avatarEl
}
function ensureRowHeader(item, lockup) {
if (!CFG.list.rowHead.enabled) return
let head = item.querySelector(`:scope > .${CFG.cls.rowHead}`)
if (!head) {
head = document.createElement("div")
head.className = CFG.cls.rowHead
item.prepend(head)
}
head.style.display = "flex"
let name = head.querySelector(`:scope > a.${CFG.cls.rowHeadName}`)
if (!name) {
name = document.createElement("a")
name.className = CFG.cls.rowHeadName
head.appendChild(name)
}
setHeaderNameTextOnly(name, lockup)
moveAvatarToHeaderOnce(item, lockup, head)
}
function getRightMetaRowsText(lockup) {
const chName = getChannelName(lockup)
const rows = Array.from(
lockup.querySelectorAll("yt-content-metadata-view-model .yt-content-metadata-view-model__metadata-row")
)
.map(r => normalizeText(r.textContent || ""))
.filter(Boolean)
.filter(t => (chName ? t !== chName : true))
if (!rows.length) return ""
const out = []
const seen = new Set()
for (const t of rows) {
const k = t.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(t)
}
if (!out.length) return ""
if (out.length === 1) {
if (chName && out[0] === chName) return ""
return out[0]
}
return out.slice(1).join(" • ")
}
function ensureInlineMeta(textContainer, lockup) {
let row = textContainer.querySelector(`.${CFG.cls.metaRow}`)
if (!row) {
row = document.createElement("div")
row.className = CFG.cls.metaRow
const heading =
textContainer.querySelector(".yt-lockup-metadata-view-model__heading-reset") || textContainer.querySelector("h3")
if (heading && heading.parentNode) heading.parentNode.insertBefore(row, heading.nextSibling)
else textContainer.appendChild(row)
}
row.style.display = "flex"
let left = row.querySelector(`:scope > .${CFG.cls.metaCh}`)
if (!left) {
left = document.createElement("div")
left.className = CFG.cls.metaCh
row.appendChild(left)
}
const srcA = detachMetaAnchorOnce(lockup)
const chName = getChannelName(lockup)
if (srcA) {
try {
srcA.style.margin = "0"
} catch {}
normalizeMetaAnchorInPlace(srcA, chName)
clearChildren(left)
left.appendChild(srcA)
} else {
let link = left.querySelector("a")
if (!link) {
link = document.createElement("a")
left.appendChild(link)
}
link.href = getChannelHref(lockup) || "javascript:void(0)"
const src = pickChannelDisplaySource(lockup)
if (src) {
cloneInto(link, src)
} else {
setTextOnly(link, chName || "")
}
}
const right = getRightMetaRowsText(lockup)
let r = row.querySelector(`:scope > .${CFG.cls.metaRt}`)
if (right) {
if (!r) {
r = document.createElement("div")
r.className = CFG.cls.metaRt
row.appendChild(r)
}
r.textContent = right
r.style.display = ""
} else if (r) {
r.textContent = ""
r.style.display = "none"
}
return row
}
function pickPrimaryVideoAnchor(lockup) {
return (
lockup.querySelector('a.yt-lockup-view-model__content-image[href^="/watch"]') ||
lockup.querySelector('a.yt-lockup-view-model__content-image[href^="/shorts/"]') ||
lockup.querySelector('a[href^="/watch"][id="thumbnail"]') ||
lockup.querySelector('a[href^="/shorts/"][id="thumbnail"]') ||
lockup.querySelector('a[href^="/shorts/"].reel-item-endpoint') ||
lockup.querySelector('a[href^="/watch"]') ||
lockup.querySelector('a[href^="/shorts/"]') ||
null
)
}
function isShortsHref(href) {
const h = String(href || "")
return h.startsWith("/shorts/") || h.includes("youtube.com/shorts/")
}
function extractVideoIdFromHref(href) {
const h = String(href || "")
if (!h) return ""
if (isShortsHref(h)) {
try {
const u = new URL(h, location.origin)
const parts = u.pathname.split("/").filter(Boolean)
const idx = parts.indexOf("shorts")
const id = idx >= 0 ? String(parts[idx + 1] || "") : ""
return id
} catch {
const m = h.match(/\/shorts\/([^?&#/]+)/)
return m ? m[1] : ""
}
}
try {
const u = new URL(h, location.origin)
return u.searchParams.get("v") || ""
} catch {
const m = h.match(/[?&]v=([^&]+)/)
return m ? m[1] : ""
}
}
function ensureDesc(textContainer, lockup) {
let desc = textContainer.querySelector(`.${CFG.cls.desc}`)
if (!desc) {
desc = document.createElement("div")
desc.className = CFG.cls.desc
textContainer.appendChild(desc)
}
const vLink = pickPrimaryVideoAnchor(lockup)
const href = vLink?.getAttribute?.("href") || ""
const vid = extractVideoIdFromHref(href)
if (!vid) {
desc.textContent = ""
desc.style.display = "none"
desc.classList.remove(CFG.cls.descSkel)
delete desc.dataset.yslvVid
return
}
desc.dataset.yslvVid = vid
const mem = STATE.descCache.get(vid)
if (mem != null) {
desc.textContent = mem
desc.style.display = mem ? "" : "none"
desc.classList.remove(CFG.cls.descSkel)
return
}
const stored = getStoredDesc(vid)
if (stored != null) {
STATE.descCache.set(vid, stored)
desc.textContent = stored
desc.style.display = stored ? "" : "none"
desc.classList.remove(CFG.cls.descSkel)
return
}
const S = skNorm()
if (!S.enabled) {
desc.textContent = ""
desc.style.display = "none"
desc.classList.remove(CFG.cls.descSkel)
return
}
desc.style.display = ""
desc.classList.add(CFG.cls.descSkel)
const needs = desc.childElementCount !== S.lines || !desc.querySelector(":scope > span")
if (needs) {
clearChildren(desc)
for (let i = 0; i < S.lines; i++) desc.appendChild(document.createElement("span"))
}
}
function summarizeDesc(raw, sentenceCount, maxChars) {
let s = String(raw || "").trim()
if (!s) return ""
s = s.replace(/\r/g, "").replace(/\n{2,}/g, "\n").replace(/[ \t]{2,}/g, " ").trim()
const seg =
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter(undefined, { granularity: "sentence" }) : null
if (seg) {
const out = []
for (const part of seg.segment(s)) {
const t = String(part.segment || "").trim()
if (!t) continue
out.push(t)
if (out.length >= sentenceCount) break
}
s = out.join(" ").trim()
} else {
const urls = []
s = s.replace(/\bhttps?:\/\/[^\s]+|\bwww\.[^\s]+/gi, m => {
const k = `__YSU${urls.length}__`
urls.push(m)
return k
})
const parts = s.split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean)
s = parts.slice(0, sentenceCount).join(" ").trim()
s = s.replace(/__YSU(\d+)__/g, (_, i) => urls[Number(i)] || "")
}
if (s.length > maxChars) s = s.slice(0, maxChars).trimEnd() + "…"
return s
}
async function fetchDescriptionForVideoId(vid) {
const F = CFG.list.descFetch
if (!F.enabled) return ""
if (!vid) return ""
const mem = STATE.descCache.get(vid)
if (mem != null) return mem
const stored = getStoredDesc(vid)
if (stored != null) {
STATE.descCache.set(vid, stored)
return stored
}
if (STATE.descInFlight.has(vid)) return STATE.descInFlight.get(vid)
if (STATE.descFetches >= F.maxTotalFetchesPerNav) return ""
const p = (async () => {
while (STATE.descActive >= F.maxConcurrent) {
await new Promise(r => setTimeout(r, 35))
}
STATE.descActive++
STATE.descFetches++
try {
const res = await fetch(`https://www.youtube.com/watch?v=${encodeURIComponent(vid)}`, {
credentials: "same-origin",
})
const html = await res.text()
const m = html.match(/ytInitialPlayerResponse\s*=\s*(\{.*?\});/s)
if (!m) return ""
const json = JSON.parse(m[1])
const raw = String(json?.videoDetails?.shortDescription || "").trim()
if (!raw) return ""
return summarizeDesc(raw, F.sentenceCount, F.maxChars)
} catch {
return ""
} finally {
STATE.descActive--
}
})()
STATE.descInFlight.set(vid, p)
const out = await p
STATE.descInFlight.delete(vid)
STATE.descCache.set(vid, out)
setStoredDesc(vid, out)
return out
}
function updateDescDomForVid(vid, text) {
const nodes = document.querySelectorAll(`.${CFG.cls.desc}[data-yslv-vid="${CSS.escape(vid)}"]`)
for (const n of nodes) {
if (!n || !n.isConnected) continue
n.classList.remove(CFG.cls.descSkel)
clearChildren(n)
n.textContent = text || ""
n.style.display = text ? "" : "none"
}
}
function buildDescQueueFromDom() {
if (!STATE.active || STATE.view !== "list") return
const root = getActiveSubsRoot()
const scope = root && root.querySelectorAll ? root : document
const descs = scope.querySelectorAll(`.${CFG.cls.desc}[data-yslv-vid]`)
if (!descs.length) return
let sig = ""
for (const d of descs) {
const vid = d?.dataset?.yslvVid || ""
if (!vid) continue
sig += vid + "|"
}
if (sig === STATE.lastQueueSig) return
STATE.lastQueueSig = sig
for (const d of descs) {
const vid = d?.dataset?.yslvVid || ""
if (!vid) continue
const stored = getStoredDesc(vid)
if (stored != null) {
STATE.descCache.set(vid, stored)
updateDescDomForVid(vid, stored)
continue
}
if (STATE.descCache.has(vid)) continue
if (STATE.descInFlight.has(vid)) continue
if (STATE.descQueued.has(vid)) continue
STATE.descQueued.add(vid)
STATE.descQueue.push(vid)
}
pumpDescQueue()
}
async function pumpDescQueue() {
if (STATE.descPumpRunning) return
STATE.descPumpRunning = true
try {
while (STATE.active && STATE.view === "list" && STATE.descQueue.length) {
const vid = STATE.descQueue.shift()
if (!vid) continue
STATE.descQueued.delete(vid)
const stored = getStoredDesc(vid)
if (stored != null) {
STATE.descCache.set(vid, stored)
updateDescDomForVid(vid, stored)
continue
}
if (STATE.descCache.has(vid)) {
updateDescDomForVid(vid, STATE.descCache.get(vid) || "")
continue
}
const txt = await fetchDescriptionForVideoId(vid)
updateDescDomForVid(vid, txt || "")
}
} finally {
STATE.descPumpRunning = false
}
}
function hasSkeletons() {
return !!document.querySelector(`.${CFG.cls.desc}.${CFG.cls.descSkel}`)
}
function stopShimmer() {
SHIMMER.running = false
if (SHIMMER.raf) cancelAnimationFrame(SHIMMER.raf)
SHIMMER.raf = 0
document.documentElement.style.removeProperty(CFG.cssVars.shimmerX)
}
function startShimmer() {
if (SHIMMER.running) return
SHIMMER.running = true
SHIMMER.t0 = performance.now()
const tick = t => {
if (!SHIMMER.running) return
const S = skNorm()
if (!STATE.active || STATE.view !== "list" || !S.enabled || !hasSkeletons()) {
stopShimmer()
return
}
const phase = ((t - SHIMMER.t0) % S.ms) / S.ms
const x = 200 - phase * 400
document.documentElement.style.setProperty(CFG.cssVars.shimmerX, `${x}%`)
SHIMMER.raf = requestAnimationFrame(tick)
}
SHIMMER.raf = requestAnimationFrame(tick)
}
function ensureDescQueueLoop() {
if (STATE.descTimer) clearInterval(STATE.descTimer)
if (!STATE.active) return
STATE.descTimer = setInterval(() => {
if (!STATE.active || STATE.view !== "list") {
stopShimmer()
return
}
pruneDescStore()
buildDescQueueFromDom()
if (hasSkeletons()) startShimmer()
else stopShimmer()
}, CFG.perf.descQueueIntervalMs)
}
function patchItem(item) {
if (!STATE.active || STATE.view !== "list") return
if (!item || item.nodeType !== 1) return
if (item.tagName !== "YTD-RICH-ITEM-RENDERER") return
if (STATE.processedItems.has(item)) return
const shortsLockup = item.querySelector("ytm-shorts-lockup-view-model-v2, ytm-shorts-lockup-view-model")
if (shortsLockup && CFG.list.shorts.enabled) {
STATE.processedItems.add(item)
item.classList.add(CFG.cls.isShort)
return
}
item.classList.remove(CFG.cls.isShort)
const lockup = item.querySelector("yt-lockup-view-model")
if (!lockup) return
const textContainer =
lockup.querySelector(".yt-lockup-metadata-view-model__text-container") ||
lockup.querySelector("yt-lockup-metadata-view-model")
if (!textContainer) return
STATE.processedItems.add(item)
ensureRowHeader(item, lockup)
ensureInlineMeta(textContainer, lockup)
ensureDesc(textContainer, lockup)
}
function enqueue(node) {
if (!STATE.active || STATE.view !== "list") return
if (!node || node.nodeType !== 1) return
if (node.tagName === "YTD-RICH-ITEM-RENDERER") {
if (STATE.qSet.has(node)) return
STATE.qSet.add(node)
STATE.q.push(node)
scheduleProcess()
return
}
const found = node.querySelectorAll ? node.querySelectorAll("ytd-rich-item-renderer") : []
if (found && found.length) {
for (const it of found) enqueue(it)
}
}
function scheduleProcess() {
if (STATE.processing) return
STATE.processing = true
const run = () => {
STATE.processing = false
processQueue()
}
if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 300 })
else setTimeout(run, 80)
}
function processQueue() {
if (!STATE.active || STATE.view !== "list") {
STATE.q.length = 0
STATE.qSet.clear()
return
}
let n = 0
while (STATE.q.length && n < CFG.perf.maxItemsPerTick) {
const item = STATE.q.shift()
STATE.qSet.delete(item)
patchItem(item)
n++
}
buildDescQueueFromDom()
if (STATE.q.length) scheduleProcess()
}
function enqueueAllOnce() {
if (!STATE.active || STATE.view !== "list") return
const root = getActiveSubsRoot()
const scope = root && root.querySelectorAll ? root : document
const items = scope.querySelectorAll ? scope.querySelectorAll("ytd-rich-item-renderer") : []
for (const it of items) enqueue(it)
}
function attachObserver() {
if (!STATE.active) return
const target = getActiveSubsRoot() || document.documentElement
if (STATE.observedTarget === target && STATE.mo) return
if (STATE.mo) STATE.mo.disconnect()
STATE.observedTarget = target
STATE.mo = new MutationObserver(muts => {
if (!STATE.active || STATE.view !== "list") return
for (const m of muts) {
for (const node of m.addedNodes) enqueue(node)
}
})
STATE.mo.observe(target, { childList: true, subtree: true })
}
function attachPageManagerObserver() {
if (STATE.pmMo) return
const pm = document.querySelector("ytd-page-manager")
if (!pm) return
STATE.pmMo = new MutationObserver(() => {
if (!STATE.active) return
attachObserver()
ensureToggleMountLoop()
if (STATE.view === "list") {
setTimeout(() => {
if (!STATE.active || STATE.view !== "list") return
enqueueAllOnce()
}, 60)
}
})
STATE.pmMo.observe(pm, { childList: true, subtree: true })
}
function restoreMovedAvatars() {
document.querySelectorAll("ytd-rich-item-renderer").forEach(item => {
const info = STATE.movedAvatars.get(item)
if (!info) return
const { avatarEl, parent, nextSibling } = info
if (!avatarEl || !parent) return
if (!avatarEl.isConnected) return
if (avatarEl.parentNode === parent) return
try {
if (nextSibling && nextSibling.parentNode === parent) parent.insertBefore(avatarEl, nextSibling)
else parent.appendChild(avatarEl)
} catch {}
})
STATE.movedAvatars = new WeakMap()
}
function cleanupListArtifacts() {
restoreMovedAvatars()
restoreMovedMetaAnchors()
document.querySelectorAll(`.${CFG.cls.rowHead}`).forEach(n => n.remove())
document.querySelectorAll(`.${CFG.cls.metaRow}`).forEach(n => n.remove())
document.querySelectorAll(`.${CFG.cls.desc}`).forEach(n => n.remove())
STATE.descQueue.length = 0
STATE.descQueued.clear()
STATE.lastQueueSig = ""
}
function resetNavState() {
STATE.processedItems = new WeakSet()
STATE.q.length = 0
STATE.qSet.clear()
STATE.descInFlight.clear()
STATE.descCache.clear()
STATE.descFetches = 0
STATE.descActive = 0
STATE.descQueue.length = 0
STATE.descQueued.clear()
STATE.descPumpRunning = false
STATE.lastQueueSig = ""
STATE.observedTarget = null
}
function teardown() {
stopShimmer()
if (STATE.view === "list") cleanupListArtifacts()
if (STATE.mo) {
STATE.mo.disconnect()
STATE.mo = null
}
STATE.observedTarget = null
if (STATE.descTimer) {
clearInterval(STATE.descTimer)
STATE.descTimer = 0
}
resetNavState()
removeToggle()
clearViewAttr()
}
function ensureToggleMountLoop() {
if (!STATE.active) return
ensureToggle()
if (STATE.active && !document.getElementById(CFG.ids.toggle)) setTimeout(ensureToggleMountLoop, 250)
}
function pageSig() {
return `${location.pathname}|${location.search}|${document.querySelector("ytd-page-manager") ? "pm" : "nopm"}`
}
function apply() {
ensureDescStoreLoaded()
pruneDescStore()
ensureStyle()
ensureToggleMountLoop()
attachObserver()
attachPageManagerObserver()
ensureDescQueueLoop()
if (STATE.view === "list") {
enqueueAllOnce()
startShimmer()
} else {
stopShimmer()
}
}
function syncActive(isNavFinish) {
const shouldBeActive = isSubsPage()
const sig = pageSig()
if (shouldBeActive && !STATE.active) {
STATE.active = true
STATE.lastPageSig = sig
applyViewAttr(loadView())
apply()
return
}
if (!shouldBeActive && STATE.active) {
STATE.active = false
STATE.lastPageSig = sig
teardown()
return
}
if (shouldBeActive && STATE.active) {
ensureToggleMountLoop()
paintToggle()
attachObserver()
if (STATE.view === "list") {
if (isNavFinish && sig !== STATE.lastPageSig) {
STATE.lastPageSig = sig
resetNavState()
enqueueAllOnce()
startShimmer()
}
} else {
stopShimmer()
}
}
}
function init() {
syncActive(true)
window.addEventListener(
"yt-navigate-finish",
() => {
syncActive(true)
},
{ passive: true }
)
window.addEventListener(
"popstate",
() => {
syncActive(true)
},
{ passive: true }
)
setTimeout(() => {
attachPageManagerObserver()
}, 250)
}
init()
})()