YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

目前為 2023-11-23 提交的版本,檢視 最新版本

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