YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

当前为 2023-11-25 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Sub Feed Filter 2
  3. // @version 1.11
  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. 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 getSubPage() {
  284. return document.querySelector('.ytd-page-manager[page-subtype="subscriptions"]');
  285. }
  286.  
  287. function getAllRows() {
  288. const subPage = getSubPage();
  289.  
  290. return subPage ? [...subPage.querySelectorAll('ytd-rich-grid-row')] : [];
  291. }
  292.  
  293. function getAllSections() {
  294. const subPage = getSubPage();
  295.  
  296. return subPage ? [...subPage.querySelectorAll('ytd-rich-section-renderer:not(:first-child)')] : [];
  297. }
  298.  
  299. function getAllVideos(row) {
  300. return [...row.querySelectorAll('ytd-rich-item-renderer')];
  301. }
  302.  
  303. function firstWordEquals(element, word) {
  304. return element.innerText.split(' ')[0] === word;
  305. }
  306.  
  307. function getVideoBadges(video) {
  308. return video.querySelectorAll('.video-badge');
  309. }
  310.  
  311. function getChannelBadges(video) {
  312. const container = video.querySelector('ytd-badge-supported-renderer.ytd-channel-name');
  313.  
  314. return container ? [...container.querySelectorAll('.badge')] : [];
  315. }
  316.  
  317. function getMetadataLine(video) {
  318. return video.querySelector('#metadata-line');
  319. }
  320.  
  321. function isScheduled(video) {
  322. return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED](video) ||
  323. VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED](video);
  324. }
  325.  
  326. function isLive(video) {
  327. return VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE](video) ||
  328. VIDEO_PREDICATES[VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE](video);
  329. }
  330.  
  331. // Config testers
  332.  
  333. const VIDEO_PREDICATES = {
  334. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
  335. const metadataLine = getMetadataLine(video);
  336.  
  337. return firstWordEquals(metadataLine, 'Scheduled');
  338. },
  339. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
  340. for (const badge of getVideoBadges(video)) {
  341. if (firstWordEquals(badge, 'LIVE')) {
  342. return true;
  343. }
  344. }
  345.  
  346. return false;
  347. },
  348. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
  349. const children = [...getMetadataLine(video).children].filter((child) => child.matches('.inline-metadata-item'));
  350.  
  351. return children.length > 1 && firstWordEquals(children[1], 'Streamed');
  352. },
  353. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED]: (video) => {
  354. const metadataLine = getMetadataLine(video);
  355.  
  356. return firstWordEquals(metadataLine, 'Premieres');
  357. },
  358. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE]: (video) => {
  359. for (const badge of getVideoBadges(video)) {
  360. if (firstWordEquals(badge, 'PREMIERING') || firstWordEquals(badge, 'PREMIERE')) {
  361. return true;
  362. }
  363. }
  364.  
  365. return false;
  366. },
  367. [VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: (video) => {
  368. return video.querySelector('ytd-rich-grid-slim-media')?.isShort ?? false;
  369. },
  370. [VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
  371. const [, {innerText}] = getMetadataLine(video).children;
  372.  
  373. return new RegExp('^\\d+ .+ ago$').test(innerText);
  374. }
  375. };
  376.  
  377. const CUTOFF_GETTERS = [
  378. // Watched %
  379. (video) => {
  380. const progressBar = video.querySelector('#progress');
  381.  
  382. if (!progressBar) {
  383. return 0;
  384. }
  385.  
  386. return Number.parseInt(progressBar.style.width.slice(0, -1));
  387. },
  388. // View count
  389. (video) => {
  390. if (isScheduled(video)) {
  391. return 0;
  392. }
  393.  
  394. const {innerText} = [...getMetadataLine(video).children].find((child) => child.matches('.inline-metadata-item'));
  395. const [valueString] = innerText.split(' ');
  396. const lastChar = valueString.slice(-1);
  397.  
  398. if (/\d/.test(lastChar)) {
  399. return Number.parseInt(valueString);
  400. }
  401.  
  402. const valueNumber = Number.parseFloat(valueString.slice(0, -1));
  403.  
  404. switch (lastChar) {
  405. case 'B':
  406. return valueNumber * 1000000000;
  407. case 'M':
  408. return valueNumber * 1000000;
  409. case 'K':
  410. return valueNumber * 1000;
  411. }
  412.  
  413. return valueNumber;
  414. },
  415. // Duration (minutes)
  416. (video) => {
  417. const timeElement = video.querySelector('ytd-thumbnail-overlay-time-status-renderer');
  418.  
  419. let minutes = 0;
  420.  
  421. if (timeElement) {
  422. const timeParts = timeElement.innerText.split(':').map((_) => Number.parseInt(_));
  423.  
  424. let timeValue = 1 / 60;
  425.  
  426. for (let i = timeParts.length - 1; i >= 0; --i) {
  427. minutes += timeParts[i] * timeValue;
  428.  
  429. timeValue *= 60;
  430. }
  431. }
  432.  
  433. return Number.isNaN(minutes) ? 0 : minutes;
  434. }
  435. ];
  436.  
  437. const BADGE_PREDICATES = [
  438. // Verified
  439. (video) => getChannelBadges(video)
  440. .some((badge) => badge.classList.contains('badge-style-type-verified')),
  441. // Official Artist
  442. (video) => getChannelBadges(video)
  443. .some((badge) => badge.classList.contains('badge-style-type-verified-artist'))
  444. ];
  445.  
  446. // Hider functions
  447.  
  448. function loadVideo(video) {
  449. return new Promise((resolve) => {
  450. const test = () => {
  451. if (video.querySelector('#interaction.yt-icon-button')) {
  452. resolve();
  453. }
  454. };
  455.  
  456. test();
  457.  
  458. new MutationObserver(test)
  459. .observe(video, {
  460. 'childList': true,
  461. 'subtree': true,
  462. 'attributes': true,
  463. 'attributeOldValue': true
  464. });
  465. });
  466. }
  467.  
  468. function shouldHide({filters, cutoffs, badges}, video) {
  469. for (let i = 0; i < BADGE_PREDICATES.length; ++i) {
  470. if (badges[i] !== 1 && Boolean(badges[i]) !== BADGE_PREDICATES[i](video)) {
  471. return true;
  472. }
  473. }
  474.  
  475. for (let i = 0; i < CUTOFF_GETTERS.length; ++i) {
  476. const [lowerBound, upperBound] = cutoffs[i];
  477. const value = CUTOFF_GETTERS[i](video);
  478.  
  479. if (value < lowerBound || value > upperBound) {
  480. return true;
  481. }
  482. }
  483.  
  484. const channelName = video.querySelector('ytd-channel-name#channel-name')?.innerText;
  485. const videoName = video.querySelector('#video-title').innerText;
  486.  
  487. for (const {'channels': channelRegex, 'videos': videoRegex, types} of filters) {
  488. if (
  489. (!channelName || channelRegex.test(channelName)) &&
  490. videoRegex.test(videoName)
  491. ) {
  492. for (const type of types) {
  493. if (VIDEO_PREDICATES[type](video)) {
  494. return true;
  495. }
  496. }
  497. }
  498. }
  499.  
  500. return false;
  501. }
  502.  
  503. const moveList = (() => {
  504. const list = [];
  505.  
  506. let hasReverted = false;
  507.  
  508. return {
  509. 'add'(doAct, element, destination) {
  510. if (doAct) {
  511. hasReverted = false;
  512. }
  513.  
  514. list.push({element, destination, 'origin': element.parentElement});
  515.  
  516. if (doAct) {
  517. destination.appendChild(element);
  518. }
  519. },
  520. 'revert'(doErase) {
  521. if (!hasReverted) {
  522. hasReverted = true;
  523.  
  524. for (let i = list.length - 1; i >= 0; --i) {
  525. const {origin, element} = list[i];
  526.  
  527. origin.prepend(element);
  528. }
  529. }
  530.  
  531. if (doErase) {
  532. list.length = 0;
  533. }
  534. },
  535. 'ensure'() {
  536. if (!hasReverted) {
  537. return;
  538. }
  539.  
  540. hasReverted = false;
  541.  
  542. for (const {element, destination} of list) {
  543. destination.appendChild(element);
  544. }
  545. }
  546. };
  547. })();
  548.  
  549. const hideList = (() => {
  550. const list = [];
  551.  
  552. let hasReverted = false;
  553.  
  554. function hide(element, doHide) {
  555. element.hidden = false;
  556.  
  557. if (doHide) {
  558. element.style.display = 'none';
  559. } else {
  560. element.style.removeProperty('display');
  561. }
  562. }
  563.  
  564. return {
  565. 'add'(doAct, element, doHide = true) {
  566. if (doAct) {
  567. hasReverted = false;
  568. }
  569.  
  570. list.push({element, doHide, 'wasHidden': element.hidden});
  571.  
  572. if (doAct) {
  573. hide(element, doHide);
  574. }
  575. },
  576. 'revert'(doErase) {
  577. if (!hasReverted) {
  578. hasReverted = true;
  579.  
  580. for (const {element, doHide, wasHidden} of list) {
  581. hide(element, !doHide);
  582.  
  583. element.hidden = wasHidden;
  584. }
  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. async function hideFromRows(config, doAct, groups = getAllRows()) {
  608. for (const group of groups) {
  609. const videos = getAllVideos(group);
  610. const [{itemsPerRow, parentElement}] = videos;
  611.  
  612. let lossCount = 0;
  613.  
  614. // Process all videos in the row in parallel
  615. await Promise.all(videos.map((video) => new Promise(async (resolve) => {
  616. await loadVideo(video);
  617.  
  618. if (shouldHide(config, video)) {
  619. hideList.add(doAct, video);
  620.  
  621. if (partialGroup) {
  622. // Allows things to be put back in the right order
  623. moveList.add(doAct, video, partialGroup.container);
  624. }
  625.  
  626. ++lossCount;
  627. } else if (partialGroup) {
  628. moveList.add(doAct, video, partialGroup.container);
  629.  
  630. if (--partialGroup.required <= 0) {
  631. partialGroup = undefined;
  632. }
  633.  
  634. ++lossCount;
  635. }
  636.  
  637. resolve();
  638. })));
  639.  
  640. if (!partialGroup) {
  641. if (lossCount > 0 && lossCount < itemsPerRow) {
  642. partialGroup = {
  643. 'container': parentElement,
  644. 'required': lossCount
  645. };
  646. }
  647. }
  648.  
  649. // Allow the page to update visually before moving on to the next row
  650. await new Promise((resolve) => {
  651. window.setTimeout(resolve, 0);
  652. });
  653. }
  654. }
  655.  
  656. const hideFromSections = (() => {
  657. return async (config, doAct, groups = getAllSections()) => {
  658. for (const group of groups) {
  659. const shownVideos = [];
  660. const backupVideos = [];
  661.  
  662. for (const video of getAllVideos(group)) {
  663. if (video.hidden) {
  664. if (!shouldHide(config, video)) {
  665. backupVideos.push(video);
  666. }
  667. } else {
  668. shownVideos.push(video);
  669. }
  670. }
  671.  
  672. let lossCount = 0;
  673.  
  674. // Process all videos in the row in parallel
  675. await Promise.all(shownVideos.map((video) => new Promise(async (resolve) => {
  676. await loadVideo(video);
  677.  
  678. if (shouldHide(config, video)) {
  679. hideList.add(doAct, video);
  680.  
  681. if (backupVideos.length > 0) {
  682. hideList.add(doAct, backupVideos.shift(), false);
  683. } else {
  684. lossCount++;
  685. }
  686. }
  687.  
  688. resolve();
  689. })));
  690.  
  691. if (lossCount >= shownVideos.length) {
  692. hideList.add(doAct, group);
  693. }
  694.  
  695. // Allow the page to update visually before moving on to the next row
  696. await new Promise((resolve) => {
  697. window.setTimeout(resolve, 0);
  698. });
  699. }
  700. };
  701. })();
  702.  
  703. function hideAll(doAct = true, rows, sections, config = $config.get()) {
  704. return Promise.all([
  705. hideFromRows(config, doAct, rows),
  706. hideFromSections(config, doAct, sections)
  707. ]);
  708. }
  709.  
  710. // Helpers
  711.  
  712. async function hideFromMutations(isActive, mutations) {
  713. const rows = [];
  714. const sections = [];
  715.  
  716. if (isActive()) {
  717. hideList.ensure();
  718.  
  719. moveList.ensure();
  720. }
  721.  
  722. for (const {addedNodes} of mutations) {
  723. for (const node of addedNodes) {
  724. switch (node.tagName) {
  725. case 'YTD-RICH-GRID-ROW':
  726. rows.push(node);
  727. break;
  728.  
  729. case 'YTD-RICH-SECTION-RENDERER':
  730. sections.push(node);
  731. }
  732. }
  733. }
  734.  
  735. hideAll(isActive(), rows, sections);
  736. }
  737.  
  738. function resetConfig(fullReset = true) {
  739. hideList.revert(fullReset);
  740.  
  741. moveList.revert(fullReset);
  742.  
  743. if (fullReset) {
  744. partialGroup = undefined;
  745. }
  746. }
  747.  
  748. function getButtonDock() {
  749. return document
  750. .querySelector('ytd-browse[page-subtype="subscriptions"]')
  751. .querySelector('#contents')
  752. .querySelector('#title-container')
  753. .querySelector('#top-level-buttons-computed');
  754. }
  755.  
  756. // Button
  757.  
  758. class ClickHandler {
  759. constructor(button, onShortClick, onLongClick) {
  760. this.onShortClick = (function() {
  761. onShortClick();
  762.  
  763. window.clearTimeout(this.longClickTimeout);
  764.  
  765. window.removeEventListener('mouseup', this.onShortClick);
  766. }).bind(this);
  767.  
  768. this.onLongClick = (function() {
  769. window.removeEventListener('mouseup', this.onShortClick);
  770.  
  771. onLongClick();
  772. }).bind(this);
  773.  
  774. this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
  775.  
  776. window.addEventListener('mouseup', this.onShortClick);
  777. }
  778. }
  779.  
  780. class Button {
  781. wasActive;
  782. isActive = false;
  783.  
  784. constructor() {
  785. this.element = this.getNewButton();
  786.  
  787. this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
  788.  
  789. GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
  790. this.isActive = isActive;
  791.  
  792. const videoObserver = new MutationObserver(hideFromMutations.bind(null, () => this.isActive));
  793.  
  794. $config.init()
  795. .catch(({message}) => {
  796. window.alert(message);
  797. })
  798. .then(() => {
  799. videoObserver.observe(
  800. document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
  801. {childList: true}
  802. );
  803.  
  804. hideAll(isActive);
  805. });
  806.  
  807. this.update();
  808. });
  809.  
  810. let resizeCount = 0;
  811.  
  812. window.addEventListener('resize', () => {
  813. const resizeId = ++resizeCount;
  814.  
  815. this.forceInactive();
  816.  
  817. resetConfig();
  818.  
  819. const listener = ({detail}) => {
  820. // column size changed
  821. if (detail.actionName === 'yt-window-resized') {
  822. window.setTimeout(() => {
  823. if (resizeId !== resizeCount) {
  824. return;
  825. }
  826.  
  827. this.forceInactive(false);
  828.  
  829. resetConfig();
  830.  
  831. hideAll(this.isActive);
  832. }, 1000);
  833.  
  834. document.body.removeEventListener('yt-action', listener);
  835. }
  836. };
  837.  
  838. document.body.addEventListener('yt-action', listener);
  839. });
  840.  
  841. document.body.addEventListener('yt-action', (x) => {
  842. const {detail} = x;
  843. if (detail.actionName === 'yt-store-grafted-ve-action') {
  844. hideList.revert(false);
  845. moveList.revert(false);
  846. }
  847. });
  848. }
  849.  
  850. forceInactive(doForce = true) {
  851. if (doForce) {
  852. // if wasActive isn't undefined, forceInactive was already called
  853. if (this.wasActive === undefined) {
  854. // Saves an async call later
  855. this.wasActive = this.isActive;
  856. this.isActive = false;
  857. }
  858. } else {
  859. this.isActive = this.wasActive;
  860. this.wasActive = undefined;
  861. }
  862. }
  863.  
  864. update() {
  865. if (this.isActive) {
  866. this.setButtonActive();
  867. }
  868. }
  869.  
  870. addToDOM(button = this.element) {
  871. const {parentElement} = getButtonDock();
  872.  
  873. parentElement.appendChild(button);
  874. }
  875.  
  876. getNewButton() {
  877. const openerTemplate = getButtonDock().children[1];
  878. const button = openerTemplate.cloneNode(false);
  879.  
  880. if (openerTemplate.innerText) {
  881. throw new Error('too early');
  882. }
  883.  
  884. this.addToDOM(button);
  885.  
  886. button.innerHTML = openerTemplate.innerHTML;
  887.  
  888. button.querySelector('yt-button-shape').innerHTML = openerTemplate.querySelector('yt-button-shape').innerHTML;
  889.  
  890. button.querySelector('a').removeAttribute('href');
  891.  
  892. // TODO Build the svg via javascript
  893. 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>';
  894.  
  895. button.querySelector('tp-yt-paper-tooltip').remove();
  896.  
  897. return button;
  898. }
  899.  
  900. hide() {
  901. this.element.style.display = 'none';
  902. }
  903.  
  904. show() {
  905. this.element.parentElement.appendChild(this.element);
  906. this.element.style.removeProperty('display');
  907. }
  908.  
  909. setButtonActive() {
  910. if (this.isActive) {
  911. this.element.querySelector('svg').style.setProperty('fill', 'var(--yt-spec-call-to-action)');
  912. } else {
  913. this.element.querySelector('svg').style.setProperty('fill', 'currentcolor');
  914. }
  915. }
  916.  
  917. toggleActive() {
  918. this.isActive = !this.isActive;
  919.  
  920. this.setButtonActive();
  921.  
  922. GM.setValue(KEY_IS_ACTIVE, this.isActive);
  923.  
  924. if (this.isActive) {
  925. moveList.ensure();
  926. hideList.ensure();
  927. } else {
  928. moveList.revert(false);
  929. hideList.revert(false);
  930. }
  931. }
  932.  
  933. onLongClick() {
  934. $config.edit()
  935. .then(() => {
  936. resetConfig();
  937.  
  938. hideAll(this.isActive);
  939. })
  940. .catch((error) => {
  941. console.error(error);
  942.  
  943. if (window.confirm(
  944. `[${TITLE}]` +
  945. '\n\nYour config\'s structure is invalid.' +
  946. '\nThis could be due to a script update or your data being corrupted.' +
  947. '\n\nError Message:' +
  948. `\n${error}` +
  949. '\n\nWould you like to erase your data?'
  950. )) {
  951. $config.reset();
  952. }
  953. });
  954. }
  955.  
  956. async onMouseDown(event) {
  957. if (event.button === 0) {
  958. new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
  959. }
  960. }
  961. }
  962.  
  963. // Main
  964.  
  965. (() => {
  966. let button;
  967.  
  968. const loadButton = () => {
  969. if (!button) {
  970. try {
  971. getButtonDock();
  972.  
  973. button = new Button();
  974. } catch (e) {
  975. const emitter = document.getElementById('page-manager');
  976. const bound = () => {
  977. loadButton();
  978.  
  979. emitter.removeEventListener('yt-action', bound);
  980. };
  981.  
  982. emitter.addEventListener('yt-action', bound);
  983.  
  984. return;
  985. }
  986. } else if (button.isActive) {
  987. hideList.ensure();
  988.  
  989. moveList.ensure();
  990. }
  991.  
  992. button.show();
  993. };
  994.  
  995. const isGridView = () => {
  996. return Boolean(
  997. document.querySelector('ytd-browse[page-subtype="subscriptions"]:not([hidden])') &&
  998. document.querySelector('ytd-browse > ytd-two-column-browse-results-renderer ytd-rich-grid-row ytd-rich-item-renderer ytd-rich-grid-media')
  999. );
  1000. };
  1001.  
  1002. const onNavigate = ({browseEndpoint}) => {
  1003. if (browseEndpoint) {
  1004. const {params, browseId} = browseEndpoint;
  1005.  
  1006. if ((params === 'MAE%3D' || (!params && (!button || isGridView()))) && browseId === 'FEsubscriptions') {
  1007. const emitter = document.querySelector('ytd-app');
  1008. const event = 'yt-action';
  1009.  
  1010. if (button || isGridView()) {
  1011. const listener = ({detail}) => {
  1012. if (detail.actionName === 'ytd-update-elements-per-row-action') {
  1013. loadButton();
  1014.  
  1015. emitter.removeEventListener(event, listener);
  1016. }
  1017. };
  1018.  
  1019. emitter.addEventListener(event, listener);
  1020. } else {
  1021. const listener = ({detail}) => {
  1022. if (detail.actionName === 'ytd-update-grid-state-action') {
  1023. if (isGridView()) {
  1024. loadButton();
  1025. }
  1026.  
  1027. emitter.removeEventListener(event, listener);
  1028. }
  1029. };
  1030.  
  1031. emitter.addEventListener(event, listener);
  1032. }
  1033.  
  1034. return;
  1035. }
  1036. }
  1037.  
  1038. if (button) {
  1039. button.hide();
  1040.  
  1041. if (button.isActive) {
  1042. moveList.revert(false);
  1043. hideList.revert(false);
  1044. }
  1045. }
  1046. };
  1047.  
  1048. document.body.addEventListener('yt-navigate-finish', ({detail}) => {
  1049. onNavigate(detail.endpoint);
  1050. });
  1051. })();