YouTube Sub Feed Filter 2

Set up filters for your sub feed

  1. // ==UserScript==
  2. // @name YouTube Sub Feed Filter 2
  3. // @version 1.48
  4. // @description Set up filters for your sub feed
  5. // @author Callum Latham
  6. // @namespace https://greasyfork.org/users/696211-ctl2
  7. // @license MIT
  8. // @match *://www.youtube.com/*
  9. // @match *://youtube.com/*
  10. // @exclude *://www.youtube.com/embed/*
  11. // @exclude *://youtube.com/embed/*
  12. // @require https://update.greasyfork.org/scripts/446506/1537901/%24Config.js
  13. // @grant GM.setValue
  14. // @grant GM.getValue
  15. // @grant GM.deleteValue
  16. // ==/UserScript==
  17.  
  18. /* global $Config */
  19.  
  20. (() => {
  21. // Don't run in frames (e.g. stream chat frame)
  22. if (window.parent !== window) {
  23. // noinspection JSAnnotator
  24. return;
  25. }
  26.  
  27. // User config
  28.  
  29. const LONG_PRESS_TIME = 400;
  30. const REGEXP_FLAGS = 'i';
  31.  
  32. // Dev config
  33.  
  34. const VIDEO_TYPE_IDS = {
  35. GROUPS: {
  36. ALL: 'All',
  37. STREAMS: 'Streams',
  38. PREMIERES: 'Premieres',
  39. NONE: 'None',
  40. },
  41. INDIVIDUALS: {
  42. STREAMS_SCHEDULED: 'Scheduled Streams',
  43. STREAMS_LIVE: 'Live Streams',
  44. STREAMS_FINISHED: 'Finished Streams',
  45. PREMIERES_SCHEDULED: 'Scheduled Premieres',
  46. PREMIERES_LIVE: 'Live Premieres',
  47. SHORTS: 'Shorts',
  48. FUNDRAISERS: 'Fundraisers',
  49. NORMAL: 'Basic Videos',
  50. },
  51. };
  52.  
  53. const CUTOFF_VALUES = [
  54. 'Minimum',
  55. 'Maximum',
  56. ];
  57.  
  58. const BADGE_VALUES = [
  59. 'Exclude',
  60. 'Include',
  61. 'Require',
  62. ];
  63.  
  64. function getVideoTypes(children) {
  65. const registry = new Set();
  66. const register = (value) => {
  67. if (registry.has(value)) {
  68. throw new Error(`Overlap found at '${value}'.`);
  69. }
  70. registry.add(value);
  71. };
  72. for (const {value} of children) {
  73. switch (value) {
  74. case VIDEO_TYPE_IDS.GROUPS.ALL:
  75. Object.values(VIDEO_TYPE_IDS.INDIVIDUALS).forEach(register);
  76. break;
  77. case VIDEO_TYPE_IDS.GROUPS.STREAMS:
  78. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED);
  79. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE);
  80. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED);
  81. break;
  82. case VIDEO_TYPE_IDS.GROUPS.PREMIERES:
  83. register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED);
  84. register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_LIVE);
  85. break;
  86. default:
  87. register(value);
  88. }
  89. }
  90. return registry;
  91. }
  92.  
  93. const $config = new $Config(
  94. 'YTSFF_TREE',
  95. (() => {
  96. const regexPredicate = (value) => {
  97. try {
  98. RegExp(value);
  99. } catch {
  100. return 'Value must be a valid regular expression.';
  101. }
  102. return true;
  103. };
  104. const videoTypeOptions = Object.values({
  105. ...VIDEO_TYPE_IDS.GROUPS,
  106. ...VIDEO_TYPE_IDS.INDIVIDUALS,
  107. });
  108. return {
  109. get: (_, configs) => Object.assign(...configs),
  110. children: [
  111. {
  112. label: 'Filters',
  113. get: (() => {
  114. const getRegex = ({children}) => children.length === 0 ?
  115. null :
  116. new RegExp(children.map(({value}) => `(${value})`).join('|'), REGEXP_FLAGS);
  117. return ({children}) => ({
  118. filters: children.map(({'children': [channel, video, type]}) => ({
  119. channels: getRegex(channel),
  120. videos: getRegex(video),
  121. types: type.children.length === 0 ? Object.values(VIDEO_TYPE_IDS.INDIVIDUALS) : getVideoTypes(type.children),
  122. })),
  123. });
  124. })(),
  125. children: [],
  126. seed: {
  127. label: 'Filter Name',
  128. value: '',
  129. children: [
  130. {
  131. label: 'Channel Regex',
  132. children: [],
  133. seed: {
  134. value: '^',
  135. predicate: regexPredicate,
  136. },
  137. },
  138. {
  139. label: 'Video Regex',
  140. children: [],
  141. seed: {
  142. value: '^',
  143. predicate: regexPredicate,
  144. },
  145. },
  146. {
  147. label: 'Video Types',
  148. children: [
  149. {
  150. value: VIDEO_TYPE_IDS.GROUPS.ALL,
  151. options: videoTypeOptions,
  152. },
  153. ],
  154. seed: {
  155. value: VIDEO_TYPE_IDS.GROUPS.NONE,
  156. options: videoTypeOptions,
  157. },
  158. childPredicate: (children) => {
  159. try {
  160. getVideoTypes(children);
  161. } catch ({message}) {
  162. return message;
  163. }
  164. return true;
  165. },
  166. },
  167. ],
  168. },
  169. },
  170. {
  171. label: 'Cutoffs',
  172. get: ({children}) => ({
  173. cutoffs: children.map(({children}) => {
  174. const boundaries = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
  175. for (const {'children': [{'value': boundary}, {value}]} of children) {
  176. boundaries[boundary === CUTOFF_VALUES[0] ? 0 : 1] = value;
  177. }
  178. return boundaries;
  179. }),
  180. }),
  181. children: [
  182. {
  183. label: 'Watched (%)',
  184. children: [],
  185. seed: {
  186. childPredicate: ([{'value': boundary}, {value}]) => {
  187. if (boundary === CUTOFF_VALUES[0]) {
  188. return value < 100 ? true : 'Minimum must be less than 100%';
  189. }
  190. return value > 0 ? true : 'Maximum must be greater than 0%';
  191. },
  192. children: [
  193. {
  194. value: CUTOFF_VALUES[1],
  195. options: CUTOFF_VALUES,
  196. },
  197. {value: 100},
  198. ],
  199. },
  200. },
  201. {
  202. label: 'View Count',
  203. children: [],
  204. seed: {
  205. childPredicate: ([{'value': boundary}, {value}]) => {
  206. if (boundary === CUTOFF_VALUES[1]) {
  207. return value > 0 ? true : 'Maximum must be greater than 0';
  208. }
  209. return true;
  210. },
  211. children: [
  212. {
  213. value: CUTOFF_VALUES[0],
  214. options: CUTOFF_VALUES,
  215. },
  216. {
  217. value: 0,
  218. predicate: (value) => Math.floor(value) === value ? true : 'Value must be an integer',
  219. },
  220. ],
  221. },
  222. },
  223. {
  224. label: 'Duration (Minutes)',
  225. children: [],
  226. seed: {
  227. childPredicate: ([{'value': boundary}, {value}]) => {
  228. if (boundary === CUTOFF_VALUES[1]) {
  229. return value > 0 ? true : 'Maximum must be greater than 0';
  230. }
  231. return true;
  232. },
  233. children: [
  234. {
  235. value: CUTOFF_VALUES[0],
  236. options: CUTOFF_VALUES,
  237. },
  238. {value: 0},
  239. ],
  240. },
  241. },
  242. ],
  243. },
  244. {
  245. label: 'Badges',
  246. get: ({children}) => ({badges: children.map(({value}) => BADGE_VALUES.indexOf(value))}),
  247. children: [
  248. {
  249. label: 'Verified',
  250. value: BADGE_VALUES[1],
  251. options: BADGE_VALUES,
  252. },
  253. {
  254. label: 'Official Artist',
  255. value: BADGE_VALUES[1],
  256. options: BADGE_VALUES,
  257. },
  258. ],
  259. },
  260. ],
  261. };
  262. })(),
  263. {
  264. headBase: '#c80000',
  265. headButtonExit: '#000000',
  266. borderHead: '#ffffff',
  267. borderTooltip: '#c80000',
  268. },
  269. {
  270. zIndex: 10000,
  271. scrollbarColor: 'initial',
  272. },
  273. );
  274.  
  275. const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
  276.  
  277. // State
  278.  
  279. let button;
  280.  
  281. // Video element helpers
  282.  
  283. function getSubPage() {
  284. return document.querySelector('.ytd-page-manager[page-subtype="subscriptions"]');
  285. }
  286.  
  287. function getAllVideos() {
  288. const subPage = getSubPage();
  289. return [...subPage.querySelectorAll('#primary > ytd-rich-grid-renderer > #contents > :not(:first-child):not(ytd-continuation-item-renderer)')];
  290. }
  291.  
  292. function firstWordEquals(element, word) {
  293. return element.innerText.split(' ')[0] === word;
  294. }
  295.  
  296. function getVideoBadges(video) {
  297. return video.querySelectorAll('.video-badge');
  298. }
  299.  
  300. function getChannelBadges(video) {
  301. const container = video.querySelector('ytd-badge-supported-renderer.ytd-channel-name');
  302. return container ? [...container.querySelectorAll('.badge')] : [];
  303. }
  304.  
  305. function isShorts(video) {
  306. return video.matches('[is-shorts] *');
  307. }
  308.  
  309. function getMetadataLine(video) {
  310. return video.querySelector(isShorts(video) ? '.shortsLockupViewModelHostOutsideMetadata' : '#metadata-line');
  311. }
  312.  
  313. function isScheduled(video) {
  314. if (isShorts(video)) {
  315. return false;
  316. }
  317. return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED](video)
  318. || VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED](video);
  319. }
  320.  
  321. function getUploadTimeNode(video) {
  322. const children = [...getMetadataLine(video).children].filter((child) => child.matches('.inline-metadata-item'));
  323. return children.length > 1 ? children[1] : null;
  324. }
  325.  
  326. // Config testers
  327.  
  328. const VIDEO_PREDICATES = {
  329. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
  330. const metadataLine = getMetadataLine(video);
  331. return firstWordEquals(metadataLine, 'Scheduled');
  332. },
  333. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
  334. for (const badge of getVideoBadges(video)) {
  335. if (firstWordEquals(badge, 'LIVE')) {
  336. return true;
  337. }
  338. }
  339. return false;
  340. },
  341. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
  342. const uploadTimeNode = getUploadTimeNode(video);
  343. return uploadTimeNode && firstWordEquals(uploadTimeNode, 'Streamed');
  344. },
  345. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_SCHEDULED]: (video) => {
  346. const metadataLine = getMetadataLine(video);
  347. return firstWordEquals(metadataLine, 'Premieres');
  348. },
  349. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERES_LIVE]: (video) => {
  350. for (const badge of getVideoBadges(video)) {
  351. if (firstWordEquals(badge, 'PREMIERING') || firstWordEquals(badge, 'PREMIERE')) {
  352. return true;
  353. }
  354. }
  355. return false;
  356. },
  357. [VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: isShorts,
  358. [VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
  359. const uploadTimeNode = getUploadTimeNode(video);
  360. return uploadTimeNode ? new RegExp('^\\d+ .+ ago$').test(uploadTimeNode.innerText) : false;
  361. },
  362. [VIDEO_TYPE_IDS.INDIVIDUALS.FUNDRAISERS]: (video) => {
  363. for (const badge of getVideoBadges(video)) {
  364. if (firstWordEquals(badge, 'Fundraiser')) {
  365. return true;
  366. }
  367. }
  368. return false;
  369. },
  370. };
  371.  
  372. const CUTOFF_GETTERS = [
  373. // Watched %
  374. (video) => {
  375. const progressBar = video.querySelector('#progress');
  376. if (!progressBar) {
  377. return 0;
  378. }
  379. return Number.parseInt(progressBar.style.width.slice(0, -1));
  380. },
  381. // View count
  382. (video) => {
  383. if (isScheduled(video)) {
  384. return 0;
  385. }
  386. const {innerText} = [...getMetadataLine(video).children].find(
  387. (child) => child.matches('.inline-metadata-item') || child.matches('.shortsLockupViewModelHostMetadataSubhead'),
  388. );
  389. const [valueString] = innerText.split(' ');
  390. const lastChar = valueString.slice(-1);
  391. if (/\d/.test(lastChar)) {
  392. return Number.parseInt(valueString);
  393. }
  394. const valueNumber = Number.parseFloat(valueString.slice(0, -1));
  395. switch (lastChar) {
  396. case 'B':
  397. return valueNumber * 1000000000;
  398. case 'M':
  399. return valueNumber * 1000000;
  400. case 'K':
  401. return valueNumber * 1000;
  402. }
  403. return valueNumber;
  404. },
  405. // Duration (minutes)
  406. (video) => {
  407. const timeElement = video.querySelector('ytd-thumbnail-overlay-time-status-renderer');
  408. let minutes = 0;
  409. if (timeElement) {
  410. const timeParts = timeElement.innerText.split(':').map((_) => Number.parseInt(_));
  411. let timeValue = 1 / 60;
  412. for (let i = timeParts.length - 1; i >= 0; --i) {
  413. minutes += timeParts[i] * timeValue;
  414. timeValue *= 60;
  415. }
  416. }
  417. return Number.isNaN(minutes) ? 0 : minutes;
  418. },
  419. ];
  420.  
  421. const BADGE_PREDICATES = [
  422. // Verified
  423. (video) => getChannelBadges(video)
  424. .some((badge) => badge.classList.contains('badge-style-type-verified')),
  425. // Official Artist
  426. (video) => getChannelBadges(video)
  427. .some((badge) => badge.classList.contains('badge-style-type-verified-artist')),
  428. ];
  429.  
  430. // Hider functions
  431.  
  432. function loadVideo(video) {
  433. return new Promise((resolve) => {
  434. const test = () => {
  435. if (video.querySelector('#interaction')) {
  436. observer.disconnect();
  437. resolve();
  438. }
  439. };
  440. const observer = new MutationObserver(test);
  441. observer.observe(video, {
  442. childList: true,
  443. subtree: true,
  444. attributes: true,
  445. attributeOldValue: true,
  446. });
  447. test();
  448. });
  449. }
  450.  
  451. function shouldHide({filters, cutoffs, badges}, video) {
  452. for (let i = 0; i < BADGE_PREDICATES.length; ++i) {
  453. if (badges[i] !== 1 && Boolean(badges[i]) !== BADGE_PREDICATES[i](video)) {
  454. return true;
  455. }
  456. }
  457. for (let i = 0; i < CUTOFF_GETTERS.length; ++i) {
  458. const [lowerBound, upperBound] = cutoffs[i];
  459. const value = CUTOFF_GETTERS[i](video);
  460. if (value < lowerBound || value > upperBound) {
  461. return true;
  462. }
  463. }
  464. const channelName = video.querySelector('ytd-channel-name#channel-name')?.innerText;
  465. const videoName = (
  466. video.querySelector('#video-title')
  467. || video.querySelector('.shortsLockupViewModelHostOutsideMetadataTitle')
  468. ).innerText;
  469. for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
  470. if ((!channelRegex || channelName && channelRegex.test(channelName)) && (!videoRegex || videoRegex.test(videoName))) {
  471. for (const type of types) {
  472. if (VIDEO_PREDICATES[type](video)) {
  473. return true;
  474. }
  475. }
  476. }
  477. }
  478. return false;
  479. }
  480.  
  481. const hideList = (() => {
  482. const list = [];
  483. let hasReverted = true;
  484. function hide(element, doHide) {
  485. element.hidden = false;
  486. if (doHide) {
  487. element.style.display = 'none';
  488. } else {
  489. element.style.removeProperty('display');
  490. }
  491. }
  492. return {
  493. 'add'(element, doHide = true) {
  494. if (button.isActive) {
  495. hasReverted = false;
  496. }
  497. list.push({element, doHide, wasHidden: element.hidden});
  498. if (button.isActive) {
  499. hide(element, doHide);
  500. }
  501. },
  502. 'revert'(doErase) {
  503. if (!hasReverted) {
  504. hasReverted = true;
  505. for (const {element, doHide, wasHidden} of list) {
  506. hide(element, !doHide);
  507. element.hidden = wasHidden;
  508. }
  509. }
  510. if (doErase) {
  511. list.length = 0;
  512. }
  513. },
  514. 'ensure'() {
  515. if (!hasReverted) {
  516. return;
  517. }
  518. hasReverted = false;
  519. for (const {element, doHide} of list) {
  520. hide(element, doHide);
  521. }
  522. },
  523. };
  524. })();
  525.  
  526. const showList = (() => {
  527. const ATTRIBUTE = 'is-in-first-column';
  528. const list = [];
  529. const observers = [];
  530. let rowLength;
  531. let rowRemaining = 1;
  532. let hasReverted = true;
  533. function disconnectObservers() {
  534. for (const observer of observers) {
  535. observer.disconnect();
  536. }
  537. observers.length = 0;
  538. }
  539. function show(element, isFirst) {
  540. const act = isFirst ?
  541. () => element.setAttribute(ATTRIBUTE, true) :
  542. () => element.removeAttribute(ATTRIBUTE);
  543. act();
  544. const observer = new MutationObserver(() => {
  545. observer.disconnect();
  546. act();
  547. // Avoids observation cycle that I can't figure out the cause of
  548. window.setTimeout(() => {
  549. observer.observe(element, {attributeFilter: [ATTRIBUTE]});
  550. }, 0);
  551. });
  552. observer.observe(element, {attributeFilter: [ATTRIBUTE]});
  553. observers.push(observer);
  554. }
  555. return {
  556. 'add'(element) {
  557. if (list.length === 0) {
  558. rowLength = element.itemsPerRow ?? 3;
  559. }
  560. if (button.isActive) {
  561. hasReverted = false;
  562. }
  563. const isFirst = --rowRemaining === 0;
  564. if (isFirst) {
  565. rowRemaining = rowLength;
  566. }
  567. list.push({element, isFirst, wasFirst: element.hasAttribute(ATTRIBUTE)});
  568. if (button.isActive) {
  569. show(element, isFirst);
  570. }
  571. },
  572. 'revert'(doErase) {
  573. if (!hasReverted) {
  574. hasReverted = true;
  575. disconnectObservers();
  576. for (const {element, wasFirst} of list) {
  577. show(element, wasFirst);
  578. }
  579. }
  580. if (doErase) {
  581. list.length = 0;
  582. rowRemaining = 1;
  583. }
  584. },
  585. 'ensure'() {
  586. if (!hasReverted) {
  587. return;
  588. }
  589. hasReverted = false;
  590. for (const {element, isFirst} of list) {
  591. show(element, isFirst);
  592. }
  593. },
  594. 'lineFeed'() {
  595. rowRemaining = 1;
  596. },
  597. };
  598. })();
  599.  
  600. async function hideVideo(element, config) {
  601. // video, else shorts container
  602. if (element.tagName === 'YTD-RICH-ITEM-RENDERER') {
  603. await loadVideo(element);
  604. if (shouldHide(config, element)) {
  605. hideList.add(element);
  606. } else {
  607. showList.add(element);
  608. }
  609. return;
  610. }
  611. let doHide = true;
  612. for (const video of element.querySelectorAll('ytd-rich-item-renderer')) {
  613. await loadVideo(video);
  614. if (shouldHide(config, video)) {
  615. hideList.add(video);
  616. } else {
  617. showList.add(video);
  618. doHide = false;
  619. }
  620. }
  621. if (doHide) {
  622. hideList.add(element);
  623. } else {
  624. showList.lineFeed();
  625. }
  626. }
  627.  
  628. async function hideVideos(videos = getAllVideos()) {
  629. const config = $config.get();
  630. for (const video of videos) {
  631. await Promise.all([
  632. hideVideo(video, config),
  633. // Allow the page to update visually before moving on
  634. new Promise((resolve) => {
  635. window.setTimeout(resolve, 0);
  636. }),
  637. ]);
  638. }
  639. }
  640.  
  641. // Helpers
  642.  
  643. function resetConfig(fullReset = true) {
  644. hideList.revert(fullReset);
  645. showList.revert(fullReset);
  646. }
  647.  
  648. function hideFromMutations(mutations) {
  649. const videos = [];
  650. for (const {addedNodes} of mutations) {
  651. for (const node of addedNodes) {
  652. switch (node.tagName) {
  653. case 'YTD-RICH-ITEM-RENDERER':
  654. case 'YTD-RICH-SECTION-RENDERER':
  655. videos.push(node);
  656. }
  657. }
  658. }
  659. hideVideos(videos);
  660. }
  661.  
  662. function getButtonDock() {
  663. return document
  664. .querySelector('ytd-browse[page-subtype="subscriptions"]')
  665. .querySelector('#contents')
  666. .querySelector('#title-container')
  667. .querySelector('#top-level-buttons-computed');
  668. }
  669.  
  670. // Button
  671.  
  672. class ClickHandler {
  673. constructor(button, onShortClick, onLongClick) {
  674. this.onShortClick = function () {
  675. onShortClick();
  676. window.clearTimeout(this.longClickTimeout);
  677. window.removeEventListener('mouseup', this.onShortClick);
  678. }.bind(this);
  679. this.onLongClick = function () {
  680. window.removeEventListener('mouseup', this.onShortClick);
  681. onLongClick();
  682. }.bind(this);
  683. this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
  684. window.addEventListener('mouseup', this.onShortClick);
  685. }
  686. }
  687.  
  688. class Button {
  689. wasActive;
  690. isActive = false;
  691. isDormant = false;
  692. constructor() {
  693. this.element = (() => {
  694. const getSVG = () => {
  695. const svgNamespace = 'http://www.w3.org/2000/svg';
  696. const bottom = document.createElementNS(svgNamespace, 'path');
  697. bottom.setAttribute('d', 'M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z');
  698. const top = document.createElementNS(svgNamespace, 'rect');
  699. top.setAttribute('x', '13.95');
  700. top.setAttribute('width', '294');
  701. top.setAttribute('height', '45');
  702. const g = document.createElementNS(svgNamespace, 'g');
  703. g.appendChild(bottom);
  704. g.appendChild(top);
  705. const svg = document.createElementNS(svgNamespace, 'svg');
  706. svg.setAttribute('viewBox', '-50 -50 400 400');
  707. svg.setAttribute('focusable', 'false');
  708. svg.appendChild(g);
  709. return svg;
  710. };
  711. const getNewButton = () => {
  712. const {parentElement, 'children': [, openerTemplate]} = getButtonDock();
  713. const button = openerTemplate.cloneNode(false);
  714. if (openerTemplate.innerText) {
  715. throw new Error('too early');
  716. }
  717. // 🤷‍♀️
  718. const policy = trustedTypes?.createPolicy('policy', {createHTML: (string) => string}) ?? {createHTML: (string) => string};
  719. parentElement.appendChild(button);
  720. button.innerHTML = policy.createHTML(openerTemplate.innerHTML);
  721. button.querySelector('yt-button-shape').innerHTML = policy.createHTML(openerTemplate.querySelector('yt-button-shape').innerHTML);
  722. button.querySelector('a').removeAttribute('href');
  723. button.querySelector('yt-icon').appendChild(getSVG());
  724. button.querySelector('tp-yt-paper-tooltip').remove();
  725. return button;
  726. };
  727. return getNewButton();
  728. })();
  729. this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
  730. GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
  731. this.isActive = isActive;
  732. this.update();
  733. const videoObserver = new MutationObserver(hideFromMutations);
  734. videoObserver.observe(
  735. document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
  736. {childList: true},
  737. );
  738. hideVideos();
  739. });
  740. let resizeCount = 0;
  741. window.addEventListener('resize', () => {
  742. const resizeId = ++resizeCount;
  743. this.forceInactive();
  744. const listener = ({detail}) => {
  745. // column size changed
  746. if (detail.actionName === 'yt-window-resized') {
  747. window.setTimeout(() => {
  748. if (resizeId !== resizeCount) {
  749. return;
  750. }
  751. this.forceInactive(false);
  752. // Don't bother re-running filters if the sub page isn't shown
  753. if (this.isDormant) {
  754. return;
  755. }
  756. resetConfig();
  757. hideVideos();
  758. }, 1000);
  759. document.body.removeEventListener('yt-action', listener);
  760. }
  761. };
  762. document.body.addEventListener('yt-action', listener);
  763. });
  764. }
  765. forceInactive(doForce = true) {
  766. if (doForce) {
  767. // if wasActive isn't undefined, forceInactive was already called
  768. if (this.wasActive === undefined) {
  769. // Saves a GM.getValue call later
  770. this.wasActive = this.isActive;
  771. this.isActive = false;
  772. }
  773. } else {
  774. this.isActive = this.wasActive;
  775. this.wasActive = undefined;
  776. }
  777. }
  778. update() {
  779. if (this.isActive) {
  780. this.setButtonActive();
  781. }
  782. }
  783. setButtonActive() {
  784. if (this.isActive) {
  785. this.element.querySelector('svg').style.setProperty('fill', 'var(--yt-spec-call-to-action)');
  786. } else {
  787. this.element.querySelector('svg').style.setProperty('fill', 'currentcolor');
  788. }
  789. }
  790. toggleActive() {
  791. this.isActive = !this.isActive;
  792. this.setButtonActive();
  793. GM.setValue(KEY_IS_ACTIVE, this.isActive);
  794. if (this.isActive) {
  795. hideList.ensure();
  796. showList.ensure();
  797. } else {
  798. hideList.revert(false);
  799. showList.revert(false);
  800. }
  801. }
  802. async onLongClick() {
  803. await $config.edit();
  804. resetConfig();
  805. hideVideos();
  806. }
  807. onMouseDown(event) {
  808. if (event.button === 0) {
  809. new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
  810. }
  811. }
  812. }
  813.  
  814. // Main
  815.  
  816. (() => {
  817. const loadButton = async () => {
  818. if (button) {
  819. button.isDormant = false;
  820. hideVideos();
  821. return;
  822. }
  823. try {
  824. await $config.ready;
  825. } catch (error) {
  826. if (!$config.reset) {
  827. throw error;
  828. }
  829. if (!window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
  830. return;
  831. }
  832. $config.reset();
  833. }
  834. try {
  835. getButtonDock();
  836. button = new Button();
  837. } catch {
  838. const emitter = document.getElementById('page-manager');
  839. const bound = () => {
  840. loadButton();
  841. emitter.removeEventListener('yt-action', bound);
  842. };
  843. emitter.addEventListener('yt-action', bound);
  844. }
  845. };
  846. const isGridView = () => {
  847. return Boolean(
  848. document.querySelector('ytd-browse[page-subtype="subscriptions"]:not([hidden])')
  849. && document.querySelector('ytd-browse > ytd-two-column-browse-results-renderer ytd-rich-grid-renderer ytd-rich-item-renderer ytd-rich-grid-media'),
  850. );
  851. };
  852. function onNavigate({detail}) {
  853. if (detail.endpoint.browseEndpoint) {
  854. const {params, browseId} = detail.endpoint.browseEndpoint;
  855. // Handle navigation to the sub feed
  856. if ((params === 'MAE%3D' || !params && (!button || isGridView())) && browseId === 'FEsubscriptions') {
  857. const emitter = document.querySelector('ytd-app');
  858. const event = 'yt-action';
  859. if (button || isGridView()) {
  860. loadButton();
  861. } else {
  862. const listener = ({detail}) => {
  863. if (detail.actionName === 'ytd-update-grid-state-action') {
  864. if (isGridView()) {
  865. loadButton();
  866. }
  867. emitter.removeEventListener(event, listener);
  868. }
  869. };
  870. emitter.addEventListener(event, listener);
  871. }
  872. return;
  873. }
  874. }
  875. // Handle navigation away from the sub feed
  876. if (button) {
  877. button.isDormant = true;
  878. hideList.revert(true);
  879. showList.revert(true);
  880. }
  881. }
  882. document.body.addEventListener('yt-navigate-finish', onNavigate);
  883. })();
  884. })();