YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

目前为 2023-11-29 提交的版本,查看 最新版本

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