YouTube Sub Feed Filter 2

Set up filters for your sub feed

  1. // ==UserScript==
  2. // @name YouTube Sub Feed Filter 2
  3. // @version 1.50
  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 .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).querySelector('.inline-metadata-item, .shortsLockupViewModelHostMetadataSubhead');
  387. const [valueString] = innerText.split(' ');
  388. const lastChar = valueString.slice(-1);
  389. if (/\d/.test(lastChar)) {
  390. return Number.parseInt(valueString);
  391. }
  392. const valueNumber = Number.parseFloat(valueString.slice(0, -1));
  393. switch (lastChar) {
  394. case 'B':
  395. return valueNumber * 1000000000;
  396. case 'M':
  397. return valueNumber * 1000000;
  398. case 'K':
  399. return valueNumber * 1000;
  400. }
  401. return valueNumber;
  402. },
  403. // Duration (minutes)
  404. (video) => {
  405. const timeElement = video.querySelector('ytd-thumbnail-overlay-time-status-renderer');
  406. let minutes = 0;
  407. if (timeElement) {
  408. const timeParts = timeElement.innerText.split(':').map((_) => Number.parseInt(_));
  409. let timeValue = 1 / 60;
  410. for (let i = timeParts.length - 1; i >= 0; --i) {
  411. minutes += timeParts[i] * timeValue;
  412. timeValue *= 60;
  413. }
  414. }
  415. return Number.isNaN(minutes) ? 0 : minutes;
  416. },
  417. ];
  418.  
  419. const BADGE_PREDICATES = [
  420. // Verified
  421. (video) => getChannelBadges(video)
  422. .some((badge) => badge.classList.contains('badge-style-type-verified')),
  423. // Official Artist
  424. (video) => getChannelBadges(video)
  425. .some((badge) => badge.classList.contains('badge-style-type-verified-artist')),
  426. ];
  427.  
  428. // Hider functions
  429.  
  430. function loadVideo(video) {
  431. return new Promise((resolve) => {
  432. const test = () => {
  433. if (video.querySelector('#interaction')) {
  434. observer.disconnect();
  435. resolve();
  436. }
  437. };
  438. const observer = new MutationObserver(test);
  439. observer.observe(video, {
  440. childList: true,
  441. subtree: true,
  442. attributes: true,
  443. attributeOldValue: true,
  444. });
  445. test();
  446. });
  447. }
  448.  
  449. function shouldHide({filters, cutoffs, badges}, video) {
  450. for (let i = 0; i < BADGE_PREDICATES.length; ++i) {
  451. if (badges[i] !== 1 && Boolean(badges[i]) !== BADGE_PREDICATES[i](video)) {
  452. return true;
  453. }
  454. }
  455. for (let i = 0; i < CUTOFF_GETTERS.length; ++i) {
  456. const [lowerBound, upperBound] = cutoffs[i];
  457. const value = CUTOFF_GETTERS[i](video);
  458. if (value < lowerBound || value > upperBound) {
  459. return true;
  460. }
  461. }
  462. const channelName = video.querySelector('ytd-channel-name#channel-name')?.innerText;
  463. const videoName = (
  464. video.querySelector('#video-title')
  465. || video.querySelector('.shortsLockupViewModelHostOutsideMetadataTitle')
  466. ).innerText;
  467. for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
  468. if ((!channelRegex || channelName && channelRegex.test(channelName)) && (!videoRegex || videoRegex.test(videoName))) {
  469. for (const type of types) {
  470. if (VIDEO_PREDICATES[type](video)) {
  471. return true;
  472. }
  473. }
  474. }
  475. }
  476. return false;
  477. }
  478.  
  479. const hideList = (() => {
  480. const list = [];
  481. let hasReverted = true;
  482. function hide(element, doHide) {
  483. element.hidden = false;
  484. if (doHide) {
  485. element.style.display = 'none';
  486. } else {
  487. element.style.removeProperty('display');
  488. }
  489. }
  490. return {
  491. 'add'(element, doHide = true) {
  492. if (button.isActive) {
  493. hasReverted = false;
  494. }
  495. list.push({element, doHide, wasHidden: element.hidden});
  496. if (button.isActive) {
  497. hide(element, doHide);
  498. }
  499. },
  500. 'revert'(doErase) {
  501. if (!hasReverted) {
  502. hasReverted = true;
  503. for (const {element, doHide, wasHidden} of list) {
  504. hide(element, !doHide);
  505. element.hidden = wasHidden;
  506. }
  507. }
  508. if (doErase) {
  509. list.length = 0;
  510. }
  511. },
  512. 'ensure'() {
  513. if (!hasReverted) {
  514. return;
  515. }
  516. hasReverted = false;
  517. for (const {element, doHide} of list) {
  518. hide(element, doHide);
  519. }
  520. },
  521. };
  522. })();
  523.  
  524. const showList = (() => {
  525. const ATTRIBUTE = 'is-in-first-column';
  526. const list = [];
  527. const observers = [];
  528. let rowLength;
  529. let rowRemaining = 1;
  530. let hasReverted = true;
  531. function disconnectObservers() {
  532. for (const observer of observers) {
  533. observer.disconnect();
  534. }
  535. observers.length = 0;
  536. }
  537. function show(element, isFirst) {
  538. const act = isFirst ?
  539. () => element.setAttribute(ATTRIBUTE, true) :
  540. () => element.removeAttribute(ATTRIBUTE);
  541. act();
  542. const observer = new MutationObserver(() => {
  543. observer.disconnect();
  544. act();
  545. // Avoids observation cycle that I can't figure out the cause of
  546. window.setTimeout(() => {
  547. observer.observe(element, {attributeFilter: [ATTRIBUTE]});
  548. }, 0);
  549. });
  550. observer.observe(element, {attributeFilter: [ATTRIBUTE]});
  551. observers.push(observer);
  552. }
  553. return {
  554. 'add'(element) {
  555. if (list.length === 0) {
  556. rowLength = element.itemsPerRow ?? 3;
  557. }
  558. if (button.isActive) {
  559. hasReverted = false;
  560. }
  561. const isFirst = --rowRemaining === 0;
  562. if (isFirst) {
  563. rowRemaining = rowLength;
  564. }
  565. list.push({element, isFirst, wasFirst: element.hasAttribute(ATTRIBUTE)});
  566. if (button.isActive) {
  567. show(element, isFirst);
  568. }
  569. },
  570. 'revert'(doErase) {
  571. if (!hasReverted) {
  572. hasReverted = true;
  573. disconnectObservers();
  574. for (const {element, wasFirst} of list) {
  575. show(element, wasFirst);
  576. }
  577. }
  578. if (doErase) {
  579. list.length = 0;
  580. rowRemaining = 1;
  581. }
  582. },
  583. 'ensure'() {
  584. if (!hasReverted) {
  585. return;
  586. }
  587. hasReverted = false;
  588. for (const {element, isFirst} of list) {
  589. show(element, isFirst);
  590. }
  591. },
  592. 'lineFeed'() {
  593. rowRemaining = 1;
  594. },
  595. };
  596. })();
  597.  
  598. async function hideVideo(element, config) {
  599. // video, else shorts container
  600. if (element.tagName === 'YTD-RICH-ITEM-RENDERER') {
  601. await loadVideo(element);
  602. if (shouldHide(config, element)) {
  603. hideList.add(element);
  604. } else {
  605. showList.add(element);
  606. }
  607. return;
  608. }
  609. let doHide = true;
  610. for (const video of element.querySelectorAll('ytd-rich-item-renderer')) {
  611. await loadVideo(video);
  612. if (shouldHide(config, video)) {
  613. hideList.add(video);
  614. } else {
  615. showList.add(video);
  616. doHide = false;
  617. }
  618. }
  619. if (doHide) {
  620. hideList.add(element);
  621. } else {
  622. showList.lineFeed();
  623. }
  624. }
  625.  
  626. async function hideVideos(videos = getAllVideos()) {
  627. const config = $config.get();
  628. for (const video of videos) {
  629. await Promise.all([
  630. hideVideo(video, config),
  631. // Allow the page to update visually before moving on
  632. new Promise((resolve) => {
  633. window.setTimeout(resolve, 0);
  634. }),
  635. ]);
  636. }
  637. }
  638.  
  639. // Helpers
  640.  
  641. function resetConfig(fullReset = true) {
  642. hideList.revert(fullReset);
  643. showList.revert(fullReset);
  644. }
  645.  
  646. function hideFromMutations(mutations) {
  647. const videos = [];
  648. for (const {addedNodes} of mutations) {
  649. for (const node of addedNodes) {
  650. switch (node.tagName) {
  651. case 'YTD-RICH-ITEM-RENDERER':
  652. case 'YTD-RICH-SECTION-RENDERER':
  653. videos.push(node);
  654. }
  655. }
  656. }
  657. hideVideos(videos);
  658. }
  659.  
  660. function getButtonDock() {
  661. return document
  662. .querySelector('ytd-browse[page-subtype="subscriptions"]')
  663. .querySelector('#contents')
  664. .querySelector('#title-container')
  665. .querySelector('#top-level-buttons-computed');
  666. }
  667.  
  668. // Button
  669.  
  670. class ClickHandler {
  671. constructor(button, onShortClick, onLongClick) {
  672. this.onShortClick = function () {
  673. onShortClick();
  674. window.clearTimeout(this.longClickTimeout);
  675. window.removeEventListener('mouseup', this.onShortClick);
  676. }.bind(this);
  677. this.onLongClick = function () {
  678. window.removeEventListener('mouseup', this.onShortClick);
  679. onLongClick();
  680. }.bind(this);
  681. this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
  682. window.addEventListener('mouseup', this.onShortClick);
  683. }
  684. }
  685.  
  686. class Button {
  687. wasActive;
  688. isActive = false;
  689. isDormant = false;
  690. constructor() {
  691. this.element = (() => {
  692. const getSVG = () => {
  693. const svgNamespace = 'http://www.w3.org/2000/svg';
  694. const bottom = document.createElementNS(svgNamespace, 'path');
  695. 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');
  696. const top = document.createElementNS(svgNamespace, 'rect');
  697. top.setAttribute('x', '13.95');
  698. top.setAttribute('width', '294');
  699. top.setAttribute('height', '45');
  700. const g = document.createElementNS(svgNamespace, 'g');
  701. g.appendChild(bottom);
  702. g.appendChild(top);
  703. const svg = document.createElementNS(svgNamespace, 'svg');
  704. svg.setAttribute('viewBox', '-50 -50 400 400');
  705. svg.setAttribute('focusable', 'false');
  706. svg.appendChild(g);
  707. return svg;
  708. };
  709. const getNewButton = () => {
  710. const {parentElement, 'children': [, openerTemplate]} = getButtonDock();
  711. const button = openerTemplate.cloneNode(false);
  712. if (openerTemplate.innerText) {
  713. throw new Error('too early');
  714. }
  715. // 🤷‍♀️
  716. const policy = trustedTypes?.createPolicy('policy', {createHTML: (string) => string}) ?? {createHTML: (string) => string};
  717. parentElement.appendChild(button);
  718. button.innerHTML = policy.createHTML(openerTemplate.innerHTML);
  719. button.querySelector('yt-button-shape').innerHTML = policy.createHTML(openerTemplate.querySelector('yt-button-shape').innerHTML);
  720. button.querySelector('a').removeAttribute('href');
  721. button.querySelector('yt-icon').appendChild(getSVG());
  722. button.querySelector('tp-yt-paper-tooltip').remove();
  723. return button;
  724. };
  725. return getNewButton();
  726. })();
  727. this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
  728. GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
  729. this.isActive = isActive;
  730. this.update();
  731. const videoObserver = new MutationObserver(hideFromMutations);
  732. videoObserver.observe(
  733. document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
  734. {childList: true},
  735. );
  736. hideVideos();
  737. });
  738. let resizeCount = 0;
  739. window.addEventListener('resize', () => {
  740. const resizeId = ++resizeCount;
  741. this.forceInactive();
  742. const listener = ({detail}) => {
  743. // column size changed
  744. if (detail.actionName === 'yt-window-resized') {
  745. window.setTimeout(() => {
  746. if (resizeId !== resizeCount) {
  747. return;
  748. }
  749. this.forceInactive(false);
  750. // Don't bother re-running filters if the sub page isn't shown
  751. if (this.isDormant) {
  752. return;
  753. }
  754. resetConfig();
  755. hideVideos();
  756. }, 1000);
  757. document.body.removeEventListener('yt-action', listener);
  758. }
  759. };
  760. document.body.addEventListener('yt-action', listener);
  761. });
  762. }
  763. forceInactive(doForce = true) {
  764. if (doForce) {
  765. // if wasActive isn't undefined, forceInactive was already called
  766. if (this.wasActive === undefined) {
  767. // Saves a GM.getValue call later
  768. this.wasActive = this.isActive;
  769. this.isActive = false;
  770. }
  771. } else {
  772. this.isActive = this.wasActive;
  773. this.wasActive = undefined;
  774. }
  775. }
  776. update() {
  777. if (this.isActive) {
  778. this.setButtonActive();
  779. }
  780. }
  781. setButtonActive() {
  782. if (this.isActive) {
  783. this.element.querySelector('svg').style.setProperty('fill', 'var(--yt-spec-call-to-action)');
  784. } else {
  785. this.element.querySelector('svg').style.setProperty('fill', 'currentcolor');
  786. }
  787. }
  788. toggleActive() {
  789. this.isActive = !this.isActive;
  790. this.setButtonActive();
  791. GM.setValue(KEY_IS_ACTIVE, this.isActive);
  792. if (this.isActive) {
  793. hideList.ensure();
  794. showList.ensure();
  795. } else {
  796. hideList.revert(false);
  797. showList.revert(false);
  798. }
  799. }
  800. async onLongClick() {
  801. await $config.edit();
  802. resetConfig();
  803. hideVideos();
  804. }
  805. onMouseDown(event) {
  806. if (event.button === 0) {
  807. new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
  808. }
  809. }
  810. }
  811.  
  812. // Main
  813.  
  814. (() => {
  815. const loadButton = async () => {
  816. if (button) {
  817. button.isDormant = false;
  818. hideVideos();
  819. return;
  820. }
  821. try {
  822. await $config.ready;
  823. } catch (error) {
  824. if (!$config.reset) {
  825. throw error;
  826. }
  827. if (!window.confirm(`${error.message}\n\nWould you like to erase your data?`)) {
  828. return;
  829. }
  830. $config.reset();
  831. }
  832. try {
  833. getButtonDock();
  834. button = new Button();
  835. } catch {
  836. const emitter = document.getElementById('page-manager');
  837. const bound = () => {
  838. loadButton();
  839. emitter.removeEventListener('yt-action', bound);
  840. };
  841. emitter.addEventListener('yt-action', bound);
  842. }
  843. };
  844. const isGridView = () => {
  845. return Boolean(
  846. document.querySelector('ytd-browse[page-subtype="subscriptions"]:not([hidden])')
  847. && document.querySelector('ytd-browse > ytd-two-column-browse-results-renderer ytd-rich-grid-renderer ytd-rich-item-renderer ytd-rich-grid-media'),
  848. );
  849. };
  850. function onNavigate({detail}) {
  851. if (detail.endpoint.browseEndpoint) {
  852. const {params, browseId} = detail.endpoint.browseEndpoint;
  853. // Handle navigation to the sub feed
  854. if ((params === 'MAE%3D' || !params && (!button || isGridView())) && browseId === 'FEsubscriptions') {
  855. const emitter = document.querySelector('ytd-app');
  856. const event = 'yt-action';
  857. if (button || isGridView()) {
  858. loadButton();
  859. } else {
  860. const listener = ({detail}) => {
  861. if (detail.actionName === 'ytd-update-grid-state-action') {
  862. if (isGridView()) {
  863. loadButton();
  864. }
  865. emitter.removeEventListener(event, listener);
  866. }
  867. };
  868. emitter.addEventListener(event, listener);
  869. }
  870. return;
  871. }
  872. }
  873. // Handle navigation away from the sub feed
  874. if (button) {
  875. button.isDormant = true;
  876. hideList.revert(true);
  877. showList.revert(true);
  878. }
  879. }
  880. document.body.addEventListener('yt-navigate-finish', onNavigate);
  881. })();
  882. })();