YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

当前为 2024-09-12 提交的版本,查看 最新版本

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