YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

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

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