YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

目前為 2023-12-20 提交的版本,檢視 最新版本

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