YouTube Sub Feed Filter 2

Filters your YouTube subscriptions feed.

当前为 2022-06-15 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Sub Feed Filter 2
  3. // @version 0.1
  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://greasyfork.org/scripts/446506-tree-frame-2/code/Tree%20Frame%202.js?version=1061073
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @grant GM.deleteValue
  14. // ==/UserScript==
  15.  
  16. //TODO Include filters for badges (Verified, Official Artist Channel, ...)
  17. // Also view counts, video lengths, ...
  18.  
  19. // User config
  20.  
  21. const LONG_PRESS_TIME = 400;
  22. const REGEXP_FLAGS = 'i';
  23.  
  24. // Dev config
  25.  
  26. const VIDEO_TYPE_IDS = {
  27. 'GROUPS': {
  28. 'ALL': 'All',
  29. 'STREAMS': 'Streams',
  30. 'PREMIERS': 'Premiers',
  31. 'NONE': 'None'
  32. },
  33. 'INDIVIDUALS': {
  34. 'STREAMS_SCHEDULED': 'Scheduled Streams',
  35. 'STREAMS_LIVE': 'Live Streams',
  36. 'STREAMS_FINISHED': 'Finished Streams',
  37. 'PREMIERS_SCHEDULED': 'Scheduled Premiers',
  38. 'PREMIERS_LIVE': 'Live Premiers',
  39. 'SHORTS': 'Shorts',
  40. 'NORMAL': 'Basic Videos'
  41. }
  42. };
  43.  
  44. const FRAME_STYLE = {
  45. 'OUTER': {'zIndex': 10000},
  46. 'INNER': {
  47. 'headBase': '#ff0000',
  48. 'headButtonExit': '#000000',
  49. 'borderHead': '#ffffff',
  50. 'nodeBase': ['#222222', '#111111'],
  51. 'borderTooltip': '#570000'
  52. }
  53. };
  54. const TITLE = 'YouTube Sub Feed Filter';
  55. const KEY_TREE = 'YTSFF_TREE';
  56. const KEY_IS_ACTIVE = 'YTSFF_IS_ACTIVE';
  57.  
  58. function getVideoTypes(children) {
  59. const registry = new Set();
  60. const register = (value) => {
  61. if (registry.has(value)) {
  62. throw new Error(`Overlap found at '${value}'.`);
  63. }
  64.  
  65. registry.add(value);
  66. };
  67.  
  68. for (const {value} of children) {
  69. switch (value) {
  70. case VIDEO_TYPE_IDS.GROUPS.ALL:
  71. Object.values(VIDEO_TYPE_IDS.INDIVIDUALS).forEach(register);
  72. break;
  73.  
  74. case VIDEO_TYPE_IDS.GROUPS.STREAMS:
  75. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED);
  76. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE);
  77. register(VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED);
  78. break;
  79.  
  80. case VIDEO_TYPE_IDS.GROUPS.PREMIERS:
  81. register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED);
  82. register(VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE);
  83. break;
  84.  
  85. default:
  86. register(value);
  87. }
  88. }
  89.  
  90. return registry;
  91. }
  92.  
  93. const DEFAULT_TREE = (() => {
  94. const regexPredicate = (value) => {
  95. try {
  96. RegExp(value);
  97. } catch (e) {
  98. return 'Value must be a valid regular expression.';
  99. }
  100.  
  101. return true;
  102. };
  103.  
  104. return {
  105. 'children': [
  106. {
  107. 'label': 'Filters',
  108. 'children': [],
  109. 'seed': {
  110. 'label': 'Filter Name',
  111. 'value': '',
  112. 'children': [
  113. {
  114. 'label': 'Channel Regex',
  115. 'children': [],
  116. 'seed': {
  117. 'value': '^',
  118. 'predicate': regexPredicate
  119. }
  120. },
  121. {
  122. 'label': 'Video Regex',
  123. 'children': [],
  124. 'seed': {
  125. 'value': '^',
  126. 'predicate': regexPredicate
  127. }
  128. },
  129. {
  130. 'label': 'Video Types',
  131. 'children': [{
  132. 'value': VIDEO_TYPE_IDS.GROUPS.ALL,
  133. 'predicate': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
  134. }],
  135. 'seed': {
  136. 'value': VIDEO_TYPE_IDS.GROUPS.NONE,
  137. 'predicate': Object.values(VIDEO_TYPE_IDS.GROUPS).concat(Object.values(VIDEO_TYPE_IDS.INDIVIDUALS))
  138. },
  139. 'childPredicate': (children) => {
  140. try {
  141. getVideoTypes(children);
  142. } catch ({message}) {
  143. return message;
  144. }
  145.  
  146. return true;
  147. }
  148. }
  149. ]
  150. }
  151. }
  152. // {
  153. // 'label': 'Options',
  154. // 'children': [
  155. // {
  156. // // <div id="progress" class="style-scope ytd-thumbnail-overlay-resume-playback-renderer" style="width: 45%;"></div>
  157. // 'label': 'Watched Cutoff (%)',
  158. // 'value': 100,
  159. // 'predicate': (value) => value > 0 ? true : 'Value must be greater than 0'
  160. // }
  161. // ]
  162. // }
  163. ]
  164. };
  165. })();
  166.  
  167. // Video element helpers
  168.  
  169. function getAllSections() {
  170. return [...document
  171. .querySelector('.ytd-page-manager[page-subtype="subscriptions"]')
  172. .querySelectorAll('ytd-item-section-renderer')
  173. ];
  174. }
  175.  
  176. function getAllVideos(section) {
  177. return [...section.querySelectorAll('ytd-grid-video-renderer')];
  178. }
  179.  
  180. function firstWordEquals(element, word) {
  181. return element.innerText.split(' ')[0] === word;
  182. }
  183.  
  184. function getVideoBadges(video) {
  185. const container = video.querySelector('#video-badges');
  186.  
  187. return container ? container.querySelectorAll('.badge') : [];
  188. }
  189.  
  190. function getMetadataLine(video) {
  191. return video.querySelector('#metadata-line');
  192. }
  193.  
  194. // Video hiding predicates
  195.  
  196. class SectionSplitter {
  197. static splitters = {
  198. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_SCHEDULED]: (video) => {
  199. const [schedule] = getMetadataLine(video).children;
  200.  
  201. return firstWordEquals(schedule, 'Scheduled');
  202. },
  203. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_LIVE]: (video) => {
  204. for (const badge of getVideoBadges(video)) {
  205. if (firstWordEquals(badge.querySelector('span.ytd-badge-supported-renderer'), 'LIVE')) {
  206. return true;
  207. }
  208. }
  209.  
  210. return false;
  211. },
  212. [VIDEO_TYPE_IDS.INDIVIDUALS.STREAMS_FINISHED]: (video) => {
  213. const metaDataLine = getMetadataLine(video);
  214.  
  215. return metaDataLine.children.length > 1 && firstWordEquals(metaDataLine.children[1], 'Streamed');
  216. },
  217. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_SCHEDULED]: (video) => {
  218. const [schedule] = getMetadataLine(video).children;
  219.  
  220. return firstWordEquals(schedule, 'Premieres');
  221. },
  222. [VIDEO_TYPE_IDS.INDIVIDUALS.PREMIERS_LIVE]: (video) => {
  223. for (const badge of getVideoBadges(video)) {
  224. const text = badge.querySelector('span.ytd-badge-supported-renderer');
  225.  
  226. if (firstWordEquals(text, 'PREMIERING') || firstWordEquals(text, 'PREMIERE')) {
  227. return true;
  228. }
  229. }
  230.  
  231. return false;
  232. },
  233. [VIDEO_TYPE_IDS.INDIVIDUALS.SHORTS]: (video) => {
  234. return new Promise(async (resolve) => {
  235. let icon = video.querySelector('[overlay-style]');
  236.  
  237. // Stop searching if it gets a live badge
  238. const predicate = () => getVideoBadges(video).length || (icon && icon.getAttribute('overlay-style'));
  239.  
  240. if (!predicate()) {
  241. await new Promise((resolve) => {
  242. const observer = new MutationObserver(() => {
  243. icon = video.querySelector('[overlay-style]');
  244.  
  245. if (predicate()) {
  246. observer.disconnect();
  247.  
  248. resolve();
  249. }
  250. });
  251.  
  252. observer.observe(video, {
  253. 'childList': true,
  254. 'subtree': true,
  255. 'attributes': true
  256. });
  257. });
  258. }
  259.  
  260. resolve(icon && icon.getAttribute('overlay-style') === 'SHORTS');
  261. });
  262. },
  263. [VIDEO_TYPE_IDS.INDIVIDUALS.NORMAL]: (video) => {
  264. const [, {innerText}] = getMetadataLine(video).children;
  265.  
  266. return new RegExp('^\\d+ .+ ago$').test(innerText);
  267. }
  268. };
  269.  
  270. hideables = [];
  271. promises = [];
  272.  
  273. constructor(section) {
  274. this.videos = getAllVideos(section);
  275. }
  276.  
  277. addHideables(channelRegex, titleRegex, videoType) {
  278. const predicate = SectionSplitter.splitters[videoType];
  279. const promises = [];
  280.  
  281. for (const video of this.videos) {
  282. if (
  283. channelRegex.test(video.querySelector('a.yt-formatted-string').innerText) &&
  284. titleRegex.test(video.querySelector('a#video-title').innerText)
  285. ) {
  286. promises.push(new Promise(async (resolve) => {
  287. if (await predicate(video)) {
  288. this.hideables.push(video);
  289. }
  290.  
  291. resolve();
  292. }));
  293. }
  294. }
  295.  
  296. this.promises.push(Promise.all(promises));
  297. }
  298. }
  299.  
  300. // Hider functions
  301.  
  302. function hideSection(section, doHide = true) {
  303. if (section.matches(':first-child')) {
  304. const title = section.querySelector('#title');
  305. const videoContainer = section.querySelector('#contents').querySelector('#contents');
  306.  
  307. if (doHide) {
  308. title.style.display = 'none';
  309. videoContainer.style.display = 'none';
  310. section.style.borderBottom = 'none';
  311. } else {
  312. title.style.removeProperty('display');
  313. videoContainer.style.removeProperty('display');
  314. section.style.removeProperty('borderBottom');
  315. }
  316. } else {
  317. if (doHide) {
  318. section.style.display = 'none';
  319. } else {
  320. section.style.removeProperty('display');
  321. }
  322. }
  323. }
  324.  
  325. function hideVideo(video, doHide = true) {
  326. if (doHide) {
  327. video.style.display = 'none';
  328. } else {
  329. video.style.removeProperty('display');
  330. }
  331. }
  332.  
  333. function getConfig([filters, options]) {
  334. return {
  335. 'filters': (() => {
  336. const getRegex = ({children}) => new RegExp(children.length === 0 ? '' :
  337. children.map(({value}) => `(${value})`).join('|'), REGEXP_FLAGS);
  338.  
  339. return filters.children.map(({'children': [channel, video, type]}) => ({
  340. 'channels': getRegex(channel),
  341. 'videos': getRegex(video),
  342. 'types': type.children.length === 0 ? Object.values(VIDEO_TYPE_IDS.INDIVIDUALS) : getVideoTypes(type.children)
  343. }));
  344. })(),
  345. 'options': {
  346. // 'time': options.children[0].value
  347. }
  348. };
  349. }
  350.  
  351. function hideFromSections(config, sections = getAllSections()) {
  352. for (const section of sections) {
  353. if (section.matches('ytd-continuation-item-renderer')) {
  354. continue;
  355. }
  356.  
  357. const splitter = new SectionSplitter(section);
  358.  
  359. // Separate the section's videos by hideability
  360. for (const {channels, videos, types} of config.filters) {
  361. for (const type of types) {
  362. splitter.addHideables(channels, videos, type);
  363. }
  364. }
  365.  
  366. Promise.all(splitter.promises)
  367. .then(() => {
  368. // Hide hideable videos
  369. for (const video of splitter.hideables) {
  370. hideVideo(video);
  371. }
  372. });
  373. }
  374. }
  375.  
  376. async function hideFromMutations(mutations) {
  377. const sections = [];
  378.  
  379. for (const {addedNodes} of mutations) {
  380. for (const section of addedNodes) {
  381. sections.push(section);
  382. }
  383. }
  384.  
  385. hideFromSections(getConfig(await getForest(KEY_TREE, DEFAULT_TREE)), sections);
  386. }
  387.  
  388. function resetConfig() {
  389. for (const section of getAllSections()) {
  390. hideSection(section, false);
  391.  
  392. for (const video of getAllVideos(section)) {
  393. hideVideo(video, false);
  394. }
  395. }
  396. }
  397.  
  398. // Button
  399.  
  400. function getButtonDock() {
  401. return document
  402. .querySelector('ytd-browse[page-subtype="subscriptions"]')
  403. .querySelector('#title-container')
  404. .querySelector('#top-level-buttons-computed');
  405. }
  406.  
  407. class ClickHandler {
  408. constructor(button, onShortClick, onLongClick) {
  409. this.onShortClick = (function() {
  410. onShortClick();
  411.  
  412. window.clearTimeout(this.longClickTimeout);
  413.  
  414. window.removeEventListener('mouseup', this.onShortClick);
  415. }).bind(this);
  416.  
  417. this.onLongClick = (function() {
  418. window.removeEventListener('mouseup', this.onShortClick);
  419.  
  420. onLongClick();
  421. }).bind(this);
  422.  
  423. this.longClickTimeout = window.setTimeout(this.onLongClick, LONG_PRESS_TIME);
  424.  
  425. window.addEventListener('mouseup', this.onShortClick);
  426. }
  427. }
  428.  
  429. class Button {
  430. constructor(pageManager) {
  431. this.pageManager = pageManager;
  432. this.element = this.getNewButton();
  433.  
  434. this.element.addEventListener('mousedown', this.onMouseDown.bind(this));
  435.  
  436. GM.getValue(KEY_IS_ACTIVE, true).then((isActive) => {
  437. this.isActive = isActive;
  438.  
  439. if (isActive) {
  440. this.setButtonActive();
  441.  
  442. this.pageManager.start();
  443. }
  444. });
  445. }
  446.  
  447. addToDOM(button = this.element) {
  448. const {parentElement} = getButtonDock();
  449. parentElement.appendChild(button);
  450. }
  451.  
  452. getNewButton() {
  453. const openerTemplate = getButtonDock().children[1];
  454. const button = openerTemplate.cloneNode(false);
  455.  
  456. this.addToDOM(button);
  457.  
  458. button.innerHTML = openerTemplate.innerHTML;
  459.  
  460. button.querySelector('button').innerHTML = openerTemplate.querySelector('button').innerHTML;
  461.  
  462. button.querySelector('a').removeAttribute('href');
  463.  
  464. // TODO Build the svg via javascript
  465. 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>`;
  466.  
  467. return button;
  468. }
  469.  
  470. hide() {
  471. this.element.style.display = 'none';
  472. }
  473.  
  474. show() {
  475. this.element.parentElement.appendChild(this.element);
  476. this.element.style.removeProperty('display');
  477. }
  478.  
  479. setButtonActive() {
  480. if (this.isActive) {
  481. this.element.classList.add('style-blue-text');
  482. this.element.classList.remove('style-opacity');
  483. } else {
  484. this.element.classList.add('style-opacity');
  485. this.element.classList.remove('style-blue-text');
  486. }
  487. }
  488.  
  489. toggleActive() {
  490. this.isActive = !this.isActive;
  491.  
  492. this.setButtonActive();
  493.  
  494. GM.setValue(KEY_IS_ACTIVE, this.isActive);
  495.  
  496. if (this.isActive) {
  497. this.pageManager.start();
  498. } else {
  499. this.pageManager.stop();
  500. }
  501. }
  502.  
  503. onLongClick() {
  504. editForest(KEY_TREE, DEFAULT_TREE, TITLE, FRAME_STYLE.INNER, FRAME_STYLE.OUTER)
  505. .then((forest) => {
  506. if (this.isActive) {
  507. resetConfig();
  508.  
  509. // Hide filtered videos
  510. hideFromSections(getConfig(forest));
  511. }
  512. })
  513. .catch((error) => {
  514. console.error(error);
  515.  
  516. if (window.confirm(
  517. 'An error was thrown by Tree Frame; Your data may be corrupted.\n' +
  518. 'Error Message: ' + error + '\n\n' +
  519. 'Would you like to clear your saved configs?'
  520. )) {
  521. GM.deleteValue(KEY_TREE);
  522. }
  523. });
  524. }
  525.  
  526. async onMouseDown(event) {
  527. if (event.button === 0) {
  528. new ClickHandler(this.element, this.toggleActive.bind(this), this.onLongClick.bind(this));
  529. }
  530. }
  531. }
  532.  
  533. // Page load/navigation handler
  534.  
  535. class PageManager {
  536. constructor() {
  537. // Don't run in frames (e.g. stream chat frame)
  538. if (window.parent !== window) {
  539. return;
  540. }
  541.  
  542. this.videoObserver = new MutationObserver(hideFromMutations);
  543.  
  544. const emitter = document.getElementById('page-manager');
  545. const event = 'yt-action';
  546. const onEvent = ({detail}) => {
  547. if (detail.actionName === 'ytd-update-grid-state-action') {
  548. this.onLoad();
  549.  
  550. emitter.removeEventListener(event, onEvent);
  551. }
  552. };
  553.  
  554. emitter.addEventListener(event, onEvent);
  555. }
  556.  
  557. start() {
  558. getForest(KEY_TREE, DEFAULT_TREE).then(forest => {
  559. // Call hide function when new videos are loaded
  560. this.videoObserver.observe(
  561. document.querySelector('ytd-browse[page-subtype="subscriptions"]').querySelector('div#contents'),
  562. {childList: true}
  563. );
  564.  
  565. hideFromSections(getConfig(forest));
  566. });
  567. }
  568.  
  569. stop() {
  570. this.videoObserver.disconnect();
  571.  
  572. resetConfig();
  573. }
  574.  
  575. isSubPage() {
  576. return new RegExp('^.*youtube.com/feed/subscriptions(\\?flow=1|\\?pbjreload=\\d+)?$').test(document.URL);
  577. }
  578.  
  579. isGridView() {
  580. return document.querySelector('ytd-expanded-shelf-contents-renderer') === null;
  581. }
  582.  
  583. onLoad() {
  584. // Allow configuration
  585. if (this.isSubPage() && this.isGridView()) {
  586. this.button = new Button(this);
  587.  
  588. this.button.show();
  589. }
  590.  
  591. document.body.addEventListener('yt-navigate-finish', (function({detail}) {
  592. this.onNavigate(detail);
  593. }).bind(this));
  594.  
  595. document.body.addEventListener('popstate', (function({state}) {
  596. this.onNavigate(state);
  597. }).bind(this));
  598. }
  599.  
  600. onNavigate({endpoint}) {
  601. if (endpoint.browseEndpoint) {
  602. const {params, browseId} = endpoint.browseEndpoint;
  603.  
  604. if ((params === 'MAE%3D' || (!params && this.isGridView())) && browseId === 'FEsubscriptions') {
  605. if (!this.button) {
  606. this.button = new Button(this);
  607. }
  608.  
  609. this.button.show();
  610.  
  611. this.start();
  612. } else {
  613. if (this.button) {
  614. this.button.hide();
  615. }
  616.  
  617. this.videoObserver.disconnect();
  618. }
  619. }
  620. }
  621. }
  622.  
  623. // Main
  624.  
  625. new PageManager();