SpankBang.com Improved

Infinite scroll. Lazy loading. Filter by duration, include/exclude tags.

Versión del día 02/05/2024. Echa un vistazo a la versión más reciente.

  1. // ==UserScript==
  2. // @name SpankBang.com Improved
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0.2
  5. // @license MIT
  6. // @description Infinite scroll. Lazy loading. Filter by duration, include/exclude tags.
  7. // @author smartacephale
  8. // @match https://*.spankbang.com/*
  9. // @require https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js
  10. // @grant GM_addStyle
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=spankbang.com
  12. // ==/UserScript==
  13.  
  14. const LOGO = `
  15. ⡕⡧⡳⡽⣿⣇⠀⢀⠀⠄⠐⡐⠌⡂⡂⠠⠀⠠⠀⠠⠀⠠⡐⡆⡇⣇⢎⢆⠆⠌⢯⡷⡥⡂⡐⠨⣻⣳⢽⢝⣵⡫⣗⢯⣺⢵⢹⡪⡳⣝⢮⡳⣿⣿⣿⣿⣿⣿⣿⣿
  16. ⢎⢞⡜⣞⣿⡯⡆⠀⠄⠐⠀⢐⠡⢊⢐⢀⠈⠀⠄⠁⡀⠡⠸⡸⡪⣪⢺⢸⢱⠡⢑⡝⣟⣧⡂⠅⠪⣻⣎⢯⡺⣪⣗⢯⣺⢪⡣⡯⣝⢼⡪⣞⣽⣿⣿⣿⣿⣿⣿⣿
  17. ⡱⡣⣣⢳⣻⣯⢿⡐⠀⠂⠁⢀⠪⢐⠐⠄⡀⠁⠄⠁⠀⠄⡑⠆⡎⢎⢎⢇⢕⠬⠀⢇⢣⢻⣞⠌⠌⡪⣷⡱⣝⢮⣺⢕⡗⣕⢧⢳⢕⢗⣝⢞⣾⣿⣿⣿⣿⣿⣿⣻
  18. ⡸⡪⣪⡚⣞⣯⡗⣝⡀⠄⠁⡀⠌⡂⠅⡁⠄⠂⠐⠈⠀⢂⢐⢔⢜⢜⢜⢜⢔⢬⢊⠠⠨⠨⣻⣽⢐⠨⡺⣞⢼⢕⣗⡽⣪⢺⢜⢵⡹⣕⢵⡫⣾⣿⣿⣿⣿⣿⢯⣟
  19. ⢎⢇⢇⢧⡫⣿⡞⡜⡄⠀⠄⠀⠐⠄⡁⠀⠄⠐⠀⠂⢁⢐⢔⢕⢕⠱⢱⠱⡱⡱⡱⡅⡂⢁⠪⡿⣎⡢⡘⢽⡪⡧⣳⡝⣜⢎⢗⢇⢯⡪⡧⣻⡺⣿⣿⣿⣿⢯⡻⡮
  20. ⢕⢝⢜⡜⡮⣻⣗⢕⠅⡀⠂⠈⢈⠐⠀⠁⢀⠐⠀⡁⠄⢊⢢⢣⢣⢣⢣⢣⢣⢫⢪⢪⠀⠄⠨⡚⣿⣳⡜⡘⣗⢽⡺⡪⡺⡸⡵⡹⡕⡧⣫⡺⡺⣿⣿⡿⡯⡯⣫⢯
  21. ⢸⢱⢱⢱⢕⢯⣿⢜⠌⢀⠀⠂⠀⠂⠁⠈⠀⠀⠄⠀⠄⢑⠌⡆⢇⢅⠕⡌⡬⡪⡪⡪⠀⠄⢁⠸⡸⣯⡻⣪⢺⡵⡫⡪⣣⢫⡪⡣⡏⣞⢜⢮⣫⣻⡿⣫⢯⢞⣗⢽
  22. ⢪⢪⢪⢪⢎⢞⢾⡇⢕⠄⡆⡪⡘⡔⢔⢀⠐⠀⢀⠁⠠⠀⠑⠜⡌⡖⡕⡕⡕⢕⠑⠀⠠⠐⠀⡀⠕⡳⣻⣜⣕⣯⢣⠣⡣⡣⡣⡫⡪⡪⣎⢧⣧⡳⣝⢮⡳⡽⣜⢵
  23. ⡸⡸⡸⡸⡸⡱⣫⢯⣲⢱⢱⢘⢜⢜⢕⢕⢅⣂⠀⡀⠐⠀⡁⢐⠨⢐⠡⡊⢌⠂⠄⠂⠀⠂⡠⡠⡢⡫⡳⡱⡝⡮⡪⣇⢧⢳⢕⣝⢮⡫⡮⡳⣕⢵⣓⢗⡝⡮⡺⡜
  24. ⠱⡑⡕⡕⡝⡜⣎⢿⣪⢗⡝⡜⡜⡜⡜⢜⢕⢎⢎⢆⢎⢔⢄⠢⡌⢆⢕⠨⡢⢱⢰⢸⢸⣜⢮⢎⢎⢎⢇⢇⢯⢪⢇⡗⣝⢮⢳⢕⡗⣝⢮⡫⡮⣣⡳⡕⣝⢼⢸⢸
  25. ⢜⢸⢨⢪⢪⢪⢪⣻⣺⢯⢎⣇⢧⢧⢧⢧⢯⢾⡵⣯⢾⣼⣜⣜⢜⢜⢜⢜⢜⢜⡜⡮⣗⡯⣟⣎⢎⢆⢇⢕⢕⢇⢗⡝⣜⢮⣳⣟⣾⣷⣷⣿⣮⣎⢎⢎⢎⢎⢎⠎
  26. ⠣⡑⢌⠎⡜⢜⢜⢜⢜⡕⣧⡳⣫⢏⡯⡯⡯⡯⣯⢯⣟⢷⣻⣟⣿⢷⣷⣳⡽⡮⡾⣝⡎⡏⡎⡇⡕⡜⡔⡕⡕⣝⢜⢮⡳⡽⣺⢽⣻⢿⢿⣟⣿⣺⣝⢮⡢⡧⣧⡠
  27. ⠐⠨⢢⢑⢅⢣⢱⢜⢞⢮⡳⣝⢮⣻⣪⢯⢯⡻⣺⢝⡾⣝⢷⢽⣺⢯⢿⢽⣻⣮⡳⣕⢇⢇⢇⢇⠪⡪⡪⡪⡪⣎⢯⢳⣹⡪⡯⣫⢯⣻⢽⣳⣳⣳⢳⣝⢮⡫⡷⣷
  28. ⠀⠨⡂⡅⡖⡵⡹⣕⢏⡧⢯⢮⡳⣣⢷⢽⢕⣯⡳⣏⡯⣞⡽⣳⢽⢽⢽⣝⣗⣗⣟⢮⢳⡱⡱⡱⡑⡕⡜⢜⢜⡜⣎⢧⡣⡯⡺⣝⡵⡯⡯⣞⣞⢮⣳⡳⣝⣞⡽⣺
  29. ⠀⠰⡸⡸⣸⢪⡫⣎⢗⢽⢕⣗⢽⣕⢯⣳⡫⣞⢮⣳⢽⣪⢯⢞⡽⣺⢵⣳⣳⡳⣳⢝⢵⡹⡪⣎⢎⢆⢇⢇⢇⢗⡕⡧⡳⣝⡝⡮⣞⡽⡽⡵⣳⣫⢞⡮⣗⣗⢽⣳
  30. ⠀⠨⢢⢣⢳⡱⣝⢼⢭⢳⢳⢕⣗⢗⡽⣪⢞⡽⣕⢷⢝⡮⣏⡯⣞⣗⢽⡺⡼⣺⢵⡫⡧⣳⢹⢜⡜⡜⡜⢜⢪⢣⡫⣎⢯⡪⣞⣝⢞⢮⢏⡯⣳⣳⣫⢾⢕⣗⢯⣞
  31. ⠀⠀⠱⡱⡱⡕⡧⡳⡕⣏⢗⣝⢮⢳⢝⢮⡳⣝⢮⡳⣝⢮⡳⣝⢮⡺⡵⣫⢯⡳⣳⢝⣞⡜⣎⢇⢎⠎⡪⢊⠎⡎⡎⡮⡺⣜⢮⢮⣫⣫⡳⡽⣕⣗⢵⣫⢯⡺⡵⣳
  32. ⠀⠀⠈⢎⢎⠮⡺⡸⡕⡧⡳⣕⢝⢮⡫⣣⢯⣪⢳⢝⢮⢳⢝⢮⡳⣝⣝⢮⡳⣝⢮⡳⡵⣝⢼⢸⢐⠅⠂⡐⢅⢇⢣⢣⢳⡱⣝⠮⡮⣪⢞⣝⢮⡺⣕⢗⣗⢽⡹⣪
  33. ⠀⠈⠀⠌⡒⡝⡜⣕⢕⢧⢫⡪⡳⡱⣝⢜⢮⡪⣳⢹⡪⣳⢹⢕⢽⡸⣜⢵⢝⢮⢳⢝⢞⢮⡪⡣⡣⡑⢅⢢⠱⡘⡜⡜⣜⢜⠮⡝⡮⡪⡧⡳⡵⣹⡪⡳⡕⡗⣝⢮
  34. ⠀⠐⠀⡁⠌⢎⢎⢎⢮⢪⢎⢮⢪⢳⢱⢝⢜⢎⢮⢣⡫⡪⡎⣗⢕⢧⢳⡱⡝⣎⢗⣝⢕⡗⣝⢼⢸⢨⢢⢃⢇⢣⢣⢣⢣⢳⢹⢪⢎⢗⢝⡜⣎⢮⢪⡳⡹⣪⢺⢸
  35. ⠀⠠⠐⢀⠐⠈⡎⡪⡪⡪⡪⡪⡣⡫⡪⡪⡣⡫⡪⡣⡣⡫⣪⢪⢺⢸⢪⢪⢺⢸⢪⡪⡺⡸⣪⢪⢪⢪⠸⡨⡊⡎⡎⡎⡇⡏⡎⡞⡜⡕⣕⢕⢕⡕⡇⡧⡫⡪⡪⡪
  36. ⢀⠀⠐⠀⠄⠁⠨⡊⡎⡎⡎⡎⡎⡎⡎⡎⡇⡏⡎⡎⡎⡎⡎⡎⡎⡮⡪⡣⡳⡱⡱⡕⡝⣜⢜⢜⢜⢔⢕⢱⠸⡸⡸⡸⡸⡸⡸⡸⡸⡸⡸⡨⡣⡣⡣⡣⡣⡣⡃⡇
  37. ⠀⠀⠈⡀⠂⠐⠀⢑⠜⡌⢎⢪⠪⡪⡪⡪⢪⠪⡪⢪⠪⡪⡪⢪⠪⡊⡎⡜⡌⡎⢎⢎⢎⢎⠎⡎⡪⠢⡑⢌⢪⢘⢔⠱⡡⢣⢃⢇⠕⡕⢅⢇⢣⢱⢑⢕⠸⡐⡱⡘`;
  38.  
  39.  
  40. //====================================================================================================
  41.  
  42. unsafeWindow.Vue = Vue;
  43. const { ref, watch, reactive, createApp } = Vue;
  44.  
  45. //====================================================================================================
  46.  
  47. function parseDOM(html) {
  48. const parsed = new DOMParser().parseFromString(html, 'text/html').body;
  49. return parsed.children.length > 1 ? parsed : parsed.firstElementChild;
  50. }
  51.  
  52. function fetchHtml(url) { return fetch(url).then((r) => r.text()).then((h) => parseDOM(h)); }
  53.  
  54. function fetchText(url) { return fetch(url).then((r) => r.text()); }
  55.  
  56. function parseIntegerOr(n, or) {
  57. return Number.isInteger(parseInt(n)) ? parseInt(n) : or;
  58. }
  59.  
  60. function stringToTags(s) {
  61. return s.split(",").map(s => s.trim().toLowerCase()).filter(_ => _);
  62. }
  63.  
  64. //====================================================================================================
  65.  
  66. class SPANKBANG_RULES {
  67. constructor() {
  68. this.PAGINATION = document.querySelector('.paginate-bar') || document.querySelector('.pagination');
  69. this.PAGINATION_LAST = parseInt(
  70. document.querySelector('.paginate-bar .status span')?.innerText.match(/\d+/)?.[0] ||
  71. document.querySelector('.pagination .next')?.previousElementSibling?.innerText);
  72. this.CONTAINER = document.querySelectorAll('.results .video-list')[0];
  73. this.HAS_VIDEOS = document.querySelector('.video-list');
  74. }
  75.  
  76. GET_THUMBS(html) {
  77. return html.querySelectorAll('.video-item:not(.clear-fix)');
  78. }
  79.  
  80. GET_THUMB_URL(thumb) {
  81. return thumb.querySelector('.thumb').href;
  82. }
  83.  
  84. THUMB_DATA(thumb) {
  85. const title = (thumb.querySelector('span.n') || thumb.querySelector('a.n')).innerText.toLowerCase();
  86. const duration = (parseInt(thumb.querySelector('span.l')?.innerText) || 1) * 60;
  87. return {
  88. title,
  89. duration
  90. }
  91. }
  92.  
  93. URL_DATA() {
  94. const { href, pathname, search, origin } = window.location;
  95. const mres = pathname.split(/\/(\d+)\/?$/);
  96. const basePathname = mres[0];
  97. const offset = parseInt(mres[1]) || 1;
  98. const iteratable_url = n => `${origin}${basePathname}/${n}/${search}`;
  99. return {
  100. offset,
  101. iteratable_url
  102. };
  103. }
  104. }
  105.  
  106. const RULES = new SPANKBANG_RULES();
  107.  
  108. //====================================================================================================
  109.  
  110. class Observer {
  111. constructor(callback) {
  112. this.callback = callback;
  113. this.observer = new IntersectionObserver(this.handleIntersection.bind(this));
  114. }
  115.  
  116. observe(target) {
  117. this.observer.observe(target);
  118. }
  119.  
  120. throttle(target, throttleTime) {
  121. this.observer.unobserve(target);
  122. setTimeout(() => this.observer.observe(target), throttleTime);
  123. }
  124.  
  125. handleIntersection(entries) {
  126. for (const entry of entries) {
  127. if (entry.isIntersecting) {
  128. this.callback(entry.target);
  129. }
  130. }
  131. }
  132.  
  133. static observeWhile(target, callback, throttleTime) {
  134. const observer_ = new Observer(async (target) => {
  135. const condition = await callback();
  136. if (condition) observer_.throttle(target, throttleTime);
  137. });
  138. observer_.observe(target);
  139. return observer_;
  140. }
  141. }
  142.  
  143. //====================================================================================================
  144.  
  145. const SCROLL_RESET_DELAY = 350;
  146.  
  147. class PaginationManager {
  148. constructor() {
  149. stateLocale.pagIndexLast = RULES.PAGINATION_LAST;
  150. this.paginationGenerator = this.createNextPageGenerator();
  151. this.paginationObserver = Observer.observeWhile(RULES.PAGINATION, this.generatorConsume, SCROLL_RESET_DELAY);
  152. }
  153.  
  154. generatorConsume = async () => {
  155. if (!state.infiniteScrollEnabled) return;
  156. const {
  157. value: { url, offset } = {},
  158. done,
  159. } = this.paginationGenerator.next();
  160. if (!done) {
  161. console.log(url);
  162. const nextPageHTML = await fetchHtml(url);
  163. const prevScrollPos = document.documentElement.scrollTop;
  164. handleLoadedHTML(nextPageHTML);
  165. stateLocale.pagIndexCur = offset;
  166. window.scrollTo(0, prevScrollPos);
  167. }
  168. return !this.generatorDone;
  169. }
  170.  
  171. createNextPageGenerator() {
  172. const { offset, iteratable_url } = RULES.URL_DATA();
  173. stateLocale.pagIndexCur = offset;
  174. function* nextPageGenerator() {
  175. for (let c = offset + 1; c <= RULES.PAGINATION_LAST; c++) {
  176. const url = iteratable_url(c);
  177. yield { url, offset: c };
  178. }
  179. }
  180. return nextPageGenerator();
  181. }
  182. }
  183.  
  184. //====================================================================================================
  185.  
  186. class PersistentState {
  187. key = "state_2";
  188.  
  189. constructor(state) {
  190. this.state = reactive(state);
  191. this.sync();
  192. this.watchPersistence();
  193. }
  194.  
  195. sync() {
  196. this.trySetFromLocalStorage();
  197. window.addEventListener('focus', this.trySetFromLocalStorage);
  198. }
  199.  
  200. watchPersistence() {
  201. watch(this.state, (value) => {
  202. this.saveToLocalStorage(this.key, value);
  203. });
  204. }
  205.  
  206. saveToLocalStorage(key, value) {
  207. localStorage.setItem(key, JSON.stringify(value));
  208. }
  209.  
  210. trySetFromLocalStorage = () => {
  211. const localStorageValue = localStorage.getItem(this.key);
  212. if (localStorageValue !== null) {
  213. const prevState = JSON.parse(localStorageValue);
  214. for (const prop of Object.keys(prevState)) {
  215. this.state[prop] = prevState[prop];
  216. }
  217. }
  218. }
  219. }
  220.  
  221. //====================================================================================================
  222.  
  223. class LazyImgLoader {
  224. attributeName = 'data-src';
  225.  
  226. constructor(callback) {
  227. this.lazyImgObserver = new Observer((target) => {
  228. callback(target, this.delazify);
  229. });
  230. }
  231.  
  232. lazify(img) {
  233. img.src = '';
  234. this.lazyImgObserver.observe(img);
  235. }
  236.  
  237. delazify = (target) => {
  238. this.lazyImgObserver.observer.unobserve(target);
  239. target.src = target.getAttribute(this.attributeName);
  240. target.removeAttribute(this.attributeName);
  241. }
  242. }
  243.  
  244. //====================================================================================================
  245.  
  246. class DomManager {
  247. constructor() {
  248. this.data = new Map();
  249. this.container = this.createThumbsContainer();
  250. this.lazyImgLoader = new LazyImgLoader((target, delazify) => {
  251. if (!this.isFiltered(target.parentElement.parentElement.parentElement)) {
  252. delazify(target);
  253. }
  254. });
  255. this.addFilterStyles();
  256. }
  257.  
  258. dataFilters = {
  259. filterDuration: new class {
  260. tag = 'filtered-duration';
  261. createFilter = () => {
  262. return (v) => {
  263. const notInRange = v.duration < state.filterDurationFrom || v.duration > state.filterDurationTo;
  264. return [this.tag, state.filterDuration && notInRange];
  265. }
  266. }
  267. },
  268. filterNegative: new class {
  269. tag = 'filtered-exclude';
  270. createFilter = () => {
  271. const tags = stringToTags(state.filterNegativeTags);
  272. return (v, k) => {
  273. const containTags = tags.some(tag => v.title.includes(tag));
  274. return [this.tag, state.filterNegative && containTags];
  275. }
  276. }
  277. },
  278. filterPositive: new class {
  279. tag = 'filtered-include';
  280. createFilter = () => {
  281. const tags = stringToTags(state.filterPositiveTags);
  282. return (v, k) => {
  283. const containTagsNot = tags.some(tag => !v.title.includes(tag));
  284. return [this.tag, state.filterPositive && containTagsNot];
  285. }
  286. }
  287. }
  288. }
  289.  
  290. addFilterStyles() {
  291. const tags = Object.keys(this.dataFilters).map(k => `.${this.dataFilters[k].tag}`).join(',');
  292. GM_addStyle(`${tags} { display: none !important; }`);
  293. }
  294.  
  295. isFiltered(el) {
  296. return el.className.includes('filtered');
  297. }
  298.  
  299. filter_ = (filters, offset = 0) => {
  300. const runFilters = [];
  301.  
  302. for (const f of Object.keys(filters)) {
  303. runFilters.push(this.dataFilters[f].createFilter());
  304. }
  305.  
  306. let offset_counter = 1;
  307. for (const [k, v] of this.data.entries()) {
  308. offset_counter++;
  309. if (offset_counter > offset) {
  310. for (const rf of runFilters) {
  311. const [tag, condition] = rf(v, k);
  312. v.element.classList.toggle(tag, condition);
  313. }
  314. }
  315. }
  316. }
  317.  
  318. filterAll = (offset) => {
  319. const applyFilters = Object.assign({}, ...Object.keys(this.dataFilters).map(f => ({ [f]: state[f] })));
  320. this.filter_(applyFilters, offset);
  321. }
  322.  
  323. createThumbsContainer() {
  324. return parseDOM('<div class="thumbs-items"></div>');
  325. }
  326.  
  327. handleLoadedHTML = (html, container) => {
  328. if (!html) {
  329. html = parseDOM(RULES.CONTAINER.innerHTML);
  330. RULES.CONTAINER.innerHTML = "";
  331. }
  332.  
  333. const thumbs = RULES.GET_THUMBS(html);
  334. const data_offset = this.data.size;
  335.  
  336. for (const thumbElement of thumbs) {
  337. const url = RULES.GET_THUMB_URL(thumbElement);
  338. if (!url || this.data.has(url)) continue;
  339.  
  340. const { title, duration } = RULES.THUMB_DATA(thumbElement);
  341. this.data.set(url, { element: thumbElement, duration, title });
  342.  
  343. const img = thumbElement.querySelector('img');
  344. this.lazyImgLoader.lazify(img);
  345.  
  346. (container || RULES.CONTAINER).appendChild(thumbElement);
  347. }
  348.  
  349. this.filterAll(data_offset);
  350. }
  351. }
  352.  
  353. const { filter_, handleLoadedHTML } = new DomManager();
  354.  
  355. //====================================================================================================
  356.  
  357. const { state } = new PersistentState({
  358. filterDurationFrom: 0,
  359. filterDurationTo: 600,
  360. filterDuration: false,
  361. filterNegativeTags: "",
  362. filterNegative: false,
  363. filterPositiveTags: "",
  364. filterPositive: false,
  365. infiniteScrollEnabled: true,
  366. uiEnabled: true,
  367. });
  368.  
  369. const stateLocale = reactive({
  370. pagIndexLast: 1,
  371. pagIndexCur: 1,
  372. });
  373.  
  374. watch([() => state.filterDurationFrom, () => state.filterDurationTo], (a, b) => {
  375. state.filterDurationFrom = parseIntegerOr(a[0], b[0]);
  376. state.filterDurationTo = parseIntegerOr(a[1], b[1]);
  377. if (state.filterDuration) filter_({ filterDuration: true });
  378. });
  379.  
  380. watch(() => state.filterDuration, () => filter_({ filterDuration: true }));
  381.  
  382. watch(() => state.filterNegative, () => filter_({ filterNegative: true }));
  383.  
  384. watch(() => state.filterNegativeTags, () => {
  385. if (state.filterNegative) filter_({ filterNegative: true });
  386. }, { deep: true });
  387.  
  388. watch(() => state.filterPositive, () => filter_({ filterPositive: true }));
  389.  
  390. watch(() => state.filterPositiveTags, () => {
  391. if (state.filterPositive) filter_({ filterPositive: true });
  392. }, { deep: true });
  393.  
  394. //====================================================================================================
  395.  
  396. class VueApp {
  397. template = `
  398. <div class="fixed bottom-0 right-0 z-9999 rounded px-2 py-0.5 bg-zinc-800 max-w-full m-1 py-1" v-if="state.uiEnabled">
  399. <div class="flex items-center cursor-pointer py-1 px-2 m-1" @click="state.hidden = !state.hidden">
  400. <span v-text="state.hidden ? '☒' : '⛶'" class="text-right w-full text-xl text-zinc-300"></span>
  401. </div>
  402. <template v-if="!state.hidden">
  403. <div class="flex items-center bg-zinc-900 py-1 px-2 m-1 font-mono">
  404. <input type="checkbox" id="exclude" v-model="state.filterNegative" class="mr-2 size-auto">
  405. <label for="exclude" class="text-zinc-300 flex font-mono">exclude</label>
  406. <input type="text" v-model="state.filterNegativeTags" placeholder="tag1, tag2,.." class="w-full h-8 text-zinc-300 px-3 py-2 bg-zinc-700 mx-2 rounded-sm">
  407. </div>
  408. <div class="flex items-center bg-zinc-900 py-1 px-2 m-1 font-mono">
  409. <input type="checkbox" id="include" v-model="state.filterPositive" class="mr-2 size-auto bg-gray-700">
  410. <label for="include" class="text-zinc-300 flex font-mono">include</label>
  411. <input type="text" v-model="state.filterPositiveTags" placeholder="tag1, tag2,.." class="w-full h-8 text-zinc-300 px-3 py-2 bg-zinc-700 mx-2 rounded-sm size-auto">
  412. </div>
  413. <div class="flex items-center bg-zinc-900 py-2 px-3 m-1 font-mono">
  414. <input type="checkbox" id="infiniteScrollEnabled" v-model="state.infiniteScrollEnabled" class="mr-2 size-auto bg-gray-700">
  415. <label for="infiniteScrollEnabled" class="text-zinc-300 flex font-mono">infinite scroll</label>
  416. <span v-if="stateLocale.pagIndexLast > 1" class="text-zinc-300 ml-auto">
  417. {{ stateLocale.pagIndexCur }}/{{ stateLocale.pagIndexLast }}
  418. </span>
  419. </div>
  420. <div class="flex items-center bg-zinc-900 py-1 px-2 m-1 font-mono">
  421. <input type="checkbox" id="duration" v-model="state.filterDuration" class="mr-2 size-auto">
  422. <label for="duration" class="text-zinc-300 font-mono">duration</label>
  423. <label for="durationFrom" class="text-zinc-300 mx-2 font-mono">from</label>
  424. <input type="number" step="10" min="0" max="72000" id="durationFrom" v-model.number="state.filterDurationFrom" class="w-24 h-8 bg-gray-700 text-zinc-300 rounded px-3 py-2">
  425. <label for="durationTo" class="text-zinc-300 mx-2 font-mono">to</label>
  426. <input type="number" step="10" min="0" max="72000" id="durationTo" v-model.number="state.filterDurationTo" class="w-24 h-8 bg-gray-700 text-zinc-300 rounded px-3 py-2">
  427. </div>
  428. </template>
  429. </div>
  430. `;
  431.  
  432. constructor(state, stateLocale) {
  433. const root = parseDOM('<div id="tapermonkey-app" style="position: relative; z-index: 999999;"></div>');
  434. document.body.appendChild(root);
  435.  
  436. this.vue = createApp({
  437. setup: () => ({ state, stateLocale }),
  438. template: this.template
  439. }).mount("#tapermonkey-app");
  440.  
  441. GM_addStyle(`#tapermonkey-app *,#tapermonkey-app :before,#tapermonkey-app :after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}#tapermonkey-app :before,#tapermonkey-app :after{--tw-content: ""}#tapermonkey-app html,#tapermonkey-app :host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}#tapermonkey-app body{margin:0;line-height:inherit}#tapermonkey-app hr{height:0;color:inherit;border-top-width:1px}#tapermonkey-app abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}#tapermonkey-app h1,#tapermonkey-app h2,#tapermonkey-app h3,#tapermonkey-app h4,#tapermonkey-app h5,#tapermonkey-app h6{font-size:inherit;font-weight:inherit}#tapermonkey-app a{color:inherit;text-decoration:inherit}#tapermonkey-app b,#tapermonkey-app strong{font-weight:bolder}#tapermonkey-app code,#tapermonkey-app kbd,#tapermonkey-app samp,#tapermonkey-app pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}#tapermonkey-app small{font-size:80%}#tapermonkey-app sub,#tapermonkey-app sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}#tapermonkey-app sub{bottom:-.25em}#tapermonkey-app sup{top:-.5em}#tapermonkey-app table{text-indent:0;border-color:inherit;border-collapse:collapse}#tapermonkey-app button,#tapermonkey-app input,#tapermonkey-app optgroup,#tapermonkey-app select,#tapermonkey-app textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}#tapermonkey-app button,#tapermonkey-app select{text-transform:none}#tapermonkey-app button,#tapermonkey-app input:where([type=button]),#tapermonkey-app input:where([type=reset]),#tapermonkey-app input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}#tapermonkey-app :-moz-focusring{outline:auto}#tapermonkey-app :-moz-ui-invalid{box-shadow:none}#tapermonkey-app progress{vertical-align:baseline}#tapermonkey-app ::-webkit-inner-spin-button,#tapermonkey-app ::-webkit-outer-spin-button{height:auto}#tapermonkey-app [type=search]{-webkit-appearance:textfield;outline-offset:-2px}#tapermonkey-app ::-webkit-search-decoration{-webkit-appearance:none}#tapermonkey-app ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}#tapermonkey-app summary{display:list-item}#tapermonkey-app blockquote,#tapermonkey-app dl,#tapermonkey-app dd,#tapermonkey-app h1,#tapermonkey-app h2,#tapermonkey-app h3,#tapermonkey-app h4,#tapermonkey-app h5,#tapermonkey-app h6,#tapermonkey-app hr,#tapermonkey-app figure,#tapermonkey-app p,#tapermonkey-app pre{margin:0}#tapermonkey-app fieldset{margin:0;padding:0}#tapermonkey-app legend{padding:0}#tapermonkey-app ol,#tapermonkey-app ul,#tapermonkey-app menu{list-style:none;margin:0;padding:0}#tapermonkey-app dialog{padding:0}#tapermonkey-app textarea{resize:vertical}#tapermonkey-app input::-moz-placeholder,#tapermonkey-app textarea::-moz-placeholder{opacity:1;color:#9ca3af}#tapermonkey-app input::placeholder,#tapermonkey-app textarea::placeholder{opacity:1;color:#9ca3af}#tapermonkey-app button,#tapermonkey-app [role=button]{cursor:pointer}#tapermonkey-app :disabled{cursor:default}#tapermonkey-app img,#tapermonkey-app svg,#tapermonkey-app video,#tapermonkey-app canvas,#tapermonkey-app audio,#tapermonkey-app iframe,#tapermonkey-app embed,#tapermonkey-app object{display:block;vertical-align:middle}#tapermonkey-app img,#tapermonkey-app video{max-width:100%;height:auto}#tapermonkey-app [hidden]{display:none}#tapermonkey-app *,#tapermonkey-app :before,#tapermonkey-app :after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }#tapermonkey-app ::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }#tapermonkey-app .fixed{position:fixed}#tapermonkey-app .bottom-0{bottom:0}#tapermonkey-app .right-0{right:0}#tapermonkey-app .m-1{margin:.25rem}#tapermonkey-app .mx-2{margin-left:.5rem;margin-right:.5rem}#tapermonkey-app .ml-auto{margin-left:auto}#tapermonkey-app .mr-2{margin-right:.5rem}#tapermonkey-app .flex{display:flex}#tapermonkey-app .hidden{display:none}#tapermonkey-app .size-auto{width:auto;height:auto}#tapermonkey-app .h-8{height:2rem}#tapermonkey-app .w-24{width:6rem}#tapermonkey-app .w-full{width:100%}#tapermonkey-app .cursor-pointer{cursor:pointer}#tapermonkey-app .resize{resize:both}#tapermonkey-app .flex-wrap{flex-wrap:wrap}#tapermonkey-app .items-center{align-items:center}#tapermonkey-app .rounded{border-radius:.25rem}#tapermonkey-app .rounded-sm{border-radius:.125rem}#tapermonkey-app .border{border-width:1px}#tapermonkey-app .bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity))}#tapermonkey-app .bg-zinc-700{--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity))}#tapermonkey-app .bg-zinc-800{--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity))}#tapermonkey-app .bg-zinc-900{--tw-bg-opacity: 1;background-color:rgb(24 24 27 / var(--tw-bg-opacity))}#tapermonkey-app .px-2{padding-left:.5rem;padding-right:.5rem}#tapermonkey-app .px-3{padding-left:.75rem;padding-right:.75rem}#tapermonkey-app .py-0{padding-top:0;padding-bottom:0}#tapermonkey-app .py-0\.5{padding-top:.125rem;padding-bottom:.125rem}#tapermonkey-app .py-1{padding-top:.25rem;padding-bottom:.25rem}#tapermonkey-app .py-2{padding-top:.5rem;padding-bottom:.5rem}#tapermonkey-app .text-right{text-align:right}#tapermonkey-app .font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}#tapermonkey-app .text-xl{font-size:1.25rem;line-height:1.75rem}#tapermonkey-app .text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}#tapermonkey-app .text-zinc-300{--tw-text-opacity: 1;color:rgb(212 212 216 / var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}`);
  442. }
  443. }
  444.  
  445. //====================================================================================================
  446.  
  447. function createPreviewElement(src, mount) {
  448. const elem = parseDOM(`
  449. <div class="video-js vjs-controls-disabled vjs-touch-enabled vjs-workinghover vjs-v7 vjs-user-active vjs-playing vjs-has-started mp4t_video-dimensions"
  450. id="mp4t_video" tabindex="-1" lang="en" translate="no" role="region" aria-label="Video Player" style="display: none;">
  451. <video id="mp4t_video_html5_api" class="vjs-tech" tabindex="-1" loop="loop" autoplay="autoplay" muted="muted" playsinline="playsinline"></video>
  452. <div class="vjs-poster vjs-hidden" tabindex="-1" aria-disabled="false"></div>
  453. <div class="vjs-text-track-display" translate="yes" aria-live="off" aria-atomic="true">
  454. <div style="position: absolute; inset: 0px; margin: 1.5%;"></div>
  455. </div>
  456. <div class="vjs-loading-spinner" dir="ltr">
  457. <span class="vjs-control-text">Video Player is loading.</span>
  458. </div><button class="vjs-big-play-button" type="button" title="Play Video" aria-disabled="false">
  459. <span class="vjs-icon-placeholder" aria-hidden="true"></span>
  460. <span class="vjs-control-text" aria-live="polite">
  461. </div>`);
  462.  
  463. mount.append(elem);
  464. const video = elem.querySelector('video');
  465. video.src = src;
  466. video.addEventListener('loadeddata', () => {
  467. elem.style.display = 'block';
  468. }, false);
  469.  
  470. return {
  471. elem,
  472. removeElem: () => {
  473. video.removeAttribute('src');
  474. video.load();
  475. elem.remove();
  476. }};
  477. }
  478.  
  479. function animate() {
  480. function handleThumbHover(e) {
  481. if (!(e.target.classList.contains('cover') && e.target.getAttribute('data-preview'))) return;
  482. const videoSrc = e.target.getAttribute('data-preview');
  483. const {elem, removeElem} = createPreviewElement(videoSrc, e.target.parentElement.parentElement);
  484. e.target.parentElement.parentElement.addEventListener('mouseleave', removeElem, { once: true });
  485. }
  486.  
  487. if (RULES.PAGINATION || document.querySelectorAll('.video-list').length > 0) {
  488. (RULES.CONTAINER || document.body).addEventListener('mouseover', handleThumbHover);
  489. }
  490. }
  491.  
  492. //====================================================================================================
  493.  
  494. (function() {
  495. 'use strict';
  496. console.log(LOGO);
  497.  
  498. if (RULES.PAGINATION) {
  499. if (RULES.GET_THUMBS(document.body).length) handleLoadedHTML();
  500. const paginationManager = new PaginationManager();
  501. } else {
  502. for (const list of document.querySelectorAll('.video-list')) {
  503. const temp = parseDOM(list.innerHTML);
  504. list.innerHTML = "";
  505. handleLoadedHTML(temp, list);
  506. }
  507. }
  508.  
  509. animate();
  510. const ui = new VueApp(state, stateLocale);
  511. })();
  512.