AnimePahe Improvements

Improvements and additions for the AnimePahe site

  1. // ==UserScript==
  2. // @name AnimePahe Improvements
  3. // @namespace https://gist.github.com/Ellivers/f7716b6b6895802058c367963f3a2c51
  4. // @match https://animepahe.com/*
  5. // @match https://animepahe.org/*
  6. // @match https://animepahe.ru/*
  7. // @match https://kwik.*/e/*
  8. // @match https://kwik.*/f/*
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @version 4.2.0
  12. // @author Ellivers
  13. // @license MIT
  14. // @description Improvements and additions for the AnimePahe site
  15. // ==/UserScript==
  16.  
  17. /*
  18. How to install:
  19. * Get the Violentmonkey browser extension (Tampermonkey is largely untested, but seems to work as well).
  20. * For the GitHub Gist page, click the "Raw" button on this page.
  21. * For Greasy Fork, click "Install this script".
  22. * I highly suggest using an ad blocker (uBlock Origin is recommended)
  23.  
  24. Feature list:
  25.  
  26. * Automatically redirects to the correct session when a tab with an old session is loaded. No more having to search for the anime and find the episode again!
  27. * Saves your watch progress of each video, so you can resume right where you left off.
  28. * Bookmark anime and view it in a bookmark menu.
  29. * Add ongoing anime to an episode feed to easily check when new episodes are out.
  30. * Quickly visit the download page for a video, instead of having to wait 5 seconds when clicking the download link.
  31. * Find collections of anime series in the search results, with the series listed in release order.
  32. * Jump directly to the next anime's first episode from the previous anime's last episode, and the other way around.
  33. * Keeps track of episodes that have been watched
  34. * Adds an option to hide all episode thumbnails on the site.
  35. * Saved data can be viewed and deleted in the "Manage Data" menu.
  36. * Reworked anime index page. You can now:
  37. * Find anime with your desired genre, theme, type, demographic, status and season.
  38. * Search among these filter results.
  39. * Open a random anime within the specified filters.
  40. * Automatically finds a relevant cover for the top of anime pages.
  41. * Adds points in the video player progress bar for opening, ending, and other highlights (only available for some anime).
  42. * Adds a button to skip openings and endings when they start (only available for some anime).
  43. * Allows you to copy screenshots to the clipboard instead of downloading them.
  44. * Frame-by-frame controls on videos, using ',' and '.'
  45. * Skip 10 seconds on videos at a time, using 'J' and 'L'
  46. * Changes the video 'loop' keybind to Shift + L
  47. * Press Shift + N to go to the next episode, and Shift + P to go to the previous one.
  48. * Speed up or slow down a video by holding Ctrl and:
  49. * Scrolling up/down
  50. * Pressing the up/down keys
  51. * You can also hold shift to make the speed change more gradual.
  52. * Remembers the selected speed for each anime.
  53. * Enables you to see images from the video while hovering over the progress bar.
  54. * Allows you to also use numpad number keys to seek through videos.
  55. * Theatre mode for a better non-fullscreen video experience on larger screens.
  56. * Instantly loads the video instead of having to click a button to load it.
  57. * Adds a more noticeable spinning loading indicator on videos.
  58. * Adds an "Auto-Play Video" option to automatically play the video (on some browsers, you may need to allow auto-playing for this to work).
  59. * Adds an "Auto-Play Next" option to automatically go to the next episode when the current one is finished.
  60. * Focuses on the video player when loading the page, so you don't have to click on it to use keyboard controls.
  61. * Adds an option to automatically choose the highest quality available when loading the video.
  62. * Adds a button (in the settings menu) to reset the video player.
  63. * Shows the dates of when episodes were added.
  64. * And more!
  65. */
  66.  
  67. const baseUrl = window.location.toString();
  68. const initialStorage = getStorage();
  69.  
  70. function getDefaultData() {
  71. return {
  72. version: 2,
  73. linkList:[],
  74. videoTimes:[],
  75. bookmarks:[],
  76. notifications: {
  77. lastUpdated: Date.now(),
  78. anime: [],
  79. episodes: []
  80. },
  81. badCovers: [],
  82. settings: {
  83. autoDelete:true,
  84. hideThumbnails:false,
  85. theatreMode:false,
  86. bestQuality:true,
  87. autoDownload:true,
  88. autoPlayNext:false,
  89. autoPlayVideo:false,
  90. seekThumbnails:true,
  91. seekPoints:true,
  92. skipButton:true,
  93. reduceMotion:false,
  94. copyScreenshots:true
  95. },
  96. videoSpeed: [],
  97. watched: ""
  98. };
  99. }
  100.  
  101. function upgradeData(data, fromver) {
  102. if (fromver === undefined) {
  103. fromver = 0;
  104. }
  105. const defaultVer = getDefaultData().version;
  106. if (fromver >= defaultVer) return;
  107. console.log(`[AnimePahe Improvements] Upgrading data from version ${fromver}`);
  108. /* Changes:
  109. * V1:
  110. * autoPlay -> autoPlayNext
  111. * v2:
  112. * autoDelete -> settings.autoDelete
  113. * hideThumbnails -> settings.hideThumbnails
  114. * theatreMode -> settings.theatreMode
  115. * bestQuality -> settings.bestQuality
  116. * autoDownload -> settings.autoDownload
  117. * autoPlayNext -> settings.autoPlayNext
  118. * autoPlayVideo -> settings.autoPlayVideo
  119. * +videoSpeed
  120. */
  121. const upgradeFunctions = [
  122. () => { // for V0
  123. data.autoPlayNext = data.autoPlay;
  124. delete data.autoPlay;
  125. },
  126. () => { // for V1
  127. const settings = {};
  128. settings.autoDelete = data.autoDelete;
  129. settings.hideThumbnails = data.hideThumbnails;
  130. settings.theatreMode = data.theatreMode;
  131. settings.bestQuality = data.bestQuality;
  132. settings.autoDownload = data.autoDownload;
  133. settings.autoPlayNext = data.autoPlayNext;
  134. settings.autoPlayVideo = data.autoPlayVideo;
  135. data.settings = settings;
  136. delete data.autoDelete;
  137. delete data.hideThumbnails;
  138. delete data.theatreMode;
  139. delete data.bestQuality;
  140. delete data.autoDownload;
  141. delete data.autoPlayNext;
  142. delete data.autoPlayVideo;
  143. }
  144. ]
  145.  
  146. for (let i = fromver; i < defaultVer; i++) {
  147. const fn = upgradeFunctions[i];
  148. if (fn !== undefined) fn();
  149. }
  150.  
  151. data.version = defaultVer;
  152. }
  153.  
  154. function getStorage() {
  155. const defa = getDefaultData();
  156. const res = GM_getValue('anime-link-tracker', defa);
  157.  
  158. const oldVersion = res.version;
  159.  
  160. for (const key of Object.keys(defa)) {
  161. if (res[key] !== undefined) continue;
  162. res[key] = defa[key];
  163. }
  164.  
  165. for (const key of Object.keys(defa.settings)) {
  166. if (res.settings[key] !== undefined) continue;
  167. res.settings[key] = defa.settings[key];
  168. }
  169.  
  170. if (oldVersion !== defa.version) {
  171. upgradeData(res, oldVersion);
  172. saveData(res);
  173. }
  174.  
  175. return res;
  176. }
  177.  
  178. function saveData(data) {
  179. GM_setValue('anime-link-tracker', data);
  180. }
  181.  
  182. function secondsToHMS(secs) {
  183. const mins = Math.floor(secs/60);
  184. const hrs = Math.floor(mins/60);
  185. const newSecs = Math.floor(secs % 60);
  186. return `${hrs > 0 ? hrs + ':' : ''}${mins % 60}:${newSecs.toString().length > 1 ? '' : '0'}${newSecs % 60}`;
  187. }
  188.  
  189. /* Watched episode format:
  190. * anime_id:episode_num1-episode_num2,episode_num3;<other entries>
  191. */
  192.  
  193. function decodeWatched(watched) {
  194. const decoded = [];
  195. if (watched.split === undefined) {
  196. console.error('[AnimePahe Improvements] Attempted to decode watched episode list with incorrect type.');
  197. return [];
  198. }
  199. for (const anime of watched.split(';')) {
  200. const parts = anime.split(':');
  201. if (parts.length <= 1) continue;
  202. const animeId = parseInt(parts[0], 36);
  203. if (animeId === NaN) continue;
  204. const episodes = [];
  205. for (const ep of parts[1].split(',')) {
  206. if (ep.includes('-')) {
  207. const epParts = ep.split('-').map(e => parseInt(e, 36));
  208. for (let i = epParts[0]; i <= epParts[1]; i++) {
  209. episodes.push(i);
  210. }
  211. }
  212. else {
  213. const episode = parseInt(ep, 36);
  214. if (episode !== NaN) episodes.push(episode);
  215. }
  216. }
  217.  
  218. decoded.push({
  219. animeId: animeId,
  220. episodes: episodes
  221. });
  222. }
  223. return decoded;
  224. }
  225.  
  226. function encodeWatched(watched) {
  227. return watched.map(a => {
  228. return a.animeId.toString(36) + ':' + (() => {
  229. const episodeRanges = [];
  230.  
  231. const sorted = a.episodes.sort((a,b) => a > b ? 1 : -1);
  232. for (const episode of sorted) {
  233. const lastRange = episodeRanges[episodeRanges.length - 1];
  234.  
  235. if (lastRange && episode - 1 === lastRange[1]) {
  236. lastRange[1] = episode;
  237. }
  238. else {
  239. episodeRanges.push([episode, episode]);
  240. }
  241. }
  242. return episodeRanges.map(e => {
  243. if (e[0] === e[1]) return e[0].toString(36);
  244. else return e[0].toString(36) + '-' + e[1].toString(36);
  245. }).join(',');
  246. })();
  247. }).join(';');
  248. }
  249.  
  250. function isWatched(animeId, episode, watched = decodeWatched(getStorage().watched)) {
  251. const found = watched.find(a => a.animeId === animeId);
  252. if (found === undefined) return false;
  253. return found.episodes.includes(episode);
  254. }
  255.  
  256. function addWatched(animeId, episode, storage = getStorage()) {
  257. const watched = decodeWatched(storage.watched);
  258. const found = watched.find(a => a.animeId === animeId);
  259.  
  260. if (found === undefined) {
  261. watched.push({
  262. animeId: animeId,
  263. episodes: [
  264. episode
  265. ]
  266. });
  267. }
  268. else {
  269. if (found.episodes.find(e => e === episode) !== undefined) return;
  270. found.episodes.push(episode);
  271. }
  272.  
  273. storage.watched = encodeWatched(watched);
  274. saveData(storage);
  275. }
  276.  
  277. function removeWatched(animeId, episode, storage = getStorage()) {
  278. const watched = decodeWatched(storage.watched);
  279. const found = watched.find(a => a.animeId === animeId);
  280. if (found === undefined) return;
  281. found.episodes = found.episodes.filter(e => e !== episode);
  282.  
  283. if (found.episodes.length === 0) {
  284. const index = watched.indexOf(found);
  285. watched.splice(index, 1);
  286. }
  287.  
  288. storage.watched = encodeWatched(watched);
  289. saveData(storage);
  290. }
  291.  
  292. function removeWatchedAnime(animeId, storage = getStorage()) {
  293. const watched = decodeWatched(storage.watched).filter(a => a.animeId !== animeId);
  294.  
  295. storage.watched = encodeWatched(watched);
  296. saveData(storage);
  297. }
  298.  
  299. function getStoredTime(name, ep, storage, id = undefined) {
  300. if (id !== undefined) {
  301. return storage.videoTimes.find(a => a.episodeNum === ep && a.animeId === id);
  302. }
  303. else return storage.videoTimes.find(a => a.animeName === name && a.episodeNum === ep);
  304. }
  305.  
  306. function applyCssSheet(cssString) {
  307. $("head").append('<style id="anitracker-style" type="text/css"></style>');
  308. const sheet = $("#anitracker-style")[0].sheet;
  309.  
  310. const rules = cssString.split(/^\}/mg).map(a => a.replace(/\n/gm,'') + '}');
  311.  
  312. for (let i = 0; i < rules.length - 1; i++) {
  313. sheet.insertRule(rules[i], i);
  314. }
  315. }
  316.  
  317. const kwikDLPageRegex = /^https:\/\/kwik\.\w+\/f\//;
  318.  
  319. // Video player improvements
  320. if (/^https:\/\/kwik\.\w+/.test(baseUrl)) {
  321. if (typeof $ !== "undefined" && $() !== null) anitrackerKwikLoad(window.location.origin + window.location.pathname);
  322. else {
  323. const scriptElem = document.querySelector('head > link:nth-child(12)');
  324. if (scriptElem == null) {
  325. const h1 = document.querySelector('h1');
  326. // Some bug that the kwik DL page had before
  327. // (You're not actually blocked when this happens)
  328. if (!kwikDLPageRegex.test(baseUrl) && h1.textContent == "Sorry, you have been blocked") {
  329. h1.textContent = "Oops, page failed to load.";
  330. document.querySelector('h2').textContent = "This doesn't mean you're blocked. Try playing from another page instead.";
  331. }
  332. return;
  333. }
  334. scriptElem.onload(() => {anitrackerKwikLoad(window.location.origin + window.location.pathname)});
  335. }
  336.  
  337. function anitrackerKwikLoad(url) {
  338. if (kwikDLPageRegex.test(url)) {
  339. if (initialStorage.settings.autoDownload === false) return;
  340. $(`
  341. <div style="width:100%;height:100%;background-color:rgba(0, 0, 0, 0.9);position:fixed;z-index:999;display:flex;justify-content:center;align-items:center;" id="anitrackerKwikDL">
  342. <span style="color:white;font-size:3.5em;font-weight:bold;">[AnimePahe Improvements] Downloading...</span>
  343. </div>`).prependTo(document.body);
  344.  
  345. if ($('form').length > 0) {
  346. $('form').submit();
  347. setTimeout(() => {$('#anitrackerKwikDL').remove()}, 1500);
  348. }
  349. else new MutationObserver(function(mutationList, observer) {
  350. if ($('form').length > 0) {
  351. observer.disconnect();
  352. $('form').submit();
  353. setTimeout(() => {$('#anitrackerKwikDL').remove()}, 1500);
  354. }
  355. }).observe(document.body, { childList: true, subtree: true });
  356.  
  357. return;
  358. }
  359.  
  360. // Needs to have this indentation
  361. const _css = `
  362. .anitracker-loading {
  363. background: none!important;
  364. border: 12px solid rgba(130,130,130,0.7);
  365. border-top-color: #00d1b2;
  366. border-radius: 50%;
  367. animation: spin 1.2s linear infinite;
  368. translate: -50% -50%;
  369. width: 80px;
  370. height: 80px;
  371. }
  372. .anitracker-message {
  373. width:50%;
  374. height:10%;
  375. position:absolute;
  376. background-color:rgba(0,0,0,0.5);
  377. justify-content:center;
  378. align-items:center;
  379. margin-top:1.5%;
  380. border-radius:20px;
  381. }
  382. .anitracker-message>span {
  383. color: white;
  384. font-size: 2.5em;
  385. }
  386. .anitracker-progress-tooltip {
  387. width: 219px;
  388. padding: 5px;
  389. opacity:0;
  390. position: absolute;
  391. left:0%;
  392. bottom: 100%;
  393. background-color: rgba(255,255,255,0.88);
  394. border-radius: 8px;
  395. transition: translate .2s ease .1s,scale .2s ease .1s,opacity .1s ease .05s;
  396. transform: translate(-50%,0);
  397. user-select: none;
  398. pointer-events: none;
  399. z-index: 2;
  400. }
  401. .anitracker-progress-image {
  402. height: 100%;
  403. width: 100%;
  404. background-color: gray;
  405. display:flex;
  406. flex-direction: column;
  407. align-items: center;
  408. overflow: hidden;
  409. border-radius: 5px;
  410. }
  411. .anitracker-progress-image>img {
  412. width: 100%;
  413. }
  414. .anitracker-progress-image>span {
  415. font-size: .9em;
  416. bottom: 5px;
  417. position: fixed;
  418. background-color: rgba(0,0,0,0.7);
  419. border-radius: 3px;
  420. padding: 0 4px 0 4px;
  421. }
  422. .anitracker-skip-button {
  423. position: absolute;
  424. left: 5%;
  425. bottom: 10%;
  426. color: white;
  427. background-color: rgba(100,100,100,0.6);
  428. z-index: 1;
  429. border: 3px solid white;
  430. border-radius: 8px;
  431. padding: 10px 24px;
  432. transition: .3s;
  433. }
  434. .anitracker-skip-button:hover, .anitracker-skip-button:focus-visible {
  435. background-color: rgba(0,0,0,0.75);
  436. }
  437. .anitracker-skip-button:focus-visible {
  438. outline: 3px dotted #00b3ff;
  439. }
  440. .anitracker-seek-points {
  441. width: 100%;
  442. bottom: 0;
  443. height: 100%;
  444. position: absolute;
  445. display: flex;
  446. align-items: center;
  447. }
  448. .anitracker-seek-points>i {
  449. position: absolute;
  450. width: 5px;
  451. height: 5px;
  452. border-radius: 2px;
  453. background-color: #1a9166;
  454. pointer-events: none;
  455. z-index: 2;
  456. translate: -50% 0;
  457. }
  458. .plyr--hide-controls>.anitracker-hide-control {
  459. opacity: 0!important;
  460. pointer-events: none!important;
  461. }
  462. @keyframes spin {
  463. 0% { transform: rotate(0deg); }
  464. 100% { transform: rotate(360deg); }
  465. }`;
  466.  
  467. applyCssSheet(_css);
  468.  
  469. if ($('.anitracker-message').length > 0) {
  470. console.log("[AnimePahe Improvements (Player)] Script was reloaded.");
  471. return;
  472. }
  473.  
  474. $('button.plyr__controls__item:nth-child(1)').hide();
  475. $('.plyr__progress__container').hide();
  476. $('.plyr__control--overlaid').hide();
  477.  
  478. $(`
  479. <div class="anitracker-loading plyr__control--overlaid">
  480. <span class="plyr__sr-only">Loading...</span>
  481. </div>`).appendTo('.plyr--video');
  482.  
  483. const player = $('#kwikPlayer')[0];
  484.  
  485. function getVideoInfo() {
  486. const fileName = document.getElementsByClassName('ss-label')[0].textContent;
  487. const nameParts = fileName.split('_');
  488. let name = '';
  489. for (let i = 0; i < nameParts.length; i++) {
  490. const part = nameParts[i];
  491. if (part.trim() === 'AnimePahe') {
  492. i ++;
  493. continue;
  494. }
  495. if (part === 'Dub' && i >= 1 && [2,3,4,5].includes(nameParts[i-1].length)) break;
  496. if (/\d{2}/.test(part) && i >= 1 && nameParts[i-1] === '-') break;
  497.  
  498. name += nameParts[i-1] + ' ';
  499. }
  500. return {
  501. animeName: name.slice(0, -1),
  502. episodeNum: +/^AnimePahe_.+_-_([\d\.]{2,})/.exec(fileName)[1]
  503. };
  504. }
  505.  
  506. $(`<div class="anitracker-seek-points"></div>`).appendTo('.plyr__progress');
  507.  
  508. function setSeekPoints(seekPoints) {
  509. $('.anitracker-seek-points>i').remove();
  510. for (const p of seekPoints) {
  511. $(`<i style="left: ${p}%"></i>`).appendTo('.anitracker-seek-points');
  512. }
  513. }
  514.  
  515. var timestamps = [];
  516.  
  517. async function getAnidbIdFromTitle(title) {
  518. return new Promise((resolve) => {
  519. const req = new XMLHttpRequest();
  520. req.open('GET', 'https://raw.githubusercontent.com/c032/anidb-animetitles-archive/refs/heads/main/data/animetitles.json', true);
  521. req.onload = () => {
  522. if (req.status !== 200) {
  523. resolve(false);
  524. return
  525. };
  526. const data = req.response.split('\n');
  527.  
  528. let anidbId = undefined;
  529. for (const anime of data) {
  530. const obj = JSON.parse(anime);
  531. if (obj.titles.find(a => a.title === title) === undefined) continue;
  532. anidbId = obj.id;
  533. break;
  534. }
  535.  
  536. resolve(anidbId);
  537. };
  538. req.send();
  539. });
  540. }
  541.  
  542. async function getTimestamps(anidbId, episode) {
  543. return new Promise((resolve) => {
  544. const req = new XMLHttpRequest();
  545. req.open('GET', 'https://raw.githubusercontent.com/Ellivers/open-anime-timestamps/refs/heads/master/timestamps.json', true); // Timestamp data
  546. req.onload = () => {
  547. if (req.status !== 200) {
  548. resolve(false);
  549. return
  550. };
  551. const data = JSON.parse(req.response)[anidbId];
  552. if (data === undefined) {
  553. resolve(false);
  554. return;
  555. }
  556. const episodeData = data.find(e => e.episode_number === episode);
  557. if (episodeData !== undefined) {
  558. console.log('[AnimePahe Improvements] Found timestamp data for episode.');
  559. }
  560. else {
  561. resolve(false);
  562. return;
  563. }
  564.  
  565. const duration = player.duration;
  566. let timestampData = [
  567. {
  568. type: "recap",
  569. start: episodeData.recap.start,
  570. end: episodeData.recap.end
  571. },
  572. {
  573. type: "opening",
  574. start: episodeData.opening.start,
  575. end: episodeData.opening.end
  576. },
  577. {
  578. type: "ending",
  579. start: episodeData.ending.start,
  580. end: episodeData.ending.end
  581. },
  582. {
  583. type: "preview",
  584. start: episodeData.preview_start,
  585. end: duration
  586. }
  587. ];
  588.  
  589. const seekPoints = [];
  590.  
  591. for (const t of timestampData) {
  592. if (t.start === -1) continue;
  593. const percentage = (t.start / duration) * 100;
  594. seekPoints.push(percentage > 100 ? 100 : percentage);
  595. }
  596.  
  597. // Filter off unusable timestamps
  598. timestampData = timestampData.filter(t => t.start !== -1 && (t.end !== -1 || t.type === 'preview'));
  599.  
  600. resolve({
  601. seekPoints: seekPoints,
  602. timestamps: timestampData
  603. });
  604. }
  605. req.send();
  606. });
  607. }
  608.  
  609. function updateStoredPlaybackSpeed(speed) {
  610. const storage = getStorage();
  611. const vidInfo = getVideoInfo();
  612. const storedVideoTime = getStoredTime(vidInfo.animeName, vidInfo.episodeNum, storage);
  613. if (storedVideoTime === undefined) return;
  614. const id = storedVideoTime.animeId;
  615. const name = vidInfo.animeName;
  616.  
  617. const storedPlaybackSpeed = (() => {
  618. if (id !== undefined) return storage.videoSpeed.find(a => a.animeId === id);
  619. else return storage.videoSpeed.find(a => a.animeName === name);
  620. })();
  621.  
  622. if (speed === 1) {
  623. if (storedPlaybackSpeed === undefined) return;
  624. if (id !== undefined) storage.videoSpeed = storage.videoSpeed.filter(a => a.animeId !== id);
  625. else storage.videoSpeed = storage.videoSpeed.filter(a => a.animeName !== name);
  626. saveData(storage);
  627. return;
  628. }
  629.  
  630. if (storedPlaybackSpeed === undefined) {
  631. storage.videoSpeed.push({
  632. animeId: id,
  633. animeName: name,
  634. speed: speed
  635. });
  636. if (storage.videoSpeed.length > 256) storage.videoSpeed.splice(0,1);
  637. }
  638. else storedPlaybackSpeed.speed = speed;
  639. saveData(storage);
  640. }
  641.  
  642. let reachedWatchedStatus = false;
  643.  
  644. function updateStoredTime() {
  645. const currentTime = player.currentTime;
  646. const storage = getStorage();
  647.  
  648. if (waitingState.idRequest === 1) return;
  649. const vidInfo = getVideoInfo();
  650. const storedVideoTime = getStoredTime(vidInfo.animeName, vidInfo.episodeNum, storage);
  651.  
  652. if (storedVideoTime === undefined) {
  653. if (![-1,1].includes(waitingState.idRequest)) { // If the ID request hasn't failed and isn't ongoing
  654. sendIdRequest();
  655. return;
  656. }
  657. const vidInfo = getVideoInfo();
  658. storage.videoTimes.push({
  659. videoUrls: [url],
  660. time: player.currentTime,
  661. animeName: vidInfo.animeName,
  662. episodeNum: vidInfo.episodeNum
  663. });
  664. if (storage.videoTimes.length > 1000) {
  665. storage.videoTimes.splice(0,1);
  666. }
  667. saveData(storage);
  668. return;
  669. }
  670.  
  671. if ((currentTime / player.duration) > 0.9) {
  672. // Mark as watched
  673. if (!reachedWatchedStatus && storedVideoTime.animeId !== undefined) {
  674. reachedWatchedStatus = true;
  675. addWatched(storedVideoTime.animeId, vidInfo.episodeNum, storage);
  676. }
  677. // Delete the storage entry
  678. if (player.duration - currentTime <= 20) {
  679. const videoInfo = getVideoInfo();
  680. storage.videoTimes = storage.videoTimes.filter(a => !(a.animeName === videoInfo.animeName && a.episodeNum === videoInfo.episodeNum));
  681. saveData(storage);
  682. return;
  683. }
  684. }
  685.  
  686. storedVideoTime.time = player.currentTime;
  687. saveData(storage);
  688. }
  689.  
  690. if (initialStorage.videoTimes === undefined) {
  691. const storage = getStorage();
  692. storage.videoTimes = [];
  693. saveData(storage);
  694. }
  695.  
  696. // For message requests from the main page
  697. // -1: failed
  698. // 0: hasn't started
  699. // 1: waiting
  700. // 2: succeeded
  701. const waitingState = {
  702. idRequest: 0,
  703. videoUrlRequest: 0,
  704. anidbIdRequest: 0
  705. };
  706. // Messages received from main page
  707. window.onmessage = function(e) {
  708. const storage = getStorage();
  709. const vidInfo = getVideoInfo();
  710.  
  711. const data = e.data;
  712. const action = data.action;
  713. if (action === 'id_response' && waitingState.idRequest === 1) {
  714. const found = storage.videoTimes.find(a => a.animeName === vidInfo.animeName && a.episodeNum === vidInfo.episodeNum);
  715.  
  716. if (found === undefined) {
  717. storage.videoTimes.push({
  718. videoUrls: [url],
  719. time: player.currentTime,
  720. animeName: vidInfo.animeName,
  721. episodeNum: vidInfo.episodeNum,
  722. animeId: data.id
  723. });
  724. if (storage.videoTimes.length > 1000) {
  725. storage.videoTimes.splice(0,1);
  726. }
  727. }
  728. else {
  729. found.animeId = data.id // If the entry already exists, just add the ID
  730. }
  731.  
  732. saveData(storage);
  733. waitingState.idRequest = 2;
  734.  
  735. const storedPlaybackSpeed = storage.videoSpeed.find(a => a.animeId === data.id);
  736. if (storedPlaybackSpeed !== undefined) {
  737. setSpeed(storedPlaybackSpeed.speed);
  738. }
  739.  
  740. waitingState.anidbIdRequest = 1;
  741. sendMessage({action:"anidb_id_request",id:data.id});
  742.  
  743. return;
  744. }
  745. else if (action === 'anidb_id_response' && waitingState.anidbIdRequest === 1) {
  746. waitingState.anidbIdRequest = 2;
  747. let anidbId = data.id;
  748. if (anidbId === undefined) {
  749. const episode = storage.linkList.find(e => e.type === 'episode' && e.animeId === data.originalId);
  750. if (episode === undefined) return;
  751. getAnidbIdFromTitle(episode.animeName).then(response => {
  752. anidbId = response;
  753. });
  754. }
  755. if (anidbId === undefined) return;
  756. getTimestamps(anidbId, vidInfo.episodeNum).then(response => {
  757. const storage = getStorage();
  758. const storedVideoTime = getStoredTime(vidInfo.animeName, vidInfo.episodeNum, storage);
  759.  
  760. if (response === false) {
  761. storedVideoTime.hasTimestamps = false;
  762. saveData(storage);
  763. return;
  764. }
  765.  
  766. if (storage.settings.seekPoints) setSeekPoints(response.seekPoints);
  767. if (storage.settings.skipButton) timestamps = response.timestamps;
  768.  
  769. storedVideoTime.hasTimestamps = true;
  770. storedVideoTime.timestampData = response;
  771. saveData(storage);
  772. });
  773. }
  774. else if (action === 'video_url_response' && waitingState.videoUrlRequest === 1) {
  775. waitingState.videoUrlRequest = 2;
  776. const request = new XMLHttpRequest();
  777. request.open('GET', data.url, true);
  778. request.onload = () => {
  779. if (request.status !== 200) {
  780. console.error('[AnimePahe Improvements] Could not get kwik page for video source');
  781. return;
  782. }
  783.  
  784. const pageElements = Array.from($(request.response)); // Elements that are not buried cannot be found with jQuery.find()
  785. const hostInfo = (() => {
  786. for (const link of pageElements.filter(a => a.tagName === 'LINK')) {
  787. const href = $(link).attr('href');
  788. if (!href.includes('vault')) continue;
  789. const result = /vault-(\d+)\.(\w+\.\w+)$/.exec(href);
  790. return {
  791. vaultId: result[1],
  792. hostName: result[2]
  793. }
  794. break;
  795. }
  796. })();
  797.  
  798. const searchInfo = (() => {
  799. for (const script of pageElements.filter(a => a.tagName === 'SCRIPT')) {
  800. if ($(script).attr('url') !== undefined || !$(script).text().startsWith('eval')) continue;
  801. const result = /(\w{64})\|((?:\w+\|){4,5})https/.exec($(script).text());
  802. let extraNumber = undefined;
  803. result[2].split('|').forEach(a => {if (/\d{2}/.test(a)) extraNumber = a;}); // Some number that's needed for the url (doesn't always exist here)
  804. if (extraNumber === undefined) {
  805. const result2 = /q=\\'\w+:\/{2}\w+\-\w+\.\w+\.\w+\/((?:\w+\/)+)/.exec($(script).text());
  806. result2[1].split('/').forEach(a => {if (/\d{2}/.test(a) && a !== hostInfo.vaultId) extraNumber = a;});
  807. }
  808. if (extraNumber === undefined) {
  809. const result2 = /source\|(\d{2})\|ended/.exec($(script).text());
  810. if (result2 !== null) extraNumber = result2[1];
  811. }
  812. return {
  813. part1: extraNumber,
  814. part2: result[1]
  815. };
  816. break;
  817. }
  818. })();
  819.  
  820. if (searchInfo.part1 === undefined) {
  821. console.error('[AnimePahe Improvements] Could not find "extraNumber" from ' + data.url);
  822. return;
  823. }
  824.  
  825. setupSeekThumbnails(`https://vault-${hostInfo.vaultId}.${hostInfo.hostName}/stream/${hostInfo.vaultId}/${searchInfo.part1}/${searchInfo.part2}/uwu.m3u8`);
  826. };
  827. request.send();
  828. }
  829. else if (action === 'change_time') {
  830. if (data.time !== undefined) player.currentTime = data.time;
  831. }
  832. else if (action === 'key') {
  833. if ([' ','k'].includes(data.key)) {
  834. if (player.paused) player.play();
  835. else player.pause();
  836. }
  837. else if (data.key === 'ArrowLeft') {
  838. player.currentTime = Math.max(0, player.currentTime - 5);
  839. return;
  840. }
  841. else if (data.key === 'ArrowRight') {
  842. player.currentTime = Math.min(player.duration, player.currentTime + 5);
  843. return;
  844. }
  845. else if (/^\d$/.test(data.key)) {
  846. player.currentTime = (player.duration/10)*(+data.key);
  847. return;
  848. }
  849. else if (data.key === 'm') player.muted = !player.muted;
  850. else $(player).trigger('keydown', {
  851. key: data.key
  852. });
  853. }
  854. else if (action === 'setting_changed') {
  855. const storedVideoTime = getStoredTime(vidInfo.animeName, vidInfo.episodeNum, storage);
  856. if (data.type === 'seek_points' && storedVideoTime.hasTimestamps === true) {
  857. if (data.value === true && $('.anitracker-seek-points>i').length === 0) setSeekPoints(storedVideoTime.timestampData.seekPoints);
  858. else if (data.value === false) $('.anitracker-seek-points>i').remove();
  859. }
  860. else if (data.type === 'skip_button' && storedVideoTime.hasTimestamps === true) {
  861. if (data.value === true) {
  862. timestamps = storedVideoTime.timestampData.timestamps;
  863. checkActiveTimestamps();
  864. }
  865. else {
  866. setSkipBtnVisibility(false);
  867. timestamps = [];
  868. }
  869. }
  870. else if (data.type === 'screenshot_mode') $('button[data-plyr="capture"]').data('mode', data.value);
  871. }
  872. };
  873.  
  874. $('.plyr--full-ui').attr('tabindex','1');
  875. let skipBtnVisible = false;
  876.  
  877. function setSkipBtnVisibility(on) {
  878. const elem = $('.anitracker-skip-button');
  879. if (on && !skipBtnVisible) {
  880. elem.css('opacity','1').css('pointer-events','').css('translate','');
  881. elem.attr('tabindex','2');
  882. skipBtnVisible = true;
  883. }
  884. else if (!on && skipBtnVisible) {
  885. elem.css('opacity','0').css('pointer-events','none').css('translate','-50%');
  886. elem.removeClass('anitracker-hide-control');
  887. elem.attr('tabindex','-1');
  888. elem.off('click');
  889. skipBtnVisible = false;
  890. }
  891. }
  892.  
  893. const skipTexts = {
  894. 'recap': 'Skip Recap',
  895. 'opening': 'Skip Opening',
  896. 'ending': 'Skip Ending',
  897. 'preview': 'Skip to End'
  898. }
  899.  
  900. function checkActiveTimestamps(time = player.currentTime) {
  901. if (timestamps.length === 0) return;
  902. let activeTimestamp;
  903. for (const t of timestamps) {
  904. if (time > t.start && time < (t.end - 2)) {
  905. activeTimestamp = t;
  906. break;
  907. }
  908. }
  909. if (activeTimestamp === undefined) {
  910. setSkipBtnVisibility(false);
  911. return;
  912. }
  913. const elem = $('.anitracker-skip-button');
  914.  
  915. const text = skipTexts[activeTimestamp.type] || 'Skip Section';
  916. if (text === elem.text() && skipBtnVisible) {
  917. if (time - activeTimestamp.start > 4) {
  918. elem.addClass('anitracker-hide-control');
  919. }
  920. return;
  921. }
  922.  
  923. elem.text(text);
  924. setSkipBtnVisibility(true);
  925. elem.off('click');
  926. elem.on('click', () => {
  927. player.focus();
  928. player.currentTime = activeTimestamp.end - 2;
  929. setSkipBtnVisibility(false);
  930. });
  931. }
  932.  
  933. function sendIdRequest() {
  934. if ([-1,1].includes(waitingState.idRequest)) return; // Return if the ID request either has failed or is ongoing
  935. waitingState.idRequest = 1;
  936. sendMessage({action: "id_request"});
  937. setTimeout(() => {
  938. if (waitingState.idRequest === 1) {
  939. waitingState.idRequest = -1; // Failed to get the anime ID from the main page within 2 seconds
  940. updateStoredTime();
  941. }
  942. }, 2000);
  943. }
  944.  
  945. player.addEventListener('loadeddata', function loadVideoData() {
  946. const storage = getStorage();
  947. const vidInfo = getVideoInfo();
  948. const storedVideoTime = getStoredTime(vidInfo.animeName, vidInfo.episodeNum, storage);
  949.  
  950. if (storedVideoTime !== undefined) {
  951. player.currentTime = Math.max(0, Math.min(storedVideoTime.time, player.duration));
  952. if (storedVideoTime.hasTimestamps) {
  953. if (storage.settings.skipButton) timestamps = storedVideoTime.timestampData.timestamps;
  954. if (storage.settings.seekPoints) setSeekPoints(storedVideoTime.timestampData.seekPoints);
  955. }
  956. if (!storedVideoTime.videoUrls.includes(url)) {
  957. storedVideoTime.videoUrls.push(url);
  958. saveData(storage);
  959. }
  960. if (storedVideoTime.animeId === undefined) sendIdRequest();
  961. const storedPlaybackSpeed = storage.videoSpeed.find(a => a.animeId === storedVideoTime.animeId);
  962. if (storedPlaybackSpeed !== undefined) {
  963. setSpeed(storedPlaybackSpeed.speed);
  964. }
  965. else player.playbackRate = 1;
  966. }
  967. else {
  968. player.playbackRate = 1;
  969. sendIdRequest();
  970. finishedLoading();
  971. }
  972.  
  973. const timeArg = Array.from(new URLSearchParams(window.location.search)).find(a => a[0] === 'time');
  974. if (timeArg !== undefined) {
  975. const newTime = +timeArg[1];
  976. if (storedVideoTime === undefined || (storedVideoTime !== undefined && Math.floor(storedVideoTime.time) === Math.floor(newTime)) || (storedVideoTime !== undefined &&
  977. confirm(`[AnimePahe Improvements]\n\nYou already have saved progress on this video (${secondsToHMS(storedVideoTime.time)}). Do you want to overwrite it and go to ${secondsToHMS(newTime)}?`))) {
  978. player.currentTime = Math.max(0, Math.min(newTime, player.duration));
  979. }
  980. window.history.replaceState({}, document.title, url);
  981. }
  982.  
  983. player.removeEventListener('loadeddata', loadVideoData);
  984.  
  985. // Set up events
  986. $('button[data-plyr="capture"]').replaceWith($('button[data-plyr="capture"]').clone()); // Just to remove existing event listeners
  987. $('button[data-plyr="capture"]')
  988. .data('mode', ['download', 'copy'][+initialStorage.settings.copyScreenshots])
  989. .on('click', (e) => {
  990. const canvas = document.createElement('canvas');
  991. canvas.height = player.videoHeight;
  992. canvas.width = player.videoWidth;
  993. const ctx = canvas.getContext('2d');
  994. ctx.drawImage(player, 0, 0, canvas.width, canvas.height);
  995. const mode = $(e.currentTarget).data('mode');
  996. if (mode === 'copy') {
  997. canvas.toBlob((blob) => {
  998. try {
  999. navigator.clipboard.write([
  1000. new ClipboardItem({[blob.type]: blob})
  1001. ]);
  1002. }
  1003. catch (e) {
  1004. console.error(e);
  1005. showMessage("Couldn't copy!");
  1006. alert("[AnimePahe Improvements]\n\nCouldn't copy screenshot. Try disabling the Copy Screenshots option.");
  1007. return;
  1008. }
  1009.  
  1010. showMessage('Copied image');
  1011. });
  1012. }
  1013. else if (mode === 'download') {
  1014. const element = document.createElement('a');
  1015. element.setAttribute('href', canvas.toDataURL('image/png'));
  1016. element.setAttribute('download', $('.ss-label').text());
  1017. element.click();
  1018. }
  1019. });
  1020.  
  1021. let lastTimeUpdate = 0;
  1022. player.addEventListener('timeupdate', function() {
  1023. const currentTime = player.currentTime;
  1024. checkActiveTimestamps(currentTime);
  1025. if (Math.trunc(currentTime) % 10 === 0 && player.currentTime - lastTimeUpdate > 9) {
  1026. updateStoredTime();
  1027. lastTimeUpdate = player.currentTime;
  1028. }
  1029. });
  1030.  
  1031. player.addEventListener('pause', () => {
  1032. updateStoredTime();
  1033. });
  1034.  
  1035. player.addEventListener('seeked', () => {
  1036. updateStoredTime();
  1037. checkActiveTimestamps();
  1038. finishedLoading();
  1039. });
  1040.  
  1041. player.addEventListener('ratechange', () => {
  1042. if (player.readyState > 2) updateStoredPlaybackSpeed(player.playbackRate);
  1043. });
  1044. });
  1045.  
  1046. function getFrame(video, time, dimensions) {
  1047. return new Promise((resolve) => {
  1048. video.onseeked = () => {
  1049. const canvas = document.createElement('canvas');
  1050. canvas.height = dimensions.y;
  1051. canvas.width = dimensions.x;
  1052. const ctx = canvas.getContext('2d');
  1053. ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  1054. resolve(canvas.toDataURL('image/png'));
  1055. };
  1056. try {
  1057. video.currentTime = time;
  1058. }
  1059. catch (e) {
  1060. console.error(time, e);
  1061. }
  1062. });
  1063. }
  1064.  
  1065. const settingsContainerId = (() => {
  1066. for (const elem of $('.plyr__menu__container')) {
  1067. const regex = /plyr\-settings\-(\d+)/.exec(elem.id);
  1068. if (regex === null) continue;
  1069. return regex[1];
  1070. }
  1071. return undefined;
  1072. })();
  1073.  
  1074. function getThumbnailTime(time, timeBetweenThumbnails, fullDuration) {
  1075. // Make thumbnails in the middle of the "time between thumbnails" slots by adding half the interval time to them
  1076. const thumbnailTime = Math.trunc(time/timeBetweenThumbnails)*timeBetweenThumbnails + Math.trunc(timeBetweenThumbnails / 2);
  1077. return Math.min(thumbnailTime, fullDuration);
  1078. }
  1079.  
  1080. function setupSeekThumbnails(videoSource) {
  1081. const resolution = 167;
  1082.  
  1083. const bgVid = document.createElement('video');
  1084. bgVid.height = resolution;
  1085. bgVid.onloadeddata = () => {
  1086. const fullDuration = bgVid.duration;
  1087. const timeBetweenThumbnails = fullDuration/(24*6); // Just something arbitrary that seems good
  1088. const thumbnails = [];
  1089. const aspectRatio = bgVid.videoWidth / bgVid.videoHeight;
  1090.  
  1091. const aspectRatioCss = `${bgVid.videoWidth} / ${bgVid.videoHeight}`;
  1092.  
  1093. $('.plyr__progress .plyr__tooltip').remove();
  1094. $(`
  1095. <div class="anitracker-progress-tooltip" style="aspect-ratio: ${aspectRatioCss};">
  1096. <div class="anitracker-progress-image">
  1097. <img style="display: none; aspect-ratio: ${aspectRatioCss};">
  1098. <span>0:00</span>
  1099. </div>
  1100. </div>`).insertAfter(`progress`);
  1101.  
  1102. $('.anitracker-progress-tooltip img').on('load', () => {
  1103. $('.anitracker-progress-tooltip img').css('display', 'block');
  1104. });
  1105.  
  1106. const toggleVisibility = (on) => {
  1107. if (on) $('.anitracker-progress-tooltip').css('opacity', '1').css('scale','1').css('translate','');
  1108. else $('.anitracker-progress-tooltip').css('opacity', '0').css('scale','0.75').css('translate','-12.5% 20px');
  1109. };
  1110.  
  1111. const elem = $('.anitracker-progress-tooltip');
  1112. let currentTime = 0;
  1113. new MutationObserver(function(mutationList, observer) {
  1114. if ($('.plyr--full-ui').hasClass('plyr--hide-controls') || !$(`#plyr-seek-${settingsContainerId}`)[0].matches(`#plyr-seek-${settingsContainerId}:hover`)) {
  1115. toggleVisibility(false);
  1116. return;
  1117. }
  1118. toggleVisibility(true);
  1119.  
  1120. const seekValue = $(`#plyr-seek-${settingsContainerId}`).attr('seek-value');
  1121. const time = seekValue !== undefined ? Math.min(Math.max(Math.trunc(fullDuration*(+seekValue/100)), 0), fullDuration) : Math.trunc(player.currentTime);
  1122. const timeSlot = Math.trunc(time/timeBetweenThumbnails);
  1123. const roundedTime = getThumbnailTime(time, timeBetweenThumbnails, fullDuration);
  1124.  
  1125. elem.find('span').text(secondsToHMS(time));
  1126. elem.css('left', seekValue + '%');
  1127.  
  1128. if (roundedTime === getThumbnailTime(currentTime, timeBetweenThumbnails, fullDuration)) return;
  1129.  
  1130. const cached = thumbnails.find(a => a.time === timeSlot);
  1131. if (cached !== undefined) {
  1132. elem.find('img').attr('src', cached.data);
  1133. }
  1134. else {
  1135. elem.find('img').css('display', 'none');
  1136. getFrame(bgVid, roundedTime, {y: resolution, x: resolution*aspectRatio}).then((response) => {
  1137. thumbnails.push({
  1138. time: timeSlot,
  1139. data: response
  1140. });
  1141.  
  1142. elem.find('img').css('display', 'none');
  1143. elem.find('img').attr('src', response);
  1144. });
  1145. }
  1146. currentTime = time;
  1147.  
  1148. }).observe($(`#plyr-seek-${settingsContainerId}`)[0], { attributes: true });
  1149.  
  1150. $(`#plyr-seek-${settingsContainerId}`).on('mouseleave', () => {
  1151. toggleVisibility(false);
  1152. });
  1153.  
  1154. }
  1155.  
  1156. const hls2 = new Hls({
  1157. maxBufferLength: 0.1,
  1158. backBufferLength: 0,
  1159. capLevelToPlayerSize: true,
  1160. maxAudioFramesDrift: Infinity
  1161. });
  1162. hls2.loadSource(videoSource);
  1163. hls2.attachMedia(bgVid);
  1164. }
  1165.  
  1166. // Thumbnails when seeking
  1167. if (Hls.isSupported() && initialStorage.settings.seekThumbnails !== false) {
  1168. sendMessage({action:"video_url_request"});
  1169. waitingState.videoUrlRequest = 1;
  1170. setTimeout(() => {
  1171. if (waitingState.videoUrlRequest === 2) return;
  1172.  
  1173. waitingState.videoUrlRequest = -1;
  1174. if (typeof hls !== "undefined") setupSeekThumbnails(hls.url);
  1175. }, 500);
  1176. }
  1177.  
  1178. function finishedLoading() {
  1179. if ($('.anitracker-loading').length === 0) return;
  1180. $('.anitracker-loading').remove();
  1181. $('button.plyr__controls__item:nth-child(1)').show();
  1182. $('.plyr__progress__container').show();
  1183. $('.plyr__control--overlaid').show();
  1184.  
  1185. const storage = getStorage();
  1186. if (storage.settings.autoPlayVideo === true) player.play();
  1187. }
  1188.  
  1189. let messageTimeout = undefined;
  1190.  
  1191. function showMessage(text) {
  1192. $('.anitracker-message span').text(text);
  1193. $('.anitracker-message').css('display', 'flex');
  1194. clearTimeout(messageTimeout);
  1195. messageTimeout = setTimeout(() => {
  1196. $('.anitracker-message').hide();
  1197. }, 1000);
  1198. }
  1199.  
  1200. const frametime = 1 / 24;
  1201. let funPitch = "";
  1202.  
  1203. $(document).on('keydown', function(e, other = undefined) {
  1204. const key = e.key || other.key;
  1205. if (key === 'ArrowUp') {
  1206. changeSpeed(e, -1); // The changeSpeed function only works if ctrl is being held
  1207. return;
  1208. }
  1209. if (key === 'ArrowDown') {
  1210. changeSpeed(e, 1);
  1211. return;
  1212. }
  1213. if (e.shiftKey && ['l','L'].includes(key)) {
  1214. showMessage('Loop: ' + (player.loop ? 'Off' : 'On'));
  1215. player.loop = !player.loop;
  1216. return;
  1217. }
  1218. if (e.shiftKey && ['n','N'].includes(key)) {
  1219. sendMessage({action: "next"});
  1220. return;
  1221. }
  1222. if (e.shiftKey && ['p','P'].includes(key)) {
  1223. sendMessage({action: "previous"});
  1224. return;
  1225. }
  1226. if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return; // Prevents special keys for the rest of the keybinds
  1227. if (key === 'j') {
  1228. player.currentTime = Math.max(0, player.currentTime - 10);
  1229. return;
  1230. }
  1231. else if (key === 'l') {
  1232. player.currentTime = Math.min(player.duration, player.currentTime + 10);
  1233. setTimeout(() => {
  1234. player.loop = false;
  1235. }, 5);
  1236. return;
  1237. }
  1238. else if (/^Numpad\d$/.test(e.code)) {
  1239. player.currentTime = (player.duration/10)*(+e.code.replace('Numpad', ''));
  1240. return;
  1241. }
  1242. if (!(player.currentTime > 0 && !player.paused && !player.ended && player.readyState > 2)) {
  1243. if (key === ',') {
  1244. player.currentTime = Math.max(0, player.currentTime - frametime);
  1245. return;
  1246. }
  1247. else if (key === '.') {
  1248. player.currentTime = Math.min(player.duration, player.currentTime + frametime);
  1249. return;
  1250. }
  1251. }
  1252.  
  1253. funPitch += key;
  1254. if (funPitch === 'crazy') {
  1255. player.preservesPitch = !player.preservesPitch;
  1256. showMessage(player.preservesPitch ? 'Off' : 'Change speed ;D');
  1257. funPitch = "";
  1258. return;
  1259. }
  1260. if (!"crazy".startsWith(funPitch)) {
  1261. funPitch = "";
  1262. }
  1263.  
  1264. sendMessage({
  1265. action: "key",
  1266. key: key
  1267. });
  1268.  
  1269. });
  1270.  
  1271. // Ctrl+scrolling to change speed
  1272.  
  1273. $(`
  1274. <button class="anitracker-skip-button" tabindex="-1" style="opacity:0;pointer-events:none;translate:-50%;" aria-label="Skip section"><span>Skip Section</span></button>
  1275. <div class="anitracker-message" style="display:none;">
  1276. <span>2.0x</span>
  1277. </div>`).appendTo($(player).parents().eq(1));
  1278.  
  1279. jQuery.event.special.wheel = {
  1280. setup: function( _, ns, handle ){
  1281. this.addEventListener("wheel", handle, { passive: false });
  1282. }
  1283. };
  1284.  
  1285. const defaultSpeeds = player.plyr.options.speed;
  1286.  
  1287. function changeSpeed(e, delta) {
  1288. if (!e.ctrlKey) return;
  1289. e.preventDefault();
  1290. if (delta == 0) return;
  1291.  
  1292. const speedChange = e.shiftKey ? 0.05 : 0.1;
  1293.  
  1294. setSpeed(player.playbackRate + speedChange * (delta > 0 ? -1 : 1));
  1295. }
  1296.  
  1297. function setSpeed(speed) {
  1298. if (speed > 0) player.playbackRate = Math.round(speed * 100) / 100;
  1299. showMessage(player.playbackRate + "x");
  1300.  
  1301. if (defaultSpeeds.includes(player.playbackRate)) {
  1302. $('.anitracker-custom-speed-btn').remove();
  1303. }
  1304. else if ($('.anitracker-custom-speed-btn').length === 0) {
  1305. $(`#plyr-settings-${settingsContainerId}-speed>div>button`).attr('aria-checked','false');
  1306. $(`
  1307. <button type="button" role="menuitemradio" class="plyr__control anitracker-custom-speed-btn" aria-checked="true"><span>Custom</span></button>
  1308. `).prependTo(`#plyr-settings-${settingsContainerId}-speed>div`);
  1309.  
  1310. for (const elem of $(`#plyr-settings-${settingsContainerId}-home>div>`)) {
  1311. if (!/^Speed/.test($(elem).children('span')[0].textContent)) continue;
  1312. $(elem).find('span')[1].textContent = "Custom";
  1313. }
  1314. }
  1315. }
  1316.  
  1317. $(`#plyr-settings-${settingsContainerId}-speed>div>button`).on('click', (e) => {
  1318. $('.anitracker-custom-speed-btn').remove();
  1319. });
  1320.  
  1321. $(document).on('wheel', function(e) {
  1322. changeSpeed(e, e.originalEvent.deltaY);
  1323. });
  1324.  
  1325. }
  1326.  
  1327. return;
  1328. }
  1329.  
  1330. if ($() !== null) anitrackerLoad(window.location.origin + window.location.pathname + window.location.search);
  1331. else {
  1332. document.querySelector('head > link:nth-child(10)').onload(() => {anitrackerLoad(window.location.origin + window.location.pathname + window.location.search)});
  1333. }
  1334.  
  1335. function anitrackerLoad(url) {
  1336.  
  1337. if ($('#anitracker-modal').length > 0) {
  1338. console.log("[AnimePahe Improvements] Script was reloaded.");
  1339. return;
  1340. }
  1341.  
  1342. if (initialStorage.settings.hideThumbnails === true) {
  1343. hideThumbnails();
  1344. }
  1345.  
  1346. function windowOpen(url, target = '_blank') {
  1347. $(`<a href="${url}" target="${target}"></a>`)[0].click();
  1348. }
  1349.  
  1350. (function($) {
  1351. $.fn.changeElementType = function(newType) {
  1352. let attrs = {};
  1353.  
  1354. $.each(this[0].attributes, function(idx, attr) {
  1355. attrs[attr.nodeName] = attr.nodeValue;
  1356. });
  1357.  
  1358. this.replaceWith(function() {
  1359. return $("<" + newType + "/>", attrs).append($(this).contents());
  1360. });
  1361. };
  1362. $.fn.replaceClass = function(oldClass, newClass) {
  1363. this.removeClass(oldClass).addClass(newClass);
  1364. };
  1365. })(jQuery);
  1366.  
  1367. // -------- AnimePahe Improvements CSS ---------
  1368.  
  1369. const animationTimes = {
  1370. modalOpen: 0.2,
  1371. fadeIn: 0.2
  1372. };
  1373.  
  1374. const _css = `
  1375. #anitracker {
  1376. display: flex;
  1377. flex-direction: row;
  1378. gap: 15px 7px;
  1379. align-items: center;
  1380. flex-wrap: wrap;
  1381. }
  1382. .anitracker-index {
  1383. align-items: end !important;
  1384. }
  1385. #anitracker>span {align-self: center;\n}
  1386. #anitracker-modal {
  1387. position: fixed;
  1388. width: 100%;
  1389. height: 100%;
  1390. background-color: rgba(0,0,0,0.6);
  1391. z-index: 20;
  1392. display: none;
  1393. }
  1394. #anitracker-modal-content {
  1395. max-height: 90%;
  1396. background-color: var(--dark);
  1397. margin: auto auto auto auto;
  1398. border-radius: 20px;
  1399. display: flex;
  1400. padding: 20px;
  1401. z-index:50;
  1402. }
  1403. #anitracker-modal-close {
  1404. font-size: 2.5em;
  1405. margin: 3px 10px;
  1406. cursor: pointer;
  1407. height: 1em;
  1408. }
  1409. #anitracker-modal-close:hover,#anitracker-modal-close:focus-visible {
  1410. color: rgb(255, 0, 108);
  1411. }
  1412. #anitracker-modal-body {
  1413. padding: 10px;
  1414. overflow-x: hidden;
  1415. }
  1416. #anitracker-modal-body .anitracker-switch {margin-bottom: 2px;\n}
  1417. .anitracker-big-list-item {
  1418. list-style: none;
  1419. border-radius: 10px;
  1420. margin-top: 5px;
  1421. }
  1422. .anitracker-big-list-item>a {
  1423. font-size: 0.875rem;
  1424. display: block;
  1425. padding: 5px 15px;
  1426. color: rgb(238, 238, 238);
  1427. text-decoration: none;
  1428. }
  1429. .anitracker-big-list-item img {
  1430. margin: auto 0px;
  1431. width: 50px;
  1432. height: 50px;
  1433. border-radius: 100%;
  1434. }
  1435. .anitracker-big-list-item .anitracker-main-text {
  1436. font-weight: 700;
  1437. color: rgb(238, 238, 238);
  1438. }
  1439. .anitracker-big-list-item .anitracker-subtext {
  1440. font-size: 0.75rem;
  1441. color: rgb(153, 153, 153);
  1442. }
  1443. .anitracker-big-list-item:hover .anitracker-main-text {
  1444. color: rgb(238, 238, 238);
  1445. }
  1446. .anitracker-big-list-item:hover .anitracker-subtext {
  1447. color: rgb(238, 238, 238);
  1448. }
  1449. .anitracker-big-list-item:hover, .anitracker-big-list-item:focus-within {
  1450. background-color: rgb(23,28,33);
  1451. }
  1452. .anitracker-big-list-item:focus-within .anitracker-main-text {
  1453. color: rgb(238, 238, 238);
  1454. }
  1455. .anitracker-big-list-item:focus-within .anitracker-subtext {
  1456. color: rgb(238, 238, 238);
  1457. }
  1458. .anitracker-hide-thumbnails .anitracker-thumbnail img {display: none;\n}
  1459. .anitracker-hide-thumbnails .anitracker-thumbnail {
  1460. border: 10px solid rgb(32, 32, 32);
  1461. aspect-ratio: 16/9;
  1462. }
  1463. .anitracker-hide-thumbnails .episode-snapshot img {
  1464. display: none;
  1465. }
  1466. .anitracker-hide-thumbnails .episode-snapshot {
  1467. border: 4px solid var(--dark);
  1468. }
  1469. .anitracker-download-spinner {display: inline;\n}
  1470. .anitracker-download-spinner .spinner-border {
  1471. height: 0.875rem;
  1472. width: 0.875rem;
  1473. }
  1474. .anitracker-dropdown-content {
  1475. display: none;
  1476. position: absolute;
  1477. min-width: 100px;
  1478. box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
  1479. z-index: 1;
  1480. max-height: 400px;
  1481. overflow-y: auto;
  1482. overflow-x: hidden;
  1483. background-color: #171717;
  1484. }
  1485. .anitracker-dropdown-content button {
  1486. color: white;
  1487. padding: 12px 16px;
  1488. text-decoration: none;
  1489. display: block;
  1490. width:100%;
  1491. background-color: #171717;
  1492. border: none;
  1493. margin: 0;
  1494. }
  1495. .anitracker-dropdown-content button:hover, .anitracker-dropdown-content button:focus {background-color: black;\n}
  1496. .anitracker-active, .anitracker-active:hover, .anitracker-active:active {
  1497. color: white!important;
  1498. background-color: #d5015b!important;
  1499. }
  1500. .anitracker-dropdown-content a:hover {background-color: #ddd;\n}
  1501. .anitracker-dropdown:hover .anitracker-dropdown-content {display: block;\n}
  1502. .anitracker-dropdown:hover .anitracker-dropbtn {background-color: #bc0150;\n}
  1503. #pickDownload span, #scrollArea span {
  1504. cursor: pointer;
  1505. font-size: 0.875rem;
  1506. }
  1507. .anitracker-expand-data-icon {
  1508. font-size: 24px;
  1509. float: right;
  1510. margin-top: 2px;
  1511. margin-right: 10px;
  1512. }
  1513. .anitracker-modal-list-container {
  1514. background-color: rgb(30,35,40);
  1515. margin-bottom: 8px;
  1516. border-radius: 12px;
  1517. }
  1518. .anitracker-storage-data {
  1519. background-color: rgb(30,35,40);
  1520. border-radius: 12px;
  1521. cursor: pointer;
  1522. position: relative;
  1523. z-index: 1;
  1524. }
  1525. .anitracker-storage-data:focus {
  1526. box-shadow: 0 0 0 .2rem rgb(255, 255, 255);
  1527. }
  1528. .anitracker-storage-data span {
  1529. display: inline-block;
  1530. font-size: 1.1em;
  1531. }
  1532. .anitracker-storage-data, .anitracker-modal-list {
  1533. padding: 8px 14px;
  1534. }
  1535. .anitracker-modal-list-entry {margin-top: 8px;\n}
  1536. .anitracker-modal-list-entry a {text-decoration: underline;\n}
  1537. .anitracker-modal-list-entry:hover {background-color: rgb(23,28,33);\n}
  1538. .anitracker-relation-link {
  1539. text-overflow: ellipsis;
  1540. overflow: hidden;
  1541. }
  1542. .anitracker-spinner {
  1543. color: #d5015b;
  1544. }
  1545. .anitracker-spinner>span {
  1546. color: white;
  1547. }
  1548. #anitracker-cover-spinner .spinner-border {
  1549. width:2rem;
  1550. height:2rem;
  1551. }
  1552. .anime-cover {
  1553. display: flex;
  1554. justify-content: center;
  1555. align-items: center;
  1556. image-rendering: optimizequality;
  1557. }
  1558. .anitracker-filter-input {
  1559. width: 12.2rem;
  1560. display: inline-block;
  1561. cursor: text;
  1562. }
  1563. .anitracker-filter-input > div {
  1564. height:56px;
  1565. width:100%;
  1566. border-bottom: 2px solid #454d54;
  1567. overflow-y: auto;
  1568. }
  1569. .anitracker-filter-input.active > div {
  1570. border-color: rgb(213, 1, 91);
  1571. }
  1572. .anitracker-filter-rules {
  1573. background: black;
  1574. border: 1px solid #bbb;
  1575. color: #bbb;
  1576. padding: 5px;
  1577. float: right;
  1578. border-radius: 5px;
  1579. font-size: .8em;
  1580. width: 2em;
  1581. aspect-ratio: 1;
  1582. margin-bottom: -10px;
  1583. z-index: 1;
  1584. position: relative;
  1585. min-height: 0;
  1586. }
  1587. .anitracker-filter-rules>i {
  1588. vertical-align: super;
  1589. }
  1590. .anitracker-filter-rules.anitracker-active {
  1591. border-color: rgb(213, 1, 91);
  1592. }
  1593. .anitracker-filter-rules:hover, .anitracker-filter-rules:focus-visible {
  1594. background: white;
  1595. color: black;
  1596. border-color: white;
  1597. }
  1598. .anitracker-filter-input-search {
  1599. position: absolute;
  1600. max-width: 150px;
  1601. max-height: 45px;
  1602. min-width: 150px;
  1603. min-height: 45px;
  1604. overflow-wrap: break-word;
  1605. overflow-y: auto;
  1606. }
  1607. .anitracker-filter-input .placeholder {
  1608. color: #999;
  1609. position: absolute;
  1610. z-index: -1;
  1611. }
  1612. .anitracker-filter-icon {
  1613. padding: 0;
  1614. padding-right: 4px;
  1615. border-radius: 12px;
  1616. display: inline-block;
  1617. cursor: pointer;
  1618. border: 2px solid white;
  1619. margin-right: 5px;
  1620. transition: background-color .3s, border-color .3s;
  1621. vertical-align: text-top;
  1622. font-size: .95em;
  1623. }
  1624. .anitracker-filter-icon>i {
  1625. margin: 2px;
  1626. margin-left: 3px;
  1627. font-size: .8em;
  1628. }
  1629. .anitracker-filter-icon.included {
  1630. background-color: rgba(20, 113, 30, 0.64);
  1631. border-color: rgb(62, 181, 62);
  1632. }
  1633. .anitracker-filter-icon.included>i {
  1634. color: rgb(83, 255, 83);
  1635. }
  1636. .anitracker-filter-icon.excluded {
  1637. background-color: rgba(187, 62, 62, 0.41);
  1638. border-color: #d75a5a;
  1639. }
  1640. .anitracker-filter-icon.excluded>i {
  1641. color: rgb(227, 96, 96);
  1642. }
  1643. .anitracker-filter-icon:hover {
  1644. border-color: white;
  1645. }
  1646. #anitracker-settings-invert-switch:checked ~ .custom-control-label::before {
  1647. border-color: red;
  1648. background-color: red;
  1649. }
  1650. #anitracker-settings-invert-switch:checked[disabled=""] ~ .custom-control-label::before {
  1651. border-color: #e88b8b;
  1652. background-color: #e88b8b;
  1653. }
  1654. .anitracker-text-input {
  1655. display: inline-block;
  1656. height: 1em;
  1657. line-break: anywhere;
  1658. min-width: 50px;
  1659. }
  1660. .anitracker-text-input-bar {
  1661. background: #333;
  1662. box-shadow: none;
  1663. color: #bbb;
  1664. }
  1665. .anitracker-text-input-bar:focus {
  1666. border-color: #d5015b;
  1667. background: none;
  1668. box-shadow: none;
  1669. color: #ddd;
  1670. }
  1671. .anitracker-text-input-bar[disabled=""] {
  1672. background: rgb(89, 89, 89);
  1673. border-color: gray;
  1674. cursor: not-allowed;
  1675. }
  1676. .anitracker-applied-filters {
  1677. display: inline-block;
  1678. }
  1679. .anitracker-placeholder {
  1680. color: gray;
  1681. }
  1682. .anitracker-filter-dropdown>button {
  1683. transition: background-color .3s;
  1684. }
  1685. .anitracker-filter-dropdown>button.included {
  1686. background-color: rgb(6, 130, 54);
  1687. }
  1688. .anitracker-filter-dropdown>button.included:focus {
  1689. border: 2px dashed rgb(141, 234, 141);
  1690. }
  1691. .anitracker-filter-dropdown>button.excluded {
  1692. background-color: rgb(117, 17, 17);
  1693. }
  1694. .anitracker-filter-dropdown>button.excluded:focus {
  1695. border: 2px dashed rgb(215, 90, 90);
  1696. }
  1697. .anitracker-filter-dropdown>button.anitracker-active:focus {
  1698. border: 2px dashed #ffd7eb;
  1699. }
  1700. #anitracker-season-copy-to-lower {
  1701. color:white;
  1702. margin-left:14px;
  1703. border-radius:5px;
  1704. }
  1705. .anitracker-filter-spinner.small {
  1706. display: inline-flex;
  1707. margin-left: 10px;
  1708. justify-content: center;
  1709. align-items: center;
  1710. vertical-align: bottom;
  1711. }
  1712. .anitracker-filter-spinner.screen {
  1713. width:100%;
  1714. height:100%;
  1715. background-color:rgba(0, 0, 0, 0.9);
  1716. position:fixed;
  1717. z-index:999;
  1718. display:flex;
  1719. justify-content:center;
  1720. align-items:center;
  1721. }
  1722. .anitracker-filter-spinner.screen .spinner-border {
  1723. width:5rem;
  1724. height:5rem;
  1725. border-width: 10px;
  1726. }
  1727. .anitracker-filter-spinner>span {
  1728. position: absolute;
  1729. font-weight: bold;
  1730. }
  1731. .anitracker-filter-spinner.small>span {
  1732. font-size: .5em;
  1733. }
  1734. .anitracker-filter-rule-selection {
  1735. margin-bottom: 2px;
  1736. display: grid;
  1737. grid-template-columns: 1.5em 32% auto;
  1738. align-items: center;
  1739. grid-gap: 5px;
  1740. border-radius: 20px;
  1741. padding: 5px;
  1742. }
  1743. .anitracker-filter-rule-selection[disabled=""]>* {
  1744. opacity: 0.5;
  1745. pointer-events: none;
  1746. }
  1747. .anitracker-filter-rule-selection>i {
  1748. text-align: center;
  1749. border-radius: 35%;
  1750. padding: 2px;
  1751. aspect-ratio: 1;
  1752. }
  1753. .anitracker-filter-rule-selection>i::before {
  1754. vertical-align: middle;
  1755. }
  1756. .anitracker-filter-rule-selection>.fa-plus {
  1757. color: rgb(72, 223, 58);
  1758. background-color: #148214;
  1759. }
  1760. .anitracker-filter-rule-selection>.fa-minus {
  1761. color: #ff0000;
  1762. background-color: #911212;
  1763. }
  1764. .anitracker-filter-rule-selection button {
  1765. padding: 0;
  1766. padding-bottom: 26px;
  1767. width: 2.5em;
  1768. height: 2em;
  1769. background-color: var(--secondary);
  1770. border: 3px solid var(--dark);
  1771. border-radius: 10px;
  1772. outline: rgb(94, 96, 100) solid 3px;
  1773. margin: 5px;
  1774. color: white;
  1775. }
  1776. .anitracker-filter-rule-selection button.anitracker-active {
  1777. outline: rgb(213, 1, 91) solid 3px;
  1778. }
  1779. .anitracker-filter-rule-selection button:hover:not([disabled=""]), .anitracker-filter-rule-selection button:focus-visible:not([disabled=""]) {
  1780. outline: white solid 3px;
  1781. }
  1782. .anitracker-flat-button {
  1783. padding-top: 0;
  1784. padding-bottom: 0;
  1785. }
  1786. .anitracker-list-btn {
  1787. height: 42px;
  1788. border-radius: 7px!important;
  1789. color: #ddd!important;
  1790. margin-left: 10px!important;
  1791. }
  1792. .anitracker-reverse-order-button {
  1793. font-size: 2em;
  1794. }
  1795. .anitracker-reverse-order-button::after {
  1796. vertical-align: 20px;
  1797. }
  1798. .anitracker-reverse-order-button.anitracker-up::after {
  1799. border-top: 0;
  1800. border-bottom: .3em solid;
  1801. vertical-align: 22px;
  1802. }
  1803. #anitracker-time-search-button {
  1804. float: right;
  1805. }
  1806. #anitracker-time-search-button svg {
  1807. width: 24px;
  1808. vertical-align: bottom;
  1809. }
  1810. .anitracker-season-group {
  1811. display: grid;
  1812. grid-template-columns: 10% 30% 20% 10%;
  1813. margin-bottom: 5px;
  1814. }
  1815. .anitracker-season-group .btn-group {
  1816. margin-left: 5px;
  1817. }
  1818. .anitracker-season-group>span {
  1819. align-self: center;
  1820. }
  1821. a.youtube-preview::before {
  1822. -webkit-transition: opacity .2s linear!important;
  1823. -moz-transition: opacity .2s linear!important;
  1824. transition: opacity .2s linear!important;
  1825. }
  1826. .anitracker-replaced-cover {background-position-y: 25%;\n}
  1827. .anitracker-text-button {
  1828. color:#d5015b;
  1829. cursor:pointer;
  1830. user-select:none;
  1831. }
  1832. .anitracker-text-button:hover, .anitracker-text-button:focus-visible {
  1833. color:white;
  1834. }
  1835. .nav-search {
  1836. float: left!important;
  1837. }
  1838. .anitracker-title-icon {
  1839. margin-left: 1rem!important;
  1840. opacity: .8!important;
  1841. color: #ff006c!important;
  1842. font-size: 2rem!important;
  1843. vertical-align: middle;
  1844. cursor: pointer;
  1845. padding: 0;
  1846. box-shadow: none!important;
  1847. }
  1848. .anitracker-title-icon:hover {
  1849. opacity: 1!important;
  1850. }
  1851. .anitracker-title-icon-check {
  1852. color: white;
  1853. margin-left: -.7rem!important;
  1854. font-size: 1rem!important;
  1855. vertical-align: super;
  1856. text-shadow: none;
  1857. opacity: 1!important;
  1858. }
  1859. .anitracker-header {
  1860. display: flex;
  1861. justify-content: left;
  1862. gap: 18px;
  1863. flex-grow: 0.05;
  1864. }
  1865. .anitracker-header-button {
  1866. color: white;
  1867. background: none;
  1868. border: 2px solid white;
  1869. border-radius: 5px;
  1870. width: 2rem;
  1871. }
  1872. .anitracker-header-button:hover {
  1873. border-color: #ff006c;
  1874. color: #ff006c;
  1875. }
  1876. .anitracker-header-button:focus {
  1877. border-color: #ff006c;
  1878. color: #ff006c;
  1879. }
  1880. .anitracker-header-notifications-circle {
  1881. color: rgb(255, 0, 108);
  1882. margin-left: -.3rem;
  1883. font-size: 0.7rem;
  1884. position: absolute;
  1885. }
  1886. .anitracker-notification-item .anitracker-main-text {
  1887. color: rgb(153, 153, 153);
  1888. }
  1889. .anitracker-notification-item-unwatched {
  1890. background-color: rgb(119, 62, 70);
  1891. }
  1892. .anitracker-notification-item-unwatched .anitracker-main-text {
  1893. color: white!important;
  1894. }
  1895. .anitracker-notification-item-unwatched .anitracker-subtext {
  1896. color: white!important;
  1897. }
  1898. .anitracker-watched-toggle {
  1899. font-size: 1.7em;
  1900. float: right;
  1901. margin-right: 5px;
  1902. margin-top: 5px;
  1903. cursor: pointer;
  1904. background-color: rgb(64, 64, 72);
  1905. padding: 5px;
  1906. border-radius: 5px;
  1907. }
  1908. .anitracker-watched-toggle:hover,.anitracker-watched-toggle:focus {
  1909. box-shadow: 0 0 0 .2rem rgb(255, 255, 255);
  1910. }
  1911. #anitracker-replace-cover {
  1912. z-index: 99;
  1913. right: 10px;
  1914. position: absolute;
  1915. bottom: 6em;
  1916. }
  1917. header.main-header nav .main-nav li.nav-item > a:focus {
  1918. color: #fff;
  1919. background-color: #bc0150;
  1920. }
  1921. .theatre-settings .dropup .btn:focus {
  1922. box-shadow: 0 0 0 .15rem rgb(100, 100, 100)!important;
  1923. }
  1924. .anitracker-episode-time {
  1925. margin-left: 5%;
  1926. font-size: 0.75rem!important;
  1927. cursor: default!important;
  1928. }
  1929. .anitracker-episode-time:hover {
  1930. text-decoration: none!important;
  1931. }
  1932. .anitracker-episode-progress {
  1933. height: 8px;
  1934. position: absolute;
  1935. bottom: 0;
  1936. background-color: #bc0150;
  1937. z-index: 1;
  1938. }
  1939. .anitracker-episode-menu-button {
  1940. top: 0;
  1941. position: absolute;
  1942. right: 0;
  1943. width: 32px;
  1944. height: 32px;
  1945. z-index: 1;
  1946. color: white;
  1947. background: none;
  1948. border: 0;
  1949. transition: background-color .3s ease;
  1950. border-radius: 10%;
  1951. border-top-right-radius: 0;
  1952. }
  1953. .anitracker-episode-menu-button>svg {
  1954. width: 6px;
  1955. display: block;
  1956. margin: auto;
  1957. stroke: #424242;
  1958. stroke-width: 32px;
  1959. }
  1960. .anitracker-episode-menu-button:hover, .anitracker-episode-menu-button:focus {
  1961. background-color: rgba(0,0,0,0.8);
  1962. color: #bc0150;
  1963. }
  1964. .anitracker-episode-menu-button:hover>svg, .anitracker-episode-menu-button:focus>svg {
  1965. stroke-width: 0;
  1966. }
  1967. .anitracker-episode-menu-dropdown {
  1968. z-index: 2;
  1969. right:0;
  1970. left: auto;
  1971. max-width: 160px;
  1972. top: 32px;
  1973. }
  1974. .anitracker-watched-episodes-list {
  1975. display: inline-block;
  1976. max-width: 500px;
  1977. overflow: hidden;
  1978. text-overflow: ellipsis;
  1979. color: gray;
  1980. }
  1981. .index>* {
  1982. width: 100%;
  1983. }
  1984. @media screen and (min-width: 1375px) {
  1985. .theatre.anitracker-theatre-mode {
  1986. margin-top: 10px!important;
  1987. }
  1988. .theatre.anitracker-theatre-mode>* {
  1989. max-width: 81%!important;
  1990. }
  1991. }
  1992. @keyframes anitracker-modalOpen {
  1993. 0% {
  1994. transform: scale(0.5);
  1995. }
  1996. 50% {
  1997. transform: scale(1.07);
  1998. }
  1999. 100% {
  2000. transform: scale(1);
  2001. }
  2002. }
  2003. @keyframes anitracker-fadeIn {
  2004. from {
  2005. opacity: 0;
  2006. }
  2007. to {
  2008. opacity: 1;
  2009. }
  2010. }
  2011. @keyframes anitracker-spin {
  2012. from {
  2013. transform: rotate(0deg);
  2014. }
  2015. to {
  2016. transform: rotate(360deg);
  2017. }
  2018. }
  2019. `;
  2020.  
  2021. applyCssSheet(_css);
  2022.  
  2023.  
  2024. const optionSwitches = [
  2025. {
  2026. optionId: 'autoDelete',
  2027. switchId: 'auto-delete',
  2028. value: initialStorage.settings.autoDelete
  2029. },
  2030. {
  2031. optionId: 'theatreMode',
  2032. switchId: 'theatre-mode',
  2033. value: initialStorage.settings.theatreMode,
  2034. onEvent: () => {
  2035. theatreMode(true);
  2036. },
  2037. offEvent: () => {
  2038. theatreMode(false);
  2039. }
  2040. },
  2041. {
  2042. optionId: 'hideThumbnails',
  2043. switchId: 'hide-thumbnails',
  2044. value: initialStorage.settings.hideThumbnails,
  2045. onEvent: hideThumbnails,
  2046. offEvent: () => {
  2047. $('.main').removeClass('anitracker-hide-thumbnails');
  2048. }
  2049. },
  2050. {
  2051. optionId: 'bestQuality',
  2052. switchId: 'best-quality',
  2053. value: initialStorage.settings.bestQuality,
  2054. onEvent: bestVideoQuality
  2055. },
  2056. {
  2057. optionId: 'autoDownload',
  2058. switchId: 'auto-download',
  2059. value: initialStorage.settings.autoDownload
  2060. },
  2061. {
  2062. optionId: 'autoPlayNext',
  2063. switchId: 'autoplay-next',
  2064. value: initialStorage.settings.autoPlayNext
  2065. },
  2066. {
  2067. optionId: 'autoPlayVideo',
  2068. switchId: 'autoplay-video',
  2069. value: initialStorage.settings.autoPlayVideo
  2070. },
  2071. {
  2072. optionId: 'seekThumbnails',
  2073. switchId: 'seek-thumbnails',
  2074. value: initialStorage.settings.seekThumbnails
  2075. },
  2076. {
  2077. optionId: 'seekPoints',
  2078. switchId: 'seek-points',
  2079. value: initialStorage.settings.seekPoints,
  2080. onEvent: () => {
  2081. sendMessage({action:'setting_changed',type:'seek_points',value:true});
  2082. },
  2083. offEvent: () => {
  2084. sendMessage({action:'setting_changed',type:'seek_points',value:false});
  2085. }
  2086. },
  2087. {
  2088. optionId: 'skipButton',
  2089. switchId: 'skip-button',
  2090. value: initialStorage.settings.skipButton,
  2091. onEvent: () => {
  2092. sendMessage({action:'setting_changed',type:'skip_button',value:true});
  2093. },
  2094. offEvent: () => {
  2095. sendMessage({action:'setting_changed',type:'skip_button',value:false});
  2096. }
  2097. },
  2098. {
  2099. optionId: 'copyScreenshots',
  2100. switchId: 'copy-screenshots',
  2101. value: initialStorage.settings.copyScreenshots,
  2102. onEvent: () => {
  2103. sendMessage({action:'setting_changed',type:'screenshot_mode',value:'copy'});
  2104. },
  2105. offEvent: () => {
  2106. sendMessage({action:'setting_changed',type:'screenshot_mode',value:'download'});
  2107. }
  2108. },
  2109. {
  2110. optionId: 'reduceMotion',
  2111. switchId: 'reduced-motion',
  2112. value: initialStorage.settings.reduceMotion
  2113. }];
  2114.  
  2115. const cachedAnimeData = [];
  2116.  
  2117. // Things that update when focusing this tab
  2118. $(document).on('visibilitychange', () => {
  2119. if (document.hidden) return;
  2120. updatePage();
  2121. });
  2122.  
  2123. function updatePage() {
  2124. updateSwitches();
  2125.  
  2126. const storage = getStorage();
  2127. const data = url.includes('/anime/') ? getAnimeData() : undefined;
  2128.  
  2129. if (data !== undefined) {
  2130. const isBookmarked = storage.bookmarks.find(a => a.id === data.id) !== undefined;
  2131. if (isBookmarked) $('.anitracker-bookmark-toggle .anitracker-title-icon-check').show();
  2132. else $('.anitracker-bookmark-toggle .anitracker-title-icon-check').hide();
  2133.  
  2134. const hasNotifications = storage.notifications.anime.find(a => a.id === data.id) !== undefined;
  2135. if (hasNotifications) $('.anitracker-notifications-toggle .anitracker-title-icon-check').show();
  2136. else $('.anitracker-notifications-toggle .anitracker-title-icon-check').hide();
  2137. }
  2138.  
  2139. if (!modalIsOpen() || $('.anitracker-view-notif-animes').length === 0) return;
  2140.  
  2141. for (const item of $('.anitracker-notification-item-unwatched')) {
  2142. const entry = storage.notifications.episodes.find(a => a.animeId === +$(item).attr('anime-data') && a.episode === +$(item).attr('episode-data') && a.watched === true);
  2143. if (entry === undefined) continue;
  2144. $(item).removeClass('anitracker-notification-item-unwatched');
  2145. const eye = $(item).find('.anitracker-watched-toggle');
  2146. eye.replaceClass('fa-eye', 'fa-eye-slash');
  2147. }
  2148. }
  2149.  
  2150. function theatreMode(on) {
  2151. if (on) $('.theatre').addClass('anitracker-theatre-mode');
  2152. else $('.theatre').removeClass('anitracker-theatre-mode');
  2153. }
  2154.  
  2155. function playAnimation(elem, anim, type = '', duration) {
  2156. return new Promise(resolve => {
  2157. elem.css('animation', `anitracker-${anim} ${duration || animationTimes[anim]}s forwards linear ${type}`);
  2158. if (animationTimes[anim] === undefined) resolve();
  2159. setTimeout(() => {
  2160. elem.css('animation', '');
  2161. resolve();
  2162. }, animationTimes[anim] * 1000);
  2163. });
  2164. }
  2165.  
  2166. let modalCloseFunction = closeModal;
  2167. // AnimePahe Improvements modal
  2168. function addModal() {
  2169. $(`
  2170. <div id="anitracker-modal" tabindex="-1">
  2171. <div id="anitracker-modal-content">
  2172. <i tabindex="0" id="anitracker-modal-close" class="fa fa-close" title="Close modal">
  2173. </i>
  2174. <div id="anitracker-modal-body"></div>
  2175. </div>
  2176. </div>`).insertBefore('.main-header');
  2177.  
  2178. $('#anitracker-modal').on('click', (e) => {
  2179. if (e.target !== e.currentTarget) return;
  2180. modalCloseFunction();
  2181. });
  2182.  
  2183. $('#anitracker-modal-close').on('click keydown', (e) => {
  2184. if (e.type === 'keydown' && e.key !== "Enter") return;
  2185. modalCloseFunction();
  2186. });
  2187. }
  2188. addModal();
  2189.  
  2190. function openModal(closeFunction = closeModal) {
  2191. if (closeFunction !== closeModal) $('#anitracker-modal-close').replaceClass('fa-close', 'fa-arrow-left');
  2192. else $('#anitracker-modal-close').replaceClass('fa-arrow-left', 'fa-close');
  2193.  
  2194. const storage = getStorage();
  2195.  
  2196. return new Promise(resolve => {
  2197. if (storage.settings.reduceMotion !== true) {
  2198. playAnimation($('#anitracker-modal-content'), 'modalOpen');
  2199. playAnimation($('#anitracker-modal'), 'fadeIn').then(() => {
  2200. $('#anitracker-modal').focus();
  2201. resolve();
  2202. });
  2203. }
  2204. else {
  2205. $('#anitracker-modal').focus();
  2206. resolve();
  2207. }
  2208.  
  2209. $('#anitracker-modal').css('display','flex');
  2210. modalCloseFunction = closeFunction;
  2211. });
  2212. }
  2213.  
  2214. function closeModal() {
  2215. const storage = getStorage();
  2216. if (storage.settings.reduceMotion === true || $('#anitracker-modal').css('animation') !== 'none') {
  2217. $('#anitracker-modal').hide();
  2218. return;
  2219. }
  2220.  
  2221. playAnimation($('#anitracker-modal'), 'fadeIn', 'reverse', 0.1).then(() => {
  2222. $('#anitracker-modal').hide();
  2223. });
  2224. }
  2225.  
  2226. function modalIsOpen() {
  2227. return $('#anitracker-modal').is(':visible');
  2228. }
  2229.  
  2230. let currentEpisodeTime = 0;
  2231. // Messages received from iframe
  2232. if (isEpisode()) {
  2233. window.onmessage = function(e) {
  2234. const data = e.data;
  2235.  
  2236. if (typeof(data) === 'number') {
  2237. currentEpisodeTime = Math.trunc(data);
  2238. return;
  2239. }
  2240.  
  2241. const action = data.action;
  2242. if (action === 'id_request') {
  2243. sendMessage({action:"id_response",id:getAnimeData().id});
  2244. }
  2245. else if (action === 'anidb_id_request') {
  2246. getAnidbId(data.id).then(result => {
  2247. sendMessage({action:"anidb_id_response",id:result,originalId:data.id});
  2248. });
  2249. }
  2250. else if (action === 'video_url_request') {
  2251. const selected = {
  2252. src: undefined,
  2253. res: undefined,
  2254. audio: undefined
  2255. }
  2256. for (const btn of $('#resolutionMenu>button')) {
  2257. const src = $(btn).data('src');
  2258. const res = +$(btn).data('resolution');
  2259. const audio = $(btn).data('audio');
  2260. if (selected.src !== undefined && selected.res < res) continue;
  2261. if (selected.audio !== undefined && audio === 'jp' && selected.res <= res) continue; // Prefer dubs, since they don't have subtitles
  2262. selected.src = src;
  2263. selected.res = res;
  2264. selected.audio = audio;
  2265. }
  2266. if (selected.src === undefined) {
  2267. console.error("[AnimePahe Improvements] Didn't find video URL");
  2268. return;
  2269. }
  2270. console.log('[AnimePahe Improvements] Found lowest resolution URL ' + selected.src);
  2271. sendMessage({action:"video_url_response", url:selected.src});
  2272. }
  2273. else if (action === 'key') {
  2274. if (data.key === 't') {
  2275. toggleTheatreMode();
  2276. }
  2277. }
  2278. else if (data === 'ended') {
  2279. const storage = getStorage();
  2280. if (storage.settings.autoPlayNext !== true) return;
  2281. const elem = $('.sequel a');
  2282. if (elem.length > 0) elem[0].click();
  2283. }
  2284. else if (action === 'next') {
  2285. const elem = $('.sequel a');
  2286. if (elem.length > 0) elem[0].click();
  2287. }
  2288. else if (action === 'previous') {
  2289. const elem = $('.prequel a');
  2290. if (elem.length > 0) elem[0].click();
  2291. }
  2292. };
  2293. }
  2294.  
  2295. function sendMessage(message) {
  2296. const iframe = $('.embed-responsive-item');
  2297. if (iframe.length === 0) return;
  2298. iframe[0].contentWindow.postMessage(message,'*');
  2299. }
  2300.  
  2301. function toggleTheatreMode() {
  2302. const storage = getStorage();
  2303. theatreMode(!storage.settings.theatreMode);
  2304.  
  2305. storage.settings.theatreMode = !storage.settings.theatreMode;
  2306. saveData(storage);
  2307. updateSwitches();
  2308. }
  2309.  
  2310. async function getAnidbId(paheId) {
  2311. return new Promise(resolve => {
  2312. const req = new XMLHttpRequest();
  2313. req.open('GET', `/a/${paheId}`, true);
  2314. req.onload = () => {
  2315. for (const link of $(req.response).find('.external-links a')) {
  2316. const elem = $(link);
  2317. if (elem.text() !== 'AniDB') continue;
  2318. resolve(/\/\/anidb.net\/anime\/(\d+)/.exec(elem.attr('href'))[1]);
  2319. }
  2320. resolve(undefined);
  2321. }
  2322. req.send();
  2323. })
  2324. }
  2325.  
  2326. async function getAnimeNameFromId(id) {
  2327. return new Promise(resolve => {
  2328. const req = new XMLHttpRequest();
  2329. req.open('GET', `/a/${id}`, true);
  2330. req.onload = () => {
  2331. if (!isAnime(new URL(req.responseURL).pathname)) {
  2332. resolve(undefined);
  2333. return;
  2334. }
  2335. resolve($($(req.response).find('.title-wrapper h1 span')[0]).text());
  2336. }
  2337. req.send();
  2338. })
  2339. }
  2340.  
  2341. function getSeasonValue(season) {
  2342. return ({winter:0, spring:1, summer:2, fall:3})[season.toLowerCase()];
  2343. }
  2344.  
  2345. function getSeasonName(season) {
  2346. return ["winter","spring","summer","fall"][season];
  2347. }
  2348.  
  2349. function stringSimilarity(s1, s2) {
  2350. let longer = s1;
  2351. let shorter = s2;
  2352. if (s1.length < s2.length) {
  2353. longer = s2;
  2354. shorter = s1;
  2355. }
  2356. const longerLength = longer.length;
  2357. if (longerLength == 0) {
  2358. return 1.0;
  2359. }
  2360. return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength);
  2361. }
  2362.  
  2363. function editDistance(s1, s2) {
  2364. s1 = s1.toLowerCase();
  2365. s2 = s2.toLowerCase();
  2366. const costs = [];
  2367. for (let i = 0; i <= s1.length; i++) {
  2368. let lastValue = i;
  2369. for (let j = 0; j <= s2.length; j++) {
  2370. if (i == 0)
  2371. costs[j] = j;
  2372. else {
  2373. if (j > 0) {
  2374. let newValue = costs[j - 1];
  2375. if (s1.charAt(i - 1) != s2.charAt(j - 1))
  2376. newValue = Math.min(Math.min(newValue, lastValue),
  2377. costs[j]) + 1;
  2378. costs[j - 1] = lastValue;
  2379. lastValue = newValue;
  2380. }
  2381. }
  2382. }
  2383. if (i > 0)
  2384. costs[s2.length] = lastValue;
  2385. }
  2386. return costs[s2.length];
  2387. }
  2388.  
  2389. function searchForCollections() {
  2390. if ($('.search-results a').length === 0) return;
  2391.  
  2392. const baseName = $($('.search-results .result-title')[0]).text();
  2393.  
  2394. const request = new XMLHttpRequest();
  2395. request.open('GET', '/api?m=search&q=' + encodeURIComponent(baseName), true);
  2396.  
  2397. request.onload = () => {
  2398. if (request.readyState !== 4 || request.status !== 200 ) return;
  2399.  
  2400. response = JSON.parse(request.response).data;
  2401.  
  2402. if (response == undefined) return;
  2403.  
  2404. let seriesList = [];
  2405.  
  2406. for (const anime of response) {
  2407. if (stringSimilarity(baseName, anime.title) >= 0.42 || (anime.title.startsWith(baseName) && stringSimilarity(baseName, anime.title) >= 0.25)) {
  2408. seriesList.push(anime);
  2409. }
  2410. }
  2411.  
  2412. if (seriesList.length < 2) return;
  2413. seriesList = sortAnimesChronologically(seriesList);
  2414.  
  2415. displayCollection(seriesList);
  2416. };
  2417.  
  2418. request.send();
  2419. }
  2420.  
  2421. new MutationObserver(function(mutationList, observer) {
  2422. if (!searchComplete()) return;
  2423. searchForCollections();
  2424. }).observe($('.search-results-wrap')[0], { childList: true });
  2425.  
  2426. function searchComplete() {
  2427. return $('.search-results').length !== 0 && $('.search-results a').length > 0;
  2428. }
  2429.  
  2430. function displayCollection(seriesList) {
  2431. $(`
  2432. <li class="anitracker-collection" data-index="-1">
  2433. <a title="${toHtmlCodes(seriesList[0].title + " - Collection")}" href="javascript:;">
  2434. <img src="${seriesList[0].poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" style="pointer-events: all !important;max-width: 30px;">
  2435. <img src="${seriesList[1].poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" style="pointer-events: all !important;max-width: 30px;left:30px;">
  2436. <div class="result-title">${toHtmlCodes(seriesList[0].title)}</div>
  2437. <div class="result-status"><strong>Collection</strong> - ${seriesList.length} Entries</div>
  2438. </a>
  2439. </li>`).prependTo('.search-results');
  2440.  
  2441. function displayInModal() {
  2442. $('#anitracker-modal-body').empty();
  2443. $(`
  2444. <h4>Collection</h4>
  2445. <div class="anitracker-modal-list-container">
  2446. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;"></div>
  2447. </div>`).appendTo('#anitracker-modal-body');
  2448.  
  2449. for (const anime of seriesList) {
  2450. $(`
  2451. <div class="anitracker-big-list-item anitracker-collection-item">
  2452. <a href="/anime/${anime.session}" title="${toHtmlCodes(anime.title)}">
  2453. <img src="${anime.poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" alt="[Thumbnail of ${anime.title}]">
  2454. <div class="anitracker-main-text">${anime.title}</div>
  2455. <div class="anitracker-subtext"><strong>${anime.type}</strong> - ${anime.episodes > 0 ? anime.episodes : '?'} Episode${anime.episodes === 1 ? '' : 's'} (${anime.status})</div>
  2456. <div class="anitracker-subtext">${anime.season} ${anime.year}</div>
  2457. </a>
  2458. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  2459. }
  2460.  
  2461. openModal();
  2462. }
  2463.  
  2464. $('.anitracker-collection').on('click', displayInModal);
  2465. $('.input-search').on('keyup', (e) => {
  2466. if (e.key === "Enter" && $('.anitracker-collection').hasClass('selected')) displayInModal();
  2467. });
  2468. }
  2469.  
  2470. function getSeasonTimeframe(from, to) {
  2471. const filters = [];
  2472. for (let i = from.year; i <= to.year; i++) {
  2473. const start = i === from.year ? from.season : 0;
  2474. const end = i === to.year ? to.season : 3;
  2475. for (let d = start; d <= end; d++) {
  2476. filters.push({type: 'season_entry', value: {year: i, season: d}});
  2477. }
  2478. }
  2479. return filters;
  2480. }
  2481.  
  2482. const is404 = $('h1').text().includes('404');
  2483.  
  2484. if (!isRandomAnime() && initialStorage.cache !== undefined) {
  2485. const storage = getStorage();
  2486. delete storage.cache;
  2487. saveData(storage);
  2488. }
  2489.  
  2490. const filterSearchCache = {};
  2491.  
  2492. const filterValues = {
  2493. "genre":[
  2494. {"name":"Comedy","value":"comedy"},{"name":"Slice of Life","value":"slice-of-life"},{"name":"Romance","value":"romance"},{"name":"Ecchi","value":"ecchi"},{"name":"Drama","value":"drama"},
  2495. {"name":"Supernatural","value":"supernatural"},{"name":"Sports","value":"sports"},{"name":"Horror","value":"horror"},{"name":"Sci-Fi","value":"sci-fi"},{"name":"Action","value":"action"},
  2496. {"name":"Fantasy","value":"fantasy"},{"name":"Mystery","value":"mystery"},{"name":"Suspense","value":"suspense"},{"name":"Adventure","value":"adventure"},{"name":"Boys Love","value":"boys-love"},
  2497. {"name":"Girls Love","value":"girls-love"},{"name":"Hentai","value":"hentai"},{"name":"Gourmet","value":"gourmet"},{"name":"Erotica","value":"erotica"},{"name":"Avant Garde","value":"avant-garde"},
  2498. {"name":"Award Winning","value":"award-winning"}
  2499. ],
  2500. "theme":[
  2501. {"name":"Adult Cast","value":"adult-cast"},{"name":"Anthropomorphic","value":"anthropomorphic"},{"name":"Detective","value":"detective"},{"name":"Love Polygon","value":"love-polygon"},
  2502. {"name":"Mecha","value":"mecha"},{"name":"Music","value":"music"},{"name":"Psychological","value":"psychological"},{"name":"School","value":"school"},{"name":"Super Power","value":"super-power"},
  2503. {"name":"Space","value":"space"},{"name":"CGDCT","value":"cgdct"},{"name":"Romantic Subtext","value":"romantic-subtext"},{"name":"Historical","value":"historical"},{"name":"Video Game","value":"video-game"},
  2504. {"name":"Martial Arts","value":"martial-arts"},{"name":"Idols (Female)","value":"idols-female"},{"name":"Idols (Male)","value":"idols-male"},{"name":"Gag Humor","value":"gag-humor"},{"name":"Parody","value":"parody"},
  2505. {"name":"Performing Arts","value":"performing-arts"},{"name":"Military","value":"military"},{"name":"Harem","value":"harem"},{"name":"Reverse Harem","value":"reverse-harem"},{"name":"Samurai","value":"samurai"},
  2506. {"name":"Vampire","value":"vampire"},{"name":"Mythology","value":"mythology"},{"name":"High Stakes Game","value":"high-stakes-game"},{"name":"Strategy Game","value":"strategy-game"},
  2507. {"name":"Magical Sex Shift","value":"magical-sex-shift"},{"name":"Racing","value":"racing"},{"name":"Isekai","value":"isekai"},{"name":"Workplace","value":"workplace"},{"name":"Iyashikei","value":"iyashikei"},
  2508. {"name":"Time Travel","value":"time-travel"},{"name":"Gore","value":"gore"},{"name":"Educational","value":"educational"},{"name":"Delinquents","value":"delinquents"},{"name":"Organized Crime","value":"organized-crime"},
  2509. {"name":"Otaku Culture","value":"otaku-culture"},{"name":"Medical","value":"medical"},{"name":"Survival","value":"survival"},{"name":"Reincarnation","value":"reincarnation"},{"name":"Showbiz","value":"showbiz"},
  2510. {"name":"Team Sports","value":"team-sports"},{"name":"Mahou Shoujo","value":"mahou-shoujo"},{"name":"Combat Sports","value":"combat-sports"},{"name":"Crossdressing","value":"crossdressing"},
  2511. {"name":"Visual Arts","value":"visual-arts"},{"name":"Childcare","value":"childcare"},{"name":"Pets","value":"pets"},{"name":"Love Status Quo","value":"love-status-quo"},{"name":"Urban Fantasy","value":"urban-fantasy"},
  2512. {"name":"Villainess","value":"villainess"}
  2513. ],
  2514. "type":[
  2515. {"name":"TV","value":"tv"},{"name":"Movie","value":"movie"},{"name":"OVA","value":"ova"},{"name":"ONA","value":"ona"},{"name":"Special","value":"special"},{"name":"Music","value":"music"}
  2516. ],
  2517. "demographic":[
  2518. {"name":"Shounen","value":"shounen"},{"name":"Shoujo","value":"shoujo"},{"name":"Seinen","value":"seinen"},{"name":"Kids","value":"kids"},{"name":"Josei","value":"josei"}
  2519. ],
  2520. "status":[
  2521. {"value":"airing"},{"value":"completed"}
  2522. ]
  2523. };
  2524.  
  2525. const filterDefaultRules = {
  2526. genre: {
  2527. include: "and",
  2528. exclude: "and"
  2529. },
  2530. theme: {
  2531. include: "and",
  2532. exclude: "and"
  2533. },
  2534. demographic: {
  2535. include: "or",
  2536. exclude: "and"
  2537. },
  2538. type: {
  2539. include: "or",
  2540. exclude: "and"
  2541. },
  2542. season: {
  2543. include: "or",
  2544. exclude: "and"
  2545. },
  2546. status: {
  2547. include: "or"
  2548. }
  2549. };
  2550.  
  2551. const filterRules = JSON.parse(JSON.stringify(filterDefaultRules));
  2552.  
  2553. function buildFilterString(type, value) {
  2554. if (type === 'status') return value;
  2555. if (type === 'season_entry') return `season/${getSeasonName(value.season)}-${value.year}`;
  2556.  
  2557. return type + '/' + value;
  2558. }
  2559.  
  2560. const seasonFilterRegex = /^!?(spring|summer|winter|fall)-(\d{4})\.\.(spring|summer|winter|fall)-(\d{4})$/;
  2561.  
  2562. function getFilteredList(filtersInput) {
  2563. let filtersChecked = 0;
  2564. let filtersTotal = 0;
  2565.  
  2566. function getPage(pageUrl) {
  2567. return new Promise((resolve, reject) => {
  2568. const cached = filterSearchCache[pageUrl];
  2569. if (cached !== undefined) { // If cache exists
  2570. if (cached === 'invalid') { // Not sure if it ever is 'invalid'
  2571. resolve([]);
  2572. return;
  2573. }
  2574. resolve(cached);
  2575. return;
  2576. }
  2577. const req = new XMLHttpRequest();
  2578. req.open('GET', pageUrl, true);
  2579. try {
  2580. req.send();
  2581. }
  2582. catch (err) {
  2583. console.error(err);
  2584. reject('A network error occured.');
  2585. return;
  2586. }
  2587.  
  2588. req.onload = () => {
  2589. if (req.status !== 200) {
  2590. filterSearchCache[pageUrl] = [];
  2591. resolve([]);
  2592. return;
  2593. }
  2594. const animeList = getAnimeList($(req.response));
  2595. filterSearchCache[pageUrl] = animeList;
  2596. resolve(animeList);
  2597. };
  2598. });
  2599. }
  2600.  
  2601. function getLists(filters) {
  2602. const lists = [];
  2603.  
  2604. return new Promise((resolve, reject) => {
  2605. function check() {
  2606. if (filters.length > 0) {
  2607. repeat(filters.shift());
  2608. }
  2609. else {
  2610. resolve(lists);
  2611. }
  2612. }
  2613.  
  2614. function repeat(filter) {
  2615. const filterType = filter.type;
  2616. if (filter.value === 'none') {
  2617. filtersTotal += filterValues[filterType].length;
  2618.  
  2619. getLists(filterValues[filterType].map(a => {return {type: filterType, value: a.value, exclude: false};})).then((filtered) => {
  2620. getPage('/anime').then((unfiltered) => {
  2621. const none = [];
  2622. for (const entry of unfiltered) {
  2623. const found = filtered.find(list => list.entries.find(a => a.name === entry.name));
  2624. if (!filter.exclude && found !== undefined) continue;
  2625. if (filter.exclude && found === undefined) continue;
  2626. none.push(entry);
  2627. }
  2628.  
  2629. lists.push({
  2630. type: filterType,
  2631. excludedFilter: false,
  2632. entries: none
  2633. });
  2634.  
  2635. check();
  2636. });
  2637. });
  2638. return;
  2639. }
  2640. if (filterType === 'season') {
  2641. const seasonFilters = getSeasonTimeframe(filter.value.from, filter.value.to);
  2642. filtersTotal += seasonFilters.length;
  2643.  
  2644. getLists(seasonFilters).then((filtered) => {
  2645. const filtersResult = [];
  2646. if (filter.exclude) getPage('/anime').then((unfiltered) => {
  2647. for (const entry of unfiltered) {
  2648. if (filtered.find(list => list.entries.find(a => a.name === entry.name)) !== undefined) continue;
  2649. filtersResult.push(entry);
  2650. }
  2651.  
  2652. lists.push({
  2653. type: 'season',
  2654. excludedFilter: true,
  2655. entries: filtersResult
  2656. });
  2657.  
  2658. check();
  2659. });
  2660. else {
  2661. for (const list of filtered) {
  2662. filtersResult.push(...list.entries);
  2663. }
  2664.  
  2665. lists.push({
  2666. type: 'season',
  2667. excludedFilter: false,
  2668. entries: filtersResult
  2669. });
  2670.  
  2671. check();
  2672. }
  2673. });
  2674.  
  2675. return;
  2676. }
  2677. if (filter.exclude) {
  2678. getPage('/anime/' + buildFilterString(filterType, filter.value)).then((filtered) => {
  2679. getPage('/anime').then((unfiltered) => {
  2680. const included = [];
  2681. for (const entry of unfiltered) {
  2682. if (filtered.find(a => a.name === entry.name) !== undefined) continue;
  2683. included.push(entry);
  2684. }
  2685.  
  2686. lists.push({
  2687. type: filterType,
  2688. excludedFilter: true,
  2689. entries: included
  2690. });
  2691.  
  2692. check();
  2693. });
  2694. });
  2695. return;
  2696. }
  2697. getPage('/anime/' + buildFilterString(filterType, filter.value)).then((result) => {
  2698. if (result !== undefined) {
  2699. lists.push({
  2700. type: filterType,
  2701. excludedFilter: false,
  2702. entries: result
  2703. });
  2704. }
  2705. if (filtersTotal > 0) {
  2706. filtersChecked++;
  2707. $($('.anitracker-filter-spinner>span')[0]).text(Math.floor((filtersChecked/filtersTotal) * 100).toString() + '%');
  2708. }
  2709.  
  2710. check();
  2711. });
  2712. }
  2713.  
  2714. check();
  2715. });
  2716. }
  2717.  
  2718. function combineLists(lists, rule) {
  2719. if (lists.length === 0) return [];
  2720.  
  2721. // Start with the first filter list result, then compare others to it
  2722. let combinedList = lists[0];
  2723. lists.splice(0,1); // Remove the first entry
  2724. for (const list of lists) {
  2725. // If the rule of this filter type is 'or,' start from the current list
  2726. // Otherwise, start from an empty list
  2727. const updatedList = rule === 'or' ? combinedList : [];
  2728. if (rule === 'and') for (const anime of list) {
  2729. // The anime has to exist in both the current and the checked list
  2730. if (combinedList.find(a => a.name === anime.name) === undefined) continue;
  2731. updatedList.push(anime);
  2732. }
  2733. else if (rule === 'or') for (const anime of list) {
  2734. // The anime just has to not already exist in the current list
  2735. if (combinedList.find(a => a.name === anime.name) !== undefined) continue;
  2736. updatedList.push(anime);
  2737. }
  2738. combinedList = updatedList;
  2739. }
  2740. return combinedList;
  2741. }
  2742.  
  2743. return new Promise((resolve, reject) => {
  2744. const filters = JSON.parse(JSON.stringify(filtersInput));
  2745.  
  2746. if (filters.length === 0) {
  2747. getPage('/anime').then((response) => {
  2748. if (response === undefined) {
  2749. alert('Page loading failed.');
  2750. reject('Anime index page not reachable.');
  2751. return;
  2752. }
  2753.  
  2754. resolve(response);
  2755. });
  2756. return;
  2757. }
  2758.  
  2759. filtersTotal = filters.length;
  2760.  
  2761.  
  2762. getLists(filters).then((listsInput) => {
  2763. const lists = JSON.parse(JSON.stringify(listsInput));
  2764.  
  2765. // groupedLists entries have the following format:
  2766. /* {
  2767. type, // the type of filter, eg. 'genre'
  2768. includeLists: [
  2769. <list of included anime>
  2770. ],
  2771. excludeLists: [
  2772. <list of excluded anime>
  2773. ]
  2774. }
  2775. */
  2776. const groupedLists = [];
  2777. for (const list of lists) {
  2778. let foundGroup = groupedLists.find(a => a.type === list.type);
  2779. if (foundGroup === undefined) {
  2780. groupedLists.push({
  2781. type: list.type,
  2782. includeLists: [],
  2783. excludeLists: []
  2784. });
  2785. foundGroup = groupedLists[groupedLists.length - 1];
  2786. }
  2787.  
  2788. if (list.excludedFilter) foundGroup.excludeLists.push(list.entries);
  2789. else foundGroup.includeLists.push(list.entries);
  2790. }
  2791.  
  2792. let finalList;
  2793.  
  2794. for (const group of groupedLists) {
  2795. const includeList = combineLists(group.includeLists, filterRules[group.type].include);
  2796. const excludeList = combineLists(group.excludeLists, filterRules[group.type].exclude);
  2797.  
  2798. // Combine the include and exclude lists
  2799.  
  2800. // If the exclude list exists, start from an empty list
  2801. // Otherwise, just default to the include list
  2802. let groupFinalList = [];
  2803. if (excludeList.length > 0 && includeList.length > 0) {
  2804. const combineRule = filterRules[group.type].combined;
  2805. for (const entry of excludeList) {
  2806. if (groupFinalList.find(a => a.name === entry.name) !== undefined) continue; // Don't include duplicates
  2807. if (combineRule === 'or') {
  2808. if (includeList.find(a => a.name === entry.name) !== undefined) continue;
  2809. groupFinalList.push(entry);
  2810. continue;
  2811. }
  2812. // Otherwise, the rule is 'and'
  2813. if (includeList.find(a => a.name === entry.name) === undefined) continue;
  2814. groupFinalList.push(entry);
  2815. }
  2816. }
  2817. else if (excludeList.length === 0) groupFinalList = includeList;
  2818. else if (includeList.length === 0) groupFinalList = excludeList;
  2819.  
  2820. // If the current final list is undefined, just add the resulting list to it and continue
  2821. if (finalList === undefined) {
  2822. finalList = groupFinalList;
  2823. continue;
  2824. }
  2825.  
  2826. const newFinalList = [];
  2827. // Loop through the resulting list
  2828. // Join together with 'and'
  2829. for (const anime of groupFinalList) {
  2830. if (finalList.find(a => a.name === anime.name) === undefined) continue;
  2831. newFinalList.push(anime);
  2832. }
  2833. finalList = newFinalList;
  2834. }
  2835.  
  2836. resolve(finalList);
  2837. });
  2838. });
  2839. }
  2840.  
  2841. function searchList(fuseClass, list, query, limit = 80) {
  2842. const fuse = new fuseClass(list, {
  2843. keys: ['name'],
  2844. findAllMatches: true
  2845. });
  2846.  
  2847. const matching = fuse.search(query);
  2848. return matching.map(a => {return a.item}).splice(0,limit);
  2849. }
  2850.  
  2851. function timeSince(date) {
  2852. const seconds = Math.floor((new Date() - date) / 1000);
  2853.  
  2854. let interval = Math.floor(seconds / 31536000);
  2855.  
  2856. if (interval >= 1) {
  2857. return interval + " year" + (interval > 1 ? 's' : '');
  2858. }
  2859. interval = Math.floor(seconds / 2592000);
  2860. if (interval >= 1) {
  2861. return interval + " month" + (interval > 1 ? 's' : '');
  2862. }
  2863. interval = Math.floor(seconds / 86400);
  2864. if (interval >= 1) {
  2865. return interval + " day" + (interval > 1 ? 's' : '');
  2866. }
  2867. interval = Math.floor(seconds / 3600);
  2868. if (interval >= 1) {
  2869. return interval + " hour" + (interval > 1 ? 's' : '');
  2870. }
  2871. interval = Math.floor(seconds / 60);
  2872. if (interval >= 1) {
  2873. return interval + " minute" + (interval > 1 ? 's' : '');
  2874. }
  2875. return seconds + " second" + (seconds > 1 ? 's' : '');
  2876. }
  2877.  
  2878. if (window.location.pathname.startsWith('/customlink')) {
  2879. const parts = {
  2880. animeSession: '',
  2881. episodeSession: '',
  2882. time: -1
  2883. };
  2884. const entries = Array.from(new URLSearchParams(window.location.search).entries()).sort((a,b) => a[0] > b[0] ? 1 : -1);
  2885. for (const entry of entries) {
  2886. if (entry[0] === 'a') {
  2887. const name = decodeURIComponent(entry[1]);
  2888. const animeData = getAnimeData(name, undefined, true);
  2889. if (animeData === undefined) return;
  2890. if (animeData.title !== name && !confirm(`[AnimePahe Improvements]\n\nCouldn't find any anime with name "${name}".\nGo to "${animeData.title}" instead?`)) {
  2891. return;
  2892. }
  2893. parts.animeSession = animeData.session;
  2894. continue;
  2895. }
  2896. if (entry[0] === 'e') {
  2897. if (parts.animeSession === '') return;
  2898. parts.episodeSession = getEpisodeSession(parts.animeSession, +entry[1]);
  2899. if (parts.episodeSession === undefined) parts.episodeSession = '';
  2900. continue;
  2901. }
  2902. if (entry[0] === 't') {
  2903. if (parts.animeSession === '') return;
  2904. if (parts.episodeSession === '') continue;
  2905.  
  2906. parts.time = +entry[1];
  2907. continue;
  2908. }
  2909. }
  2910.  
  2911. const destination = (() => {
  2912. if (parts.animeSession !== '' && parts.episodeSession === '' && parts.time === -1) {
  2913. return '/anime/' + parts.animeSession + '?ref=customlink';
  2914. }
  2915. if (parts.animeSession !== '' && parts.episodeSession !== '' && parts.time === -1) {
  2916. return '/play/' + parts.animeSession + '/' + parts.episodeSession + '?ref=customlink';
  2917. }
  2918. if (parts.animeSession !== '' && parts.episodeSession !== '' && parts.time >= 0) {
  2919. return '/play/' + parts.animeSession + '/' + parts.episodeSession + '?time=' + parts.time + '&ref=customlink';
  2920. }
  2921. return undefined;
  2922. })();
  2923.  
  2924. if (destination !== undefined) {
  2925. document.title = "Redirecting... :: animepahe";
  2926. $('h1').text('Redirecting...');
  2927. window.location.replace(destination);
  2928. }
  2929. return;
  2930. }
  2931.  
  2932. // Main key events
  2933. if (!is404) $(document).on('keydown', (e) => {
  2934. const isTextInput = $(e.target).is('input[type=text],input[type=""],input:not([type])');
  2935.  
  2936. if (modalIsOpen() && (e.key === 'Escape' || e.key === 'Backspace' && !isTextInput)) {
  2937. modalCloseFunction();
  2938. return;
  2939. }
  2940.  
  2941. if (isTextInput) return;
  2942.  
  2943. if (!isEpisode() || modalIsOpen()) return;
  2944. if (e.key === 't') {
  2945. toggleTheatreMode();
  2946. }
  2947. else {
  2948. sendMessage({action:"key",key:e.key});
  2949. $('.embed-responsive-item')[0].contentWindow.focus();
  2950. if ([" "].includes(e.key)) e.preventDefault();
  2951. }
  2952. });
  2953.  
  2954. if (window.location.pathname.startsWith('/queue')) {
  2955. $(`
  2956. <span style="font-size:.6em;">&nbsp;&nbsp;&nbsp;(Incoming episodes)</span>
  2957. `).appendTo('h2');
  2958. }
  2959.  
  2960. // Redirect filter pages
  2961. if (/^\/anime\/\w+(\/[\w\-\.]+)?$/.test(window.location.pathname)) {
  2962. if (is404) return;
  2963.  
  2964. const filter = /\/anime\/([^\/]+)\/?([^\/]+)?/.exec(window.location.pathname);
  2965.  
  2966. if (filter[2] !== undefined) {
  2967. if (filterRules[filter[1]] === undefined) return;
  2968. if (filter[1] === 'season') {
  2969. window.location.replace(`/anime?${filter[1]}=${filter[2]}..${filter[2]}`);
  2970. return;
  2971. }
  2972. window.location.replace(`/anime?${filter[1]}=${filter[2]}`);
  2973. }
  2974. else {
  2975. window.location.replace(`/anime?status=${filter[1]}`);
  2976. }
  2977. return;
  2978. }
  2979.  
  2980. function getDayName(day) {
  2981. return [
  2982. "Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"
  2983. ][day];
  2984. }
  2985.  
  2986. function toHtmlCodes(string) {
  2987. return $('<div>').text(string).html().replace(/"/g, "&quot;").replace(/'/g, "&#039;");
  2988. }
  2989.  
  2990. // Bookmark & episode feed header buttons
  2991. $(`
  2992. <div class="anitracker-header">
  2993. <button class="anitracker-header-notifications anitracker-header-button" title="View episode feed">
  2994. <i class="fa fa-bell" aria-hidden="true"></i>
  2995. <i style="display:none;" aria-hidden="true" class="fa fa-circle anitracker-header-notifications-circle"></i>
  2996. </button>
  2997. <button class="anitracker-header-bookmark anitracker-header-button" title="View bookmarks"><i class="fa fa-bookmark" aria-hidden="true"></i></button>
  2998. </div>`).insertAfter('.navbar-nav');
  2999.  
  3000. let currentNotificationIndex = 0;
  3001.  
  3002. function openNotificationsModal() {
  3003. currentNotificationIndex = 0;
  3004. const oldStorage = getStorage();
  3005. $('#anitracker-modal-body').empty();
  3006.  
  3007. $(`
  3008. <h4>Episode Feed</h4>
  3009. <div class="btn-group" style="margin-bottom: 10px;">
  3010. <button class="btn btn-secondary anitracker-view-notif-animes">
  3011. Handle Feed...
  3012. </button>
  3013. </div>
  3014. <div class="anitracker-modal-list-container">
  3015. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;">
  3016. <div id="anitracker-notifications-list-spinner" class="anitracker-spinner" style="display:flex;justify-content:center;">
  3017. <div class="spinner-border" role="status">
  3018. <span class="sr-only">Loading...</span>
  3019. </div>
  3020. </div>
  3021. </div>
  3022. </div>`).appendTo('#anitracker-modal-body');
  3023.  
  3024. $('.anitracker-view-notif-animes').on('click', () => {
  3025. $('#anitracker-modal-body').empty();
  3026. const storage = getStorage();
  3027. $(`
  3028. <h4>Handle Episode Feed</h4>
  3029. <div class="anitracker-modal-list-container">
  3030. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;"></div>
  3031. </div>
  3032. `).appendTo('#anitracker-modal-body');
  3033. [...storage.notifications.anime].sort((a,b) => a.latest_episode > b.latest_episode ? 1 : -1).forEach(g => {
  3034. const latestEp = new Date(g.latest_episode + " UTC");
  3035. const latestEpString = g.latest_episode !== undefined ? `${getDayName(latestEp.getDay())} ${latestEp.toLocaleTimeString()}` : "None found";
  3036. $(`
  3037. <div class="anitracker-modal-list-entry" animeid="${g.id}" animename="${toHtmlCodes(g.name)}">
  3038. <a href="/a/${g.id}" target="_blank" title="${toHtmlCodes(g.name)}">
  3039. ${g.name}
  3040. </a><br>
  3041. <span>
  3042. Latest episode: ${latestEpString}
  3043. </span><br>
  3044. <div class="btn-group">
  3045. <button class="btn btn-danger anitracker-delete-button anitracker-flat-button" title="Remove this anime from the episode feed">
  3046. <i class="fa fa-trash" aria-hidden="true"></i>
  3047. &nbsp;Remove
  3048. </button>
  3049. </div>
  3050. <div class="btn-group">
  3051. <button class="btn btn-secondary anitracker-get-all-button anitracker-flat-button" title="Put all episodes in the feed" ${g.hasFirstEpisode ? 'disabled=""' : ''}>
  3052. <i class="fa fa-rotate-right" aria-hidden="true"></i>
  3053. &nbsp;Get All
  3054. </button>
  3055. </div>
  3056. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3057. });
  3058. if (storage.notifications.anime.length === 0) {
  3059. $(`<span>Use the <i class="fa fa-bell" title="bell"></i> button on an ongoing anime to add it to the feed.</span>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3060. }
  3061.  
  3062. $('.anitracker-modal-list-entry .anitracker-get-all-button').on('click', (e) => {
  3063. const elem = $(e.currentTarget);
  3064. const id = +elem.parents().eq(1).attr('animeid');
  3065. const storage = getStorage();
  3066.  
  3067. const found = storage.notifications.anime.find(a => a.id === id);
  3068. if (found === undefined) {
  3069. console.error("[AnimePahe Improvements] Couldn't find feed for anime with ID " + id);
  3070. return;
  3071. }
  3072.  
  3073. found.hasFirstEpisode = true;
  3074. found.updateFrom = 0;
  3075. saveData(storage);
  3076.  
  3077. elem.replaceClass("btn-secondary", "btn-primary");
  3078. setTimeout(() => {
  3079. elem.replaceClass("btn-primary", "btn-secondary");
  3080. elem.prop('disabled', true);
  3081. }, 200);
  3082. });
  3083.  
  3084. $('.anitracker-modal-list-entry .anitracker-delete-button').on('click', (e) => {
  3085. const parent = $(e.currentTarget).parents().eq(1);
  3086. const name = parent.attr('animename');
  3087. toggleNotifications(name, +parent.attr('animeid'));
  3088.  
  3089. const name2 = getAnimeName();
  3090. if (name2.length > 0 && name2 === name) {
  3091. $('.anitracker-notifications-toggle .anitracker-title-icon-check').hide();
  3092. }
  3093.  
  3094. parent.remove();
  3095. });
  3096.  
  3097. openModal();
  3098. });
  3099.  
  3100. const animeData = [];
  3101. const queue = [...oldStorage.notifications.anime];
  3102.  
  3103. openModal().then(() => {
  3104. if (queue.length > 0) next();
  3105. else done();
  3106. });
  3107.  
  3108. async function next() {
  3109. if (queue.length === 0) done();
  3110. const anime = queue.shift();
  3111. const data = await updateNotifications(anime.name);
  3112.  
  3113. if (data === -1) {
  3114. $("<span>An error occured.</span>").appendTo('#anitracker-modal-body .anitracker-modal-list');
  3115. return;
  3116. }
  3117. animeData.push({
  3118. id: anime.id,
  3119. data: data
  3120. });
  3121.  
  3122. if (queue.length > 0 && $('#anitracker-notifications-list-spinner').length > 0) next();
  3123. else done();
  3124. }
  3125.  
  3126. function done() {
  3127. if ($('#anitracker-notifications-list-spinner').length === 0) return;
  3128. const storage = getStorage();
  3129. let removedAnime = 0;
  3130. for (const anime of storage.notifications.anime) {
  3131. if (anime.latest_episode === undefined || anime.dont_ask === true) continue;
  3132. const time = Date.now() - new Date(anime.latest_episode + " UTC").getTime();
  3133. if ((time / 1000 / 60 / 60 / 24 / 7) > 2) {
  3134. const remove = confirm(`[AnimePahe Improvements]\n\nThe latest episode for ${anime.name} was more than 2 weeks ago. Remove it from the feed?\n\nThis prompt will not be shown again.`);
  3135. if (remove === true) {
  3136. toggleNotifications(anime.name, anime.id);
  3137. removedAnime++;
  3138. }
  3139. else {
  3140. anime.dont_ask = true;
  3141. saveData(storage);
  3142. }
  3143. }
  3144. }
  3145. if (removedAnime > 0) {
  3146. openNotificationsModal();
  3147. return;
  3148. }
  3149. $('#anitracker-notifications-list-spinner').remove();
  3150. storage.notifications.episodes.sort((a,b) => a.time < b.time ? 1 : -1);
  3151. storage.notifications.lastUpdated = Date.now();
  3152. saveData(storage);
  3153. if (storage.notifications.episodes.length === 0) {
  3154. $("<span>Nothing here yet!</span>").appendTo('#anitracker-modal-body .anitracker-modal-list');
  3155. }
  3156. else addToList(20);
  3157. }
  3158.  
  3159. function addToList(num) {
  3160. const storage = getStorage();
  3161. const index = currentNotificationIndex;
  3162. for (let i = currentNotificationIndex; i < storage.notifications.episodes.length; i++) {
  3163. const ep = storage.notifications.episodes[i];
  3164. if (ep === undefined) break;
  3165. currentNotificationIndex++;
  3166. const data = animeData.find(a => a.id === ep.animeId)?.data;
  3167. if (data === undefined) {
  3168. console.error(`[AnimePahe Improvements] Could not find corresponding anime "${ep.animeName}" with ID ${ep.animeId} (episode ${ep.episode})`);
  3169. continue;
  3170. }
  3171.  
  3172. const releaseTime = new Date(ep.time + " UTC");
  3173. $(`
  3174. <div class="anitracker-big-list-item anitracker-notification-item${ep.watched ? "" : " anitracker-notification-item-unwatched"} anitracker-temp" anime-data="${data.id}" episode-data="${ep.episode}">
  3175. <a href="/play/${data.session}/${ep.session}" target="_blank" title="${toHtmlCodes(data.title)}">
  3176. <img src="${data.poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" alt="[Thumbnail of ${toHtmlCodes(data.title)}]"}>
  3177. <i class="fa ${ep.watched ? 'fa-eye-slash' : 'fa-eye'} anitracker-watched-toggle" tabindex="0" aria-hidden="true" title="Mark this episode as ${ep.watched ? 'unwatched' : 'watched'}"></i>
  3178. <div class="anitracker-main-text">${data.title}</div>
  3179. <div class="anitracker-subtext"><strong>Episode ${ep.episode}</strong></div>
  3180. <div class="anitracker-subtext">${timeSince(releaseTime)} ago (${releaseTime.toLocaleDateString()})</div>
  3181. </a>
  3182. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3183. if (i > index+num-1) break;
  3184. }
  3185.  
  3186. $('.anitracker-notification-item.anitracker-temp').on('click', (e) => {
  3187. $(e.currentTarget).find('a').blur();
  3188. });
  3189.  
  3190. $('.anitracker-notification-item.anitracker-temp .anitracker-watched-toggle').on('click keydown', (e) => {
  3191. if (e.type === 'keydown' && e.key !== "Enter") return;
  3192. e.preventDefault();
  3193. const storage = getStorage();
  3194. const elem = $(e.currentTarget);
  3195. const parent = elem.parents().eq(1);
  3196. const animeId = +parent.attr('anime-data');
  3197. const episode = +parent.attr('episode-data');
  3198. const ep = storage.notifications.episodes.find(a => a.animeId === animeId && a.episode === episode);
  3199. if (ep === undefined) {
  3200. console.error("[AnimePahe Improvements] couldn't mark episode as watched/unwatched");
  3201. return;
  3202. }
  3203. parent.toggleClass('anitracker-notification-item-unwatched');
  3204. elem.toggleClass('fa-eye').toggleClass('fa-eye-slash');
  3205.  
  3206. if (e.type === 'click') elem.blur();
  3207.  
  3208. ep.watched = !ep.watched;
  3209. elem.attr('title', `Mark this episode as ${ep.watched ? 'unwatched' : 'watched'}`);
  3210.  
  3211. saveData(storage);
  3212.  
  3213. if (ep.watched) {
  3214. addWatched(animeId, episode, storage);
  3215. }
  3216. else {
  3217. removeWatched(animeId, episode, storage);
  3218. }
  3219.  
  3220. if (isAnime() && getAnimeData().id === animeId) updateEpisodesPage();
  3221. });
  3222.  
  3223. $('.anitracker-notification-item.anitracker-temp').removeClass('anitracker-temp');
  3224.  
  3225. }
  3226.  
  3227. $('#anitracker-modal-body').on('scroll', () => {
  3228. const elem = $('#anitracker-modal-body');
  3229. if (elem.scrollTop() >= elem[0].scrollTopMax) {
  3230. if ($('.anitracker-view-notif-animes').length === 0) return;
  3231. addToList(20);
  3232. }
  3233. });
  3234. }
  3235.  
  3236. $('.anitracker-header-notifications').on('click', openNotificationsModal);
  3237.  
  3238. $('.anitracker-header-bookmark').on('click', () => {
  3239. $('#anitracker-modal-body').empty();
  3240. const storage = getStorage();
  3241. $(`
  3242. <h4>Bookmarks</h4>
  3243. <div class="anitracker-modal-list-container">
  3244. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;">
  3245. <div class="btn-group">
  3246. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-modal-search" placeholder="Search">
  3247. <button dir="down" class="btn btn-secondary dropdown-toggle anitracker-reverse-order-button anitracker-list-btn" title="Sort direction (down is default, and means newest first)"></button>
  3248. </div>
  3249. </div>
  3250. </div>
  3251. `).appendTo('#anitracker-modal-body');
  3252.  
  3253. $('.anitracker-modal-search').on('input', (e) => {
  3254. setTimeout(() => {
  3255. const query = $(e.target).val();
  3256. for (const entry of $('.anitracker-modal-list-entry')) {
  3257. if ($($(entry).find('a,span')[0]).text().toLowerCase().includes(query)) {
  3258. $(entry).show();
  3259. continue;
  3260. }
  3261. $(entry).hide();
  3262. }
  3263. }, 10);
  3264. });
  3265.  
  3266. function applyDeleteEvents() {
  3267. $('.anitracker-modal-list-entry button').on('click', (e) => {
  3268. const id = $(e.currentTarget).parent().attr('animeid');
  3269. toggleBookmark(id);
  3270.  
  3271. const data = getAnimeData();
  3272. if (data !== undefined && data.id === +id) {
  3273. $('.anitracker-bookmark-toggle .anitracker-title-icon-check').hide();
  3274. }
  3275.  
  3276. $(e.currentTarget).parent().remove();
  3277. });
  3278. }
  3279.  
  3280. // When clicking the reverse order button
  3281. $('.anitracker-reverse-order-button').on('click', (e) => {
  3282. const btn = $(e.target);
  3283. if (btn.attr('dir') === 'down') {
  3284. btn.attr('dir', 'up');
  3285. btn.addClass('anitracker-up');
  3286. }
  3287. else {
  3288. btn.attr('dir', 'down');
  3289. btn.removeClass('anitracker-up');
  3290. }
  3291.  
  3292. const entries = [];
  3293. for (const entry of $('.anitracker-modal-list-entry')) {
  3294. entries.push(entry.outerHTML);
  3295. }
  3296. entries.reverse();
  3297. $('.anitracker-modal-list-entry').remove();
  3298. for (const entry of entries) {
  3299. $(entry).appendTo($('.anitracker-modal-list'));
  3300. }
  3301. applyDeleteEvents();
  3302. });
  3303.  
  3304. [...storage.bookmarks].reverse().forEach(g => {
  3305. $(`
  3306. <div class="anitracker-modal-list-entry" animeid="${g.id}">
  3307. <a href="/a/${g.id}" target="_blank" title="${toHtmlCodes(g.name)}">
  3308. ${g.name}
  3309. </a><br>
  3310. <button class="btn btn-danger anitracker-flat-button" title="Remove this bookmark">
  3311. <i class="fa fa-trash" aria-hidden="true"></i>
  3312. &nbsp;Remove
  3313. </button>
  3314. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list')
  3315. });
  3316. if (storage.bookmarks.length === 0) {
  3317. $(`<span style="display: block;">No bookmarks yet!</span>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3318. }
  3319.  
  3320. applyDeleteEvents();
  3321. openModal();
  3322. $('#anitracker-modal-body')[0].scrollTop = 0;
  3323. });
  3324.  
  3325. function toggleBookmark(id, name=undefined) {
  3326. const storage = getStorage();
  3327. const found = storage.bookmarks.find(g => g.id === +id);
  3328.  
  3329. if (found !== undefined) {
  3330. const index = storage.bookmarks.indexOf(found);
  3331. storage.bookmarks.splice(index, 1);
  3332.  
  3333. saveData(storage);
  3334.  
  3335. return false;
  3336. }
  3337.  
  3338. if (name === undefined) return false;
  3339.  
  3340. storage.bookmarks.push({
  3341. id: +id,
  3342. name: name
  3343. });
  3344. saveData(storage);
  3345.  
  3346. return true;
  3347. }
  3348.  
  3349. function toggleNotifications(name, id = undefined) {
  3350. const storage = getStorage();
  3351. const found = (() => {
  3352. if (id !== undefined) return storage.notifications.anime.find(g => g.id === id);
  3353. else return storage.notifications.anime.find(g => g.name === name);
  3354. })();
  3355.  
  3356. if (found !== undefined) {
  3357. const index = storage.notifications.anime.indexOf(found);
  3358. storage.notifications.anime.splice(index, 1);
  3359.  
  3360. storage.notifications.episodes = storage.notifications.episodes.filter(a => a.animeName !== found.name); // Uses the name, because old data might not be updated to use IDs
  3361.  
  3362. saveData(storage);
  3363.  
  3364. return false;
  3365. }
  3366.  
  3367. const animeData = getAnimeData(name);
  3368.  
  3369. storage.notifications.anime.push({
  3370. name: name,
  3371. id: animeData.id
  3372. });
  3373. saveData(storage);
  3374.  
  3375. return true;
  3376. }
  3377.  
  3378. async function updateNotifications(animeName, storage = getStorage()) {
  3379. const nobj = storage.notifications.anime.find(g => g.name === animeName);
  3380. if (nobj === undefined) {
  3381. toggleNotifications(animeName);
  3382. return;
  3383. }
  3384. const data = await asyncGetAnimeData(animeName, nobj.id);
  3385. if (data === undefined) return -1;
  3386. const episodes = await asyncGetAllEpisodes(data.session, 'desc');
  3387. if (episodes === undefined) return 0;
  3388.  
  3389. return new Promise((resolve, reject) => {
  3390. if (episodes.length === 0) resolve(undefined);
  3391.  
  3392. nobj.latest_episode = episodes[0].created_at;
  3393.  
  3394. if (nobj.name !== data.title) {
  3395. for (const ep of storage.notifications.episodes) {
  3396. if (ep.animeName !== nobj.name) continue;
  3397. ep.animeName = data.title;
  3398. }
  3399. nobj.name = data.title;
  3400. }
  3401.  
  3402. const compareUpdateTime = nobj.updateFrom ?? storage.notifications.lastUpdated;
  3403. if (nobj.updateFrom !== undefined) delete nobj.updateFrom;
  3404.  
  3405. const watched = decodeWatched(storage.watched);
  3406.  
  3407. for (const ep of episodes) {
  3408. const epWatched = isWatched(nobj.id, ep.episode, watched);
  3409.  
  3410. const found = storage.notifications.episodes.find(a => a.episode === ep.episode && a.animeId === nobj.id) ?? storage.notifications.episodes.find(a => a.episode === ep.episode && a.animeName === data.title);
  3411. if (found !== undefined) {
  3412. found.session = ep.session;
  3413. if (!found.watched) found.watched = epWatched;
  3414. if (found.animeId === undefined) found.animeId = nobj.id;
  3415.  
  3416. if (episodes.indexOf(ep) === episodes.length - 1) nobj.hasFirstEpisode = true;
  3417. continue;
  3418. }
  3419.  
  3420. if (new Date(ep.created_at + " UTC").getTime() < compareUpdateTime) {
  3421. continue;
  3422. }
  3423.  
  3424. storage.notifications.episodes.push({
  3425. animeName: nobj.name,
  3426. animeId: nobj.id,
  3427. session: ep.session,
  3428. episode: ep.episode,
  3429. time: ep.created_at,
  3430. watched: epWatched
  3431. });
  3432. }
  3433.  
  3434. const length = storage.notifications.episodes.length;
  3435. if (length > 150) {
  3436. storage.notifications.episodes = storage.notifications.episodes.slice(length - 150);
  3437. }
  3438.  
  3439. saveData(storage);
  3440.  
  3441. resolve(data);
  3442. });
  3443. }
  3444.  
  3445. const paramArray = Array.from(new URLSearchParams(window.location.search));
  3446.  
  3447. const refArg01 = paramArray.find(a => a[0] === 'ref');
  3448. if (refArg01 !== undefined) {
  3449. const ref = refArg01[1];
  3450. if (ref === '404') {
  3451. alert('[AnimePahe Improvements]\n\nThe session was outdated, and has been refreshed. Please try that link again.');
  3452. }
  3453. else if (ref === 'customlink' && isEpisode() && initialStorage.settings.autoDelete) {
  3454. const name = getAnimeName();
  3455. const num = getEpisodeNum();
  3456. if (initialStorage.linkList.find(e => e.animeName === name && e.type === 'episode' && e.episodeNum !== num)) { // If another episode is already stored
  3457. $(`
  3458. <span style="display:block;width:100%;text-align:center;" class="anitracker-from-share-warning">
  3459. The current episode data for this anime was not replaced due to coming from a share link.
  3460. <br>Refresh this page to replace it.
  3461. <br><span class="anitracker-text-button" tabindex="0">Dismiss</span>
  3462. </span>`).prependTo('.content-wrapper');
  3463.  
  3464. $('.anitracker-from-share-warning>span').on('click keydown', function(e) {
  3465. if (e.type === 'keydown' && e.key !== "Enter") return;
  3466. $(e.target).parent().remove();
  3467. });
  3468. }
  3469. }
  3470.  
  3471. window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
  3472. }
  3473.  
  3474. function getCurrentSeason() {
  3475. const month = new Date().getMonth();
  3476. return Math.trunc(month/3);
  3477. }
  3478.  
  3479. function getFiltersFromParams(params) {
  3480. const filters = [];
  3481. for (const [key, value] of params.entries()) {
  3482. const inputFilters = value.split(','); // Get all filters of this filter type
  3483. for (const filter of inputFilters) {
  3484. if (filterRules[key] === undefined) continue;
  3485.  
  3486. const exclude = filter.startsWith('!');
  3487. if (key === 'season' && seasonFilterRegex.test(filter)) {
  3488. const parts = seasonFilterRegex.exec(filter);
  3489. if (!parts.includes(undefined) && ![parseInt(parts[2]),parseInt(parts[4])].includes(NaN)) {
  3490. filters.push({
  3491. type: 'season',
  3492. value: {
  3493. from: { season: getSeasonValue(parts[1]), year: parseInt(parts[2]) },
  3494. to: { season: getSeasonValue(parts[3]), year: parseInt(parts[4]) }
  3495. },
  3496. exclude: exclude
  3497. });
  3498. }
  3499. continue;
  3500. }
  3501.  
  3502. filters.push({
  3503. type: key,
  3504. value: filter.replace(/^!/,''),
  3505. exclude: exclude
  3506. });
  3507. }
  3508. }
  3509. return filters;
  3510. }
  3511.  
  3512. function loadIndexPage() {
  3513. const animeList = getAnimeList();
  3514. filterSearchCache['/anime'] = JSON.parse(JSON.stringify(animeList));
  3515.  
  3516. $(`
  3517. <div id="anitracker" class="anitracker-index" style="margin-bottom: 10px;">
  3518.  
  3519. <div class="anitracker-filter-input" data-filter-type="genre">
  3520. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="genre"><i class="fa fa-sliders"></i></button>
  3521. <div>
  3522. <div data-filter-type="genre" class="anitracker-applied-filters"></div><span data-filter-type="genre" role="textbox" contenteditable="" spellcheck="false" class="anitracker-text-input"></span>
  3523. </div>
  3524. </div>
  3525.  
  3526. <div class="anitracker-filter-input" data-filter-type="theme">
  3527. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="theme"><i class="fa fa-sliders"></i></button>
  3528. <div>
  3529. <div data-filter-type="theme" class="anitracker-applied-filters"></div><span data-filter-type="theme" role="textbox" contenteditable="" spellcheck="false" class="anitracker-text-input"></span>
  3530. </div>
  3531. </div>
  3532.  
  3533. <div class="anitracker-filter-input" data-filter-type="type">
  3534. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="type"><i class="fa fa-sliders"></i></button>
  3535. <div>
  3536. <div data-filter-type="type" class="anitracker-applied-filters"></div><span data-filter-type="type" role="textbox" contenteditable="" spellcheck="false" class="anitracker-text-input"></span>
  3537. </div>
  3538. </div>
  3539.  
  3540. <div class="anitracker-filter-input" data-filter-type="demographic">
  3541. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="demographic"><i class="fa fa-sliders"></i></button>
  3542. <div>
  3543. <div data-filter-type="demographic" class="anitracker-applied-filters"></div><span data-filter-type="demographic" role="textbox" contenteditable="" spellcheck="false" class="anitracker-text-input"></span>
  3544. </div>
  3545. </div>
  3546.  
  3547. <div style="margin-left: auto;">
  3548. <div class="btn-group">
  3549. <button class="btn dropdown-toggle btn-dark" id="anitracker-status-button" data-bs-toggle="dropdown" data-toggle="dropdown" title="Choose status">All</button>
  3550. </div>
  3551.  
  3552. <div class="btn-group">
  3553. <button class="btn btn-dark" id="anitracker-time-search-button" title="Set season filter">
  3554. <svg fill="#ffffff" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" xml:space="preserve" aria-hidden="true">
  3555. <path d="M256,0C114.842,0,0,114.842,0,256s114.842,256,256,256s256-114.842,256-256S397.158,0,256,0z M374.821,283.546H256 c-15.148,0-27.429-12.283-27.429-27.429V137.295c0-15.148,12.281-27.429,27.429-27.429s27.429,12.281,27.429,27.429v91.394h91.392 c15.148,0,27.429,12.279,27.429,27.429C402.249,271.263,389.968,283.546,374.821,283.546z"/>
  3556. </svg>
  3557. </button>
  3558. </div>
  3559. </div>
  3560.  
  3561. <div id="anitracker-filter-dropdown-container"></div>
  3562. </div>
  3563. <div id="anitracker-row-2">
  3564. <span style="font-size: 1.2em;color:#ddd;" id="anitracker-filter-result-count">Filter results: <span>${animeList.length}</span></span>
  3565. <div style="float: right; margin-right: 6px; margin-bottom: 2rem;">
  3566. <div class="btn-group">
  3567. <span id="anitracker-reset-filters" title="Reset filters" class="anitracker-text-button" style="margin-right: 10px;" tabindex="0"><i aria-hidden="true" class="fa fa-rotate-right"></i>&nbsp;Reset</span>
  3568. </div>
  3569. <div class="btn-group">
  3570. <button class="btn btn-dark" id="anitracker-apply-filters" title="Apply selected filters"><i class="fa fa-check" aria-hidden="true"></i>&nbsp;&nbsp;Apply</button>
  3571. </div>
  3572. <div class="btn-group">
  3573. <button class="btn btn-dark" id="anitracker-random-anime" title="Open a random anime from within the selected filters">
  3574. <i class="fa fa-random" aria-hidden="true"></i>
  3575. &nbsp;Random Anime
  3576. </button>
  3577. </div>
  3578. <div class="btn-group">
  3579. <input id="anitracker-anime-list-search" title="Search within applied filters" disabled="" autocomplete="off" class="form-control anitracker-text-input-bar" style="width: 150px;" placeholder="Loading...">
  3580. </div>
  3581. </div>
  3582. </div>`).insertBefore('.index');
  3583.  
  3584. function getDropdownButtons(filters, type) {
  3585. return filters.sort((a,b) => a.name > b.name ? 1 : -1).concat({value: 'none', name: '(None)'}).map(g => $(`<button data-filter-type="${type}" data-filter-value="${g.value}">${g.name}</button>`));
  3586. }
  3587.  
  3588. $(`<div id="anitracker-genre-dropdown" tabindex="-1" data-filter-type="genre" class="dropdown-menu anitracker-dropdown-content anitracker-filter-dropdown">`).appendTo('#anitracker-filter-dropdown-container');
  3589. getDropdownButtons(filterValues.genre, 'genre').forEach(g => { g.appendTo('#anitracker-genre-dropdown') });
  3590.  
  3591. $(`<div id="anitracker-theme-dropdown" tabindex="-1" data-filter-type="theme" class="dropdown-menu anitracker-dropdown-content anitracker-filter-dropdown">`).appendTo('#anitracker-filter-dropdown-container');
  3592. getDropdownButtons(filterValues.theme, 'theme').forEach(g => { g.appendTo('#anitracker-theme-dropdown') });
  3593.  
  3594. $(`<div id="anitracker-type-dropdown" tabindex="-1" data-filter-type="type" class="dropdown-menu anitracker-dropdown-content anitracker-filter-dropdown">`).appendTo('#anitracker-filter-dropdown-container');
  3595. getDropdownButtons(filterValues.type, 'type').forEach(g => { g.appendTo('#anitracker-type-dropdown') });
  3596.  
  3597. $(`<div id="anitracker-demographic-dropdown" tabindex="-1" data-filter-type="demographic" class="dropdown-menu anitracker-dropdown-content anitracker-filter-dropdown">`).appendTo('#anitracker-filter-dropdown-container');
  3598. getDropdownButtons(filterValues.demographic, 'demographic').forEach(g => { g.appendTo('#anitracker-demographic-dropdown') });
  3599.  
  3600. $(`<div id="anitracker-status-dropdown" tabindex="-1" data-filter-type="status" class="dropdown-menu anitracker-dropdown-content anitracker-filter-dropdown special">`).insertAfter('#anitracker-status-button');
  3601. ['all','airing','completed'].forEach(g => { $(`<button data-filter-type="status" data-filter-value="${g}">${g[0].toUpperCase() + g.slice(1)}</button>`).appendTo('#anitracker-status-dropdown') });
  3602. $(`<button data-filter-type="status" data-filter-value="none">(No status)</button>`).appendTo('#anitracker-status-dropdown');
  3603.  
  3604. const timeframeSettings = {
  3605. enabled: false
  3606. };
  3607.  
  3608. const placeholderTexts = {
  3609. 'genre': 'Genre',
  3610. 'theme': 'Theme',
  3611. 'type': 'Type',
  3612. 'demographic': 'Demographic'
  3613. }
  3614.  
  3615. const selectedFilters = [];
  3616. const appliedFilters = [];
  3617.  
  3618. function getElemsFromFilterType(filterType) {
  3619. const elems = {};
  3620. if (filterType === undefined) return elems;
  3621. for (const inp of $('.anitracker-filter-input')) {
  3622. if ($(inp).data('filter-type') !== filterType) continue;
  3623. elems.parent = $(inp);
  3624. elems.filterIcons = Array.from($(inp).find('.anitracker-filter-icon'));
  3625. elems.filterIconContainer = $(inp).find('.anitracker-applied-filters');
  3626. elems.input = $(inp).find('.anitracker-text-input');
  3627. elems.inputPlaceholder = $(inp).find('.anitracker-placeholder');
  3628. elems.scrollingDiv = $(inp).find('>div');
  3629. elems.filterRuleButton = $(inp).find('.anitracker-filter-rules');
  3630. break;
  3631. }
  3632. for (const drop of $('.anitracker-filter-dropdown')) {
  3633. if ($(drop).data('filter-type') !== filterType) continue;
  3634. elems.dropdown = $(drop);
  3635. }
  3636. return elems;
  3637. }
  3638.  
  3639. function getFilterDataFromElem(jquery) {
  3640. return {
  3641. type: jquery.data('filter-type'),
  3642. value: jquery.data('filter-value'),
  3643. exclude: jquery.data('filter-exclude') === true
  3644. }
  3645. }
  3646.  
  3647. function getInputText(elem) {
  3648. return elem.contents().filter(function() {
  3649. return this.nodeType === Node.TEXT_NODE;
  3650. }).text().trim();
  3651. }
  3652.  
  3653. function clearPlaceholder(elem) {
  3654. elem.find('.anitracker-placeholder').remove();
  3655. }
  3656.  
  3657. function addPlaceholder(elem, filterType) {
  3658. if (getInputText(elem) !== '' || elem.find('.anitracker-placeholder').length > 0) return;
  3659. $(`<span data-filter-type="${filterType}" class="anitracker-placeholder">${placeholderTexts[filterType]}</span>`).prependTo(elem);
  3660. }
  3661.  
  3662. function setChangesToApply(on) {
  3663. const elem = $('#anitracker-apply-filters');
  3664. if (on) elem.addClass('btn-primary').removeClass('btn-dark');
  3665. else elem.removeClass('btn-primary').addClass('btn-dark');
  3666. }
  3667.  
  3668. function updateApplyButton() {
  3669. setChangesToApply(JSON.stringify(selectedFilters) !== JSON.stringify(appliedFilters));
  3670. }
  3671.  
  3672. function showDropdown(elem, parentElem) {
  3673. for (const type of Object.keys(filterRules)) {
  3674. const elems = getElemsFromFilterType(type);
  3675. if (elems.dropdown === undefined || elems.dropdown.length === 0 || elems.dropdown.hasClass('special')) continue;
  3676. elems.dropdown.hide();
  3677. }
  3678.  
  3679. const top = $(parentElem).position().top + $(parentElem).outerHeight(true);
  3680. const left = $(parentElem).position().left;
  3681. elem.css('top',top).css('left',left);
  3682. elem.show();
  3683. elem.scrollTop(0);
  3684. }
  3685.  
  3686. function checkCloseDropdown(elems) {
  3687. setTimeout(() => {
  3688. if (elems.dropdown.is(':focus,:focus-within') || elems.input.is(':focus')) return;
  3689. elems.dropdown.hide();
  3690. }, 1);
  3691. }
  3692.  
  3693. function fixSelection(elem) {
  3694. const sel = window.getSelection();
  3695. if (!$(sel.anchorNode).is('div')) return;
  3696.  
  3697. setSelection(elem);
  3698. }
  3699.  
  3700. function setSelection(elem) {
  3701. const sel = window.getSelection();
  3702. elem.focus();
  3703.  
  3704. const index = elem.text().length - 1 - elem.find('.anitracker-placeholder').text().length - 1;
  3705. const range = document.createRange();
  3706. range.setStart(elem[0], index > 0 ? index : 0);
  3707. range.collapse(true);
  3708.  
  3709. sel.removeAllRanges();
  3710. sel.addRange(range);
  3711. }
  3712.  
  3713. function scrollToBottom(elem) {
  3714. elem.scrollTop(9999);
  3715. }
  3716.  
  3717. ['genre','theme','type','demographic'].forEach((type) => {
  3718. const elems = getElemsFromFilterType(type);
  3719. addPlaceholder(elems.input, type);
  3720. elems.input.css('width','100%').css('height','100%');
  3721. });
  3722.  
  3723. function getActiveFilter(filter) {
  3724. return selectedFilters.find(f => f.type === filter.type && f.value === filter.value && f.exclude === filter.exclude);
  3725. }
  3726.  
  3727. function refreshIconSymbol(elem) {
  3728. const excluded = elem.data('filter-exclude');
  3729. elem.find('i').remove();
  3730. if (excluded === undefined) return;
  3731. $(`<i class="fa fa-${excluded ? 'minus' : 'plus'}"></i>`).prependTo(elem);
  3732. }
  3733.  
  3734. function setStatusFilter(filter) {
  3735. for (const filter of selectedFilters.filter(f => f.type === 'status')) {
  3736. selectedFilters.splice(selectedFilters.indexOf(filter), 1);
  3737. }
  3738.  
  3739. for (const btn of $('#anitracker-status-dropdown>button')) {
  3740. const elem = $(btn);
  3741. const filterValue = elem.data('filter-value')
  3742. if (filterValue !== filter.value) {
  3743. elem.removeClass('anitracker-active');
  3744. continue;
  3745. }
  3746. $('#anitracker-status-button').text(elem.text());
  3747. if (filterValue !== 'all') elem.addClass('anitracker-active');
  3748. }
  3749.  
  3750. if (filter.value !== 'all') selectedFilters.push(filter);
  3751.  
  3752. if (filter.value === 'all') $('#anitracker-status-button').removeClass('anitracker-active');
  3753. else $('#anitracker-status-button').addClass('anitracker-active');
  3754. }
  3755.  
  3756. function addFilter(filter) {
  3757. if (filter.type === 'season') {
  3758. addSeasonFilter(filter);
  3759. return;
  3760. }
  3761. if (filter.type === 'status') {
  3762. setStatusFilter(filter);
  3763. return;
  3764. }
  3765.  
  3766. const elems = getElemsFromFilterType(filter.type);
  3767. elems.parent?.addClass('active');
  3768. elems.input?.css('width','').css('height','');
  3769. if (elems.input !== undefined) clearPlaceholder(elems.input);
  3770. if (getActiveFilter(filter) !== undefined || filterValues[filter.type] === undefined) return;
  3771. const filterEntry = filterValues[filter.type].find(f => f.value === filter.value);
  3772. const name = (() => {
  3773. if (filter.value === 'none') return '(None)';
  3774. else return filterEntry !== undefined ? filterEntry.name : filter.value;
  3775. })();
  3776. const icon = $(`<span class="anitracker-filter-icon ${filter.exclude ? 'excluded' : 'included'}" data-filter-type="${filter.type}" data-filter-value="${filter.value}" data-filter-exclude="${filter.exclude}">${name}</span>`).appendTo(elems.filterIconContainer);
  3777. refreshIconSymbol(icon);
  3778. icon.on('click', (e) => {
  3779. cycleFilter(getFilterDataFromElem($(e.currentTarget)));
  3780. });
  3781.  
  3782. for (const btn of elems.dropdown.find('button')) {
  3783. const elem = $(btn);
  3784. if (elem.data('filter-value') !== filter.value) continue;
  3785. if (filter.exclude !== undefined) elem.data('filter-exclude', filter.exclude);
  3786.  
  3787. if (filter.exclude) elem.addClass('excluded').removeClass('included');
  3788. else elem.addClass('included').removeClass('excluded');
  3789. }
  3790.  
  3791. if (filter.exclude === undefined) filter.exclude = false;
  3792.  
  3793. selectedFilters.push(filter);
  3794. }
  3795.  
  3796. function removeFilter(filter) {
  3797. const elems = getElemsFromFilterType(filter.type);
  3798. const activeFilter = getActiveFilter(filter);
  3799. if (activeFilter === undefined) return;
  3800.  
  3801. for (const icon of elems.filterIcons) {
  3802. const elem = $(icon);
  3803. if (elem.data('filter-value') !== filter.value) continue;
  3804. elem.remove();
  3805. }
  3806.  
  3807. for (const btn of elems.dropdown.find('button')) {
  3808. const elem = $(btn);
  3809. if (elem.data('filter-value') !== filter.value) continue;
  3810. elem.data('filter-exclude', '');
  3811.  
  3812. elem.removeClass('excluded').removeClass('included');
  3813. }
  3814.  
  3815. selectedFilters.splice(selectedFilters.indexOf(activeFilter), 1);
  3816.  
  3817. // Count remaining filters of the same type
  3818. const remainingFilters = selectedFilters.filter(f => f.type === filter.type);
  3819. if (remainingFilters.length === 0) {
  3820. elems.parent?.removeClass('active');
  3821. elems.input?.css('width','100%').css('height','100%');
  3822. if (elems.input !== undefined) addPlaceholder(elems.input, filter.type);
  3823. }
  3824. }
  3825.  
  3826. // Sets the filter to negative, doesn't actually invert it
  3827. function invertFilter(filter) {
  3828. const elems = getElemsFromFilterType(filter.type);
  3829. const activeFilter = getActiveFilter(filter);
  3830. if (activeFilter === undefined) return;
  3831.  
  3832. for (const icon of elems.filterIcons) {
  3833. const elem = $(icon);
  3834. if (elem.data('filter-value') !== filter.value) continue;
  3835. elem.removeClass('included').addClass('excluded');
  3836. elem.data('filter-exclude', true);
  3837. refreshIconSymbol(elem);
  3838. }
  3839.  
  3840. for (const btn of elems.dropdown.find('button')) {
  3841. const elem = $(btn);
  3842. if (elem.data('filter-value') !== filter.value) continue;
  3843.  
  3844. elem.removeClass('included').addClass('excluded');
  3845. elem.data('filter-exclude', true);
  3846. }
  3847.  
  3848. activeFilter.exclude = true;
  3849. }
  3850.  
  3851. function cycleFilter(filter) {
  3852. if (getActiveFilter(filter) === undefined) addFilter(filter);
  3853. else if (filter.exclude === false) invertFilter(filter);
  3854. else if (filter.exclude === true) removeFilter(filter);
  3855. updateApplyButton();
  3856. }
  3857.  
  3858. function removeSeasonFilters() {
  3859. for (const filter of selectedFilters.filter(f => f.type === 'season')) {
  3860. selectedFilters.splice(selectedFilters.indexOf(filter), 1);
  3861. }
  3862. }
  3863.  
  3864. function addSeasonFilter(filter) {
  3865. $('#anitracker-time-search-button').addClass('anitracker-active');
  3866. timeframeSettings.enabled = true;
  3867. timeframeSettings.inverted = filter.exclude === true;
  3868. timeframeSettings.from = filter.value.from;
  3869. timeframeSettings.to = filter.value.to;
  3870. selectedFilters.push(filter);
  3871. }
  3872.  
  3873. const searchParams = new URLSearchParams(window.location.search);
  3874.  
  3875. function setSearchParam(name, value) {
  3876. if (value === undefined) searchParams.delete(name);
  3877. else searchParams.set(name,value);
  3878. }
  3879.  
  3880. function getSearchParamsString(params) {
  3881. if (Array.from(params.entries()).length === 0) return '';
  3882. return '?' + decodeURIComponent(params.toString());
  3883. }
  3884.  
  3885. function updateSearchParams() {
  3886. window.history.replaceState({}, document.title, "/anime" + getSearchParamsString(searchParams));
  3887. }
  3888.  
  3889. function layoutTabless(entries) { // Tabless = without tabs
  3890. $('.index>').hide();
  3891. $('#anitracker-search-results').remove();
  3892.  
  3893. $(`<div class="row" id="anitracker-search-results"></div>`).prependTo('.index');
  3894.  
  3895. let elements = entries.map(match => {
  3896. return `
  3897. <div class="col-12 col-md-6">
  3898. ${match.html}
  3899. </div>`;
  3900. });
  3901.  
  3902. if (entries.length === 0) elements = `<div class="col-12 col-md-6">No results found.</div>`;
  3903.  
  3904. Array.from($(elements)).forEach(a => {$(a).appendTo('#anitracker-search-results');});
  3905. }
  3906.  
  3907. function layoutAnime(entries) {
  3908. $('#anitracker-filter-result-count>span').text(entries.length);
  3909.  
  3910. const tabs = $('.tab-content>div');
  3911. tabs.find('.col-12').remove();
  3912. $('.nav-link').show();
  3913. $('.index>').show();
  3914. $('#anitracker-search-results').remove();
  3915.  
  3916. const sortedEntries = entries.sort((a,b) => a.name > b.name ? 1 : -1);
  3917. if (entries.length < 100) {
  3918. layoutTabless(sortedEntries);
  3919. $('#anitracker-anime-list-search').trigger('anitracker:search');
  3920. return;
  3921. }
  3922.  
  3923. for (const tab of tabs) {
  3924. const id = $(tab).attr('id');
  3925. const symbol = id.toLowerCase();
  3926. const matchingAnime = (() => {
  3927. if (symbol === 'hash') {
  3928. return sortedEntries.filter(a => /^(?![A-Za-z])./.test(a.name.toLowerCase()));
  3929. }
  3930. else return sortedEntries.filter(a => a.name.toLowerCase().startsWith(symbol));
  3931. })();
  3932. if (matchingAnime.length === 0) {
  3933. $(`.index .nav-link[href="#${id}"]`).hide();
  3934. continue;
  3935. }
  3936.  
  3937. const row = $(tab).find('.row');
  3938. for (const anime of matchingAnime) {
  3939. $(`<div class="col-12 col-md-6">
  3940. ${anime.html}
  3941. </div>`).appendTo(row);
  3942. }
  3943. }
  3944.  
  3945. if (!$('.index .nav-link.active').is(':visible')) {
  3946. $('.index .nav-link:visible:not([href="#hash"])')[0].click();
  3947. }
  3948. $('#anitracker-anime-list-search').trigger('anitracker:search');
  3949. }
  3950.  
  3951. function updateAnimeEntries(entries) {
  3952. animeList.length = 0;
  3953. animeList.push(...entries);
  3954. }
  3955.  
  3956. function setSpinner(coverScreen) {
  3957. const elem = $(`
  3958. <div class="anitracker-filter-spinner anitracker-spinner ${coverScreen ? 'screen' : 'small'}">
  3959. <div class="spinner-border" role="status">
  3960. <span class="sr-only">Loading...</span>
  3961. </div>
  3962. <span>0%</span>
  3963. </div>`);
  3964. if (coverScreen) elem.prependTo(document.body);
  3965. else elem.appendTo('.page-index h1');
  3966. }
  3967.  
  3968. function getSearchParams(filters, rules, inputParams = undefined) {
  3969. const params = inputParams || new URLSearchParams();
  3970. const values = [];
  3971. for (const type of ['genre','theme','type','demographic','status','season']) {
  3972. const foundFilters = filters.filter(f => f.type === type);
  3973. if (foundFilters.length === 0) {
  3974. params.delete(type);
  3975. continue;
  3976. }
  3977.  
  3978. values.push({filters: foundFilters, type: type});
  3979. }
  3980. for (const entry of values) {
  3981. if (entry.type === 'season') {
  3982. const value = entry.filters[0].value;
  3983. params.set('season', (entry.filters[0].exclude ? '!' : '') + `${getSeasonName(value.from.season)}-${value.from.year}..${getSeasonName(value.to.season)}-${value.to.year}`);
  3984. continue;
  3985. }
  3986.  
  3987. params.set(entry.type, entry.filters.map(g => (g.exclude ? '!' : '') + g.value).join(','));
  3988. }
  3989.  
  3990. const existingRules = getRulesListFromParams(params);
  3991. for (const rule of existingRules) {
  3992. params.delete(`rule-${rule.filterType}-${rule.ruleType}`);
  3993. }
  3994. const changedRules = getChangedRulesList(rules);
  3995. if (changedRules.length === 0) return params;
  3996. for (const rule of changedRules) {
  3997. params.set(`rule-${rule.filterType}-${rule.ruleType}`, rule.value);
  3998. }
  3999.  
  4000. return params;
  4001. }
  4002.  
  4003. function searchWithFilters(filters, screenSpinner) {
  4004. if ($('.anitracker-filter-spinner').length > 0) return; // If already searching
  4005. setSpinner(screenSpinner);
  4006.  
  4007. appliedFilters.length = 0;
  4008. appliedFilters.push(...JSON.parse(JSON.stringify(filters)));
  4009.  
  4010. setChangesToApply(false);
  4011.  
  4012. getFilteredList(filters).then(results => {
  4013. updateAnimeEntries(results);
  4014. layoutAnime(results);
  4015. $('.anitracker-filter-spinner').remove();
  4016. getSearchParams(filters, filterRules, searchParams); // Since a reference is passed, this will set the params
  4017. updateSearchParams();
  4018. });
  4019. }
  4020.  
  4021. const searchParamRuleRegex = /^rule\-(\w+)\-(include|exclude|combined)/;
  4022.  
  4023. function getRulesListFromParams(params) {
  4024. const rulesList = [];
  4025. for (const [key, value] of params.entries()) {
  4026. if (!searchParamRuleRegex.test(key) || !['any','or'].includes(value)) continue;
  4027. const parts = searchParamRuleRegex.exec(key);
  4028. if (filterRules[parts[1]] === undefined) continue;
  4029. rulesList.push({
  4030. filterType: parts[1],
  4031. ruleType: parts[2],
  4032. value: value
  4033. });
  4034. }
  4035. return rulesList;
  4036. }
  4037.  
  4038. function applyRulesList(rulesList) {
  4039. for (const rule of rulesList) {
  4040. filterRules[rule.filterType][rule.ruleType] = rule.value;
  4041. }
  4042. }
  4043.  
  4044. function getChangedRulesList(rules, type = undefined) {
  4045. const changed = [];
  4046. for (const [key, value] of Object.entries(rules)) {
  4047. if (type !== undefined && key !== type) continue;
  4048.  
  4049. if (value.include !== filterDefaultRules[key].include) {
  4050. changed.push({filterType: key, ruleType: 'include', value: value.include});
  4051. }
  4052. if (value.exclude !== filterDefaultRules[key].exclude) {
  4053. changed.push({filterType: key, ruleType: 'exclude', value: value.exclude});
  4054. }
  4055. if (![undefined,'and'].includes(value.combined)) {
  4056. changed.push({filterType: key, ruleType: 'combined', value: value.combined});
  4057. }
  4058. }
  4059. return changed;
  4060. }
  4061.  
  4062. function updateRuleButtons() {
  4063. const changedRules = getChangedRulesList(filterRules);
  4064. for (const type of Object.keys(filterRules)) {
  4065. const elems = getElemsFromFilterType(type);
  4066. const btn = elems.filterRuleButton;
  4067. if (btn === undefined || btn.length === 0) continue;
  4068. if (changedRules.find(r => r.filterType === type) === undefined) btn.removeClass('anitracker-active');
  4069. else btn.addClass('anitracker-active');
  4070. }
  4071. }
  4072.  
  4073. // Events
  4074.  
  4075. $('.anitracker-text-input').on('focus', (e) => {
  4076. const elem = $(e.currentTarget);
  4077. const filterType = elem.data('filter-type');
  4078. const elems = getElemsFromFilterType(filterType);
  4079. showDropdown(elems.dropdown, elems.parent);
  4080. clearPlaceholder(elems.input);
  4081. elem.css('width','').css('height','');
  4082. scrollToBottom(elems.scrollingDiv);
  4083. })
  4084. .on('blur', (e) => {
  4085. const elem = $(e.currentTarget);
  4086. const filterType = elem.data('filter-type');
  4087. const elems = getElemsFromFilterType(filterType);
  4088. checkCloseDropdown(elems);
  4089. if (elems.filterIcons.length === 0) {
  4090. addPlaceholder(elems.input, filterType);
  4091. elem.css('width','100%').css('height','100%');
  4092. }
  4093. })
  4094. .on('keydown', (e) => {
  4095. const elem = $(e.currentTarget);
  4096. const filterType = elem.data('filter-type');
  4097. const elems = getElemsFromFilterType(filterType);
  4098.  
  4099. if (e.key === 'Escape') {
  4100. elem.blur();
  4101. return;
  4102. }
  4103. if (e.key === 'ArrowDown') {
  4104. e.preventDefault();
  4105. elems.dropdown.find('button:visible')[0]?.focus();
  4106. return;
  4107. }
  4108. const filterIcons = elems.filterIcons;
  4109. if (e.key === 'Backspace' && getInputText(elem) === '' && filterIcons.length > 0) {
  4110. removeFilter(getFilterDataFromElem($(filterIcons[filterIcons.length - 1])));
  4111. updateApplyButton();
  4112. }
  4113.  
  4114. setTimeout(() => {
  4115. const text = getInputText(elem).toLowerCase();
  4116.  
  4117. for (const btn of elems.dropdown.find('button')) {
  4118. const jqbtn = $(btn);
  4119. if (jqbtn.text().toLowerCase().includes(text)) {
  4120. jqbtn.show();
  4121. continue;
  4122. }
  4123. jqbtn.hide();
  4124. }
  4125. }, 1);
  4126. }).on('click', (e) => {
  4127. fixSelection($(e.currentTarget));
  4128. });
  4129.  
  4130. $('.anitracker-filter-dropdown:not(.special)>button').on('blur', (e) => {
  4131. const elem = $(e.currentTarget);
  4132. const filterType = elem.data('filter-type');
  4133. checkCloseDropdown(getElemsFromFilterType(filterType));
  4134. }).on('click', (e) => {
  4135. const elem = $(e.currentTarget);
  4136. const filter = getFilterDataFromElem(elem);
  4137. cycleFilter(filter);
  4138.  
  4139. const elems = getElemsFromFilterType(elem.data('filter-type'));
  4140. elems.input?.text('').keydown().blur();
  4141. scrollToBottom(elems.scrollingDiv);
  4142. });
  4143.  
  4144. $('.anitracker-filter-dropdown>button').on('keydown', (e) => {
  4145. const elem = $(e.currentTarget);
  4146. const filterType = elem.data('filter-type');
  4147. const elems = getElemsFromFilterType(filterType);
  4148.  
  4149. if (e.key === 'Escape') {
  4150. elem.blur();
  4151. return;
  4152. }
  4153.  
  4154. const direction = {
  4155. ArrowUp: -1,
  4156. ArrowDown: 1
  4157. }[e.key];
  4158. if (direction === undefined) return;
  4159.  
  4160. const activeButtons = elems.dropdown.find('button:visible');
  4161. let activeIndex = 0;
  4162. for (let i = 0; i < activeButtons.length; i++) {
  4163. const btn = activeButtons[i];
  4164. if (!$(btn).is(':focus')) continue;
  4165. activeIndex = i;
  4166. break;
  4167. }
  4168. const nextIndex = activeIndex + direction;
  4169. if (activeButtons[nextIndex] !== undefined) {
  4170. activeButtons[nextIndex].focus();
  4171. return;
  4172. }
  4173. if (direction === -1 && activeIndex === 0) {
  4174. elems.input?.focus();
  4175. return;
  4176. }
  4177. });
  4178.  
  4179. $('.anitracker-filter-input').on('click', (e) => {
  4180. const elem = $(e.target);
  4181. if (!elem.is('.anitracker-filter-input,.anitracker-applied-filters,.anitracker-filter-input>div')) return;
  4182.  
  4183. const filterType = $(e.currentTarget).data('filter-type');
  4184. const elems = getElemsFromFilterType(filterType);
  4185. setSelection(elems.input);
  4186. });
  4187.  
  4188. $('#anitracker-status-button').on('keydown', (e) => {
  4189. if (e.key !== 'ArrowDown') return;
  4190. const elems = getElemsFromFilterType('status');
  4191. elems.dropdown.find('button')[0]?.focus();
  4192. });
  4193.  
  4194. $('#anitracker-status-dropdown>button').on('click', (e) => {
  4195. const elem = $(e.currentTarget);
  4196. const filter = getFilterDataFromElem(elem);
  4197. addFilter(filter);
  4198. updateApplyButton();
  4199. });
  4200.  
  4201. $('#anitracker-apply-filters').on('click', () => {
  4202. searchWithFilters(selectedFilters, false);
  4203. });
  4204.  
  4205. $('#anitracker-reset-filters').on('click keyup', (e) => {
  4206. if (e.type === 'keyup' && e.key !== "Enter") return;
  4207. window.location.replace(window.location.origin + window.location.pathname);
  4208. });
  4209.  
  4210. $('.anitracker-filter-rules').on('click', (e) => {
  4211. const elem1 = $(e.currentTarget);
  4212. const filterType = elem1.data('filter-type');
  4213.  
  4214. const disableInclude = ['type','demographic'].includes(filterType) ? 'disabled' : '';
  4215.  
  4216. $('#anitracker-modal-body').empty();
  4217.  
  4218. $(`
  4219. <p>Rules for ${filterType} filters</p>
  4220. <div class="anitracker-filter-rule-selection" ${disableInclude} data-rule-type="include" style="background-color: #485057;">
  4221. <i class="fa fa-plus" aria-hidden="true"></i>
  4222. <span>Include:</span>
  4223. <div class="btn-group"><button ${disableInclude} title="Select this rule type">and</button><button ${disableInclude} title="Select this rule type">or</button></div>
  4224. </div>
  4225. <div class="anitracker-filter-rule-selection" data-rule-type="combined" style="display: flex;justify-content: center;">
  4226. <span>-</span>
  4227. <div class="btn-group"><button title="Select this rule type">and</button><button title="Select this rule type">or</button></div>
  4228. <span>-</span>
  4229. </div>
  4230. <div class="anitracker-filter-rule-selection" data-rule-type="exclude" style="background-color: #485057;">
  4231. <i class="fa fa-minus" aria-hidden="true"></i>
  4232. <span>Exclude:</span>
  4233. <div class="btn-group"><button title="Select this rule type">and</button><button title="Select this rule type">or</button></div>
  4234. </div>
  4235. <div style="display: flex;justify-content: center; margin-top: 10px;"><button class="btn btn-secondary anitracker-flat-button" id="anitracker-reset-filter-rules" title="Reset to defaults">Reset</button></div>
  4236. `).appendTo('#anitracker-modal-body');
  4237.  
  4238. function refreshBtnStates() {
  4239. const rules = filterRules[filterType];
  4240. for (const selec of $('.anitracker-filter-rule-selection')) {
  4241. const ruleType = $(selec).data('rule-type');
  4242. const rule = rules[ruleType];
  4243.  
  4244. const btns = $(selec).find('button').removeClass('anitracker-active');
  4245. if (rule === 'or') $(btns[1]).addClass('anitracker-active');
  4246. else $(btns[0]).addClass('anitracker-active');
  4247. }
  4248. }
  4249.  
  4250. $('.anitracker-filter-rule-selection button').on('click', (e) => {
  4251. const elem = $(e.currentTarget);
  4252. const ruleType = elem.parents().eq(1).data('rule-type');
  4253. const text = elem.text();
  4254. if (!['and','or'].includes(text)) return;
  4255.  
  4256. filterRules[filterType][ruleType] = text;
  4257.  
  4258. elem.parent().find('button').removeClass('anitracker-active');
  4259. elem.addClass('anitracker-active');
  4260. updateRuleButtons();
  4261. });
  4262.  
  4263. $('#anitracker-reset-filter-rules').on('click', () => {
  4264. filterRules[filterType] = JSON.parse(JSON.stringify(filterDefaultRules[filterType]));
  4265. refreshBtnStates();
  4266. updateRuleButtons();
  4267. });
  4268.  
  4269. refreshBtnStates();
  4270.  
  4271. openModal();
  4272. });
  4273.  
  4274. $('#anitracker-time-search-button').on('click', () => {
  4275. $('#anitracker-modal-body').empty();
  4276.  
  4277. $(`
  4278. <h5>Time interval</h5>
  4279. <div class="custom-control custom-switch">
  4280. <input type="checkbox" class="custom-control-input" id="anitracker-settings-enable-switch">
  4281. <label class="custom-control-label" for="anitracker-settings-enable-switch" title="Enable timeframe settings">Enable</label>
  4282. </div>
  4283. <div class="custom-control custom-switch">
  4284. <input type="checkbox" class="custom-control-input" id="anitracker-settings-invert-switch" disabled>
  4285. <label class="custom-control-label" for="anitracker-settings-invert-switch" title="Invert time range">Invert</label>
  4286. </div>
  4287. <br>
  4288. <div class="anitracker-season-group" id="anitracker-season-from">
  4289. <span>From:</span>
  4290. <div class="btn-group">
  4291. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-year-input" disabled placeholder="Year" type="number">
  4292. </div>
  4293. <div class="btn-group">
  4294. <button class="btn dropdown-toggle btn-secondary anitracker-season-dropdown-button" disabled data-bs-toggle="dropdown" data-toggle="dropdown" data-value="Spring">Spring</button>
  4295. <button class="btn btn-secondary" id="anitracker-season-copy-to-lower" title="Copy the 'from' season to the 'to' season">
  4296. <i class="fa fa-arrow-circle-down" aria-hidden="true"></i>
  4297. </button>
  4298. </div>
  4299. </div>
  4300. <div class="anitracker-season-group" id="anitracker-season-to">
  4301. <span>To:</span>
  4302. <div class="btn-group">
  4303. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-year-input" disabled placeholder="Year" type="number">
  4304. </div>
  4305. <div class="btn-group">
  4306. <button class="btn dropdown-toggle btn-secondary anitracker-season-dropdown-button" disabled data-bs-toggle="dropdown" data-toggle="dropdown" data-value="Spring">Spring</button>
  4307. </div>
  4308. </div>
  4309. <br>
  4310. <div>
  4311. <div class="btn-group">
  4312. <button class="btn btn-primary" id="anitracker-modal-confirm-button">Save</button>
  4313. </div>
  4314. </div>`).appendTo('#anitracker-modal-body');
  4315.  
  4316. $('.anitracker-year-input').val(new Date().getFullYear());
  4317.  
  4318. $('#anitracker-settings-enable-switch').on('change', () => {
  4319. const enabled = $('#anitracker-settings-enable-switch').is(':checked');
  4320. $('.anitracker-season-group').find('input,button').prop('disabled', !enabled);
  4321. $('#anitracker-settings-invert-switch').prop('disabled', !enabled);
  4322. }).prop('checked', timeframeSettings.enabled).change();
  4323.  
  4324. $('#anitracker-settings-invert-switch').prop('checked', timeframeSettings.inverted);
  4325.  
  4326. $('#anitracker-season-copy-to-lower').on('click', () => {
  4327. const seasonName = $('#anitracker-season-from .anitracker-season-dropdown-button').data('value');
  4328. $('#anitracker-season-to .anitracker-year-input').val($('#anitracker-season-from .anitracker-year-input').val());
  4329. $('#anitracker-season-to .anitracker-season-dropdown-button').data('value', seasonName);
  4330. $('#anitracker-season-to .anitracker-season-dropdown-button').text(seasonName);
  4331. });
  4332.  
  4333. $(`<div class="dropdown-menu anitracker-dropdown-content anitracker-season-dropdown">`).insertAfter('.anitracker-season-dropdown-button');
  4334. ['Winter','Spring','Summer','Fall'].forEach(g => { $(`<button ref="${g.toLowerCase()}">${g}</button>`).appendTo('.anitracker-season-dropdown') });
  4335.  
  4336. $('.anitracker-season-dropdown button').on('click', (e) => {
  4337. const pressed = $(e.target)
  4338. const btn = pressed.parents().eq(1).find('.anitracker-season-dropdown-button');
  4339. btn.data('value', pressed.text());
  4340. btn.text(pressed.text());
  4341. });
  4342.  
  4343. const currentSeason = getCurrentSeason();
  4344. if (timeframeSettings.from) {
  4345. $('#anitracker-season-from .anitracker-year-input').val(timeframeSettings.from.year.toString());
  4346. $('#anitracker-season-from .anitracker-season-dropdown button')[timeframeSettings.from.season].click();
  4347. }
  4348. else $('#anitracker-season-from .anitracker-season-dropdown button')[currentSeason].click();
  4349.  
  4350. if (timeframeSettings.to) {
  4351. $('#anitracker-season-to .anitracker-year-input').val(timeframeSettings.to.year.toString());
  4352. $('#anitracker-season-to .anitracker-season-dropdown button')[timeframeSettings.to.season].click();
  4353. }
  4354. else $('#anitracker-season-to .anitracker-season-dropdown button')[currentSeason].click();
  4355.  
  4356. $('#anitracker-modal-confirm-button').on('click', () => {
  4357. const enabled = $('#anitracker-settings-enable-switch').is(':checked');
  4358. const inverted = $('#anitracker-settings-invert-switch').is(':checked');
  4359. const from = {
  4360. year: +$('#anitracker-season-from .anitracker-year-input').val(),
  4361. season: getSeasonValue($('#anitracker-season-from').find('.anitracker-season-dropdown-button').data('value'))
  4362. }
  4363. const to = {
  4364. year: +$('#anitracker-season-to .anitracker-year-input').val(),
  4365. season: getSeasonValue($('#anitracker-season-to').find('.anitracker-season-dropdown-button').data('value'))
  4366. }
  4367. if (enabled) {
  4368. for (const input of $('.anitracker-year-input')) {
  4369. if (/^\d{4}$/.test($(input).val())) continue;
  4370. alert('[AnimePahe Improvements]\n\nYear values must both be 4 numbers.');
  4371. return;
  4372. }
  4373. if (to.year < from.year || (to.year === from.year && to.season < from.season)) {
  4374. alert('[AnimePahe Improvements]\n\nSeason times must be from oldest to newest.' + (to.season === 0 ? '\n(Winter is the first quarter of the year)' : ''));
  4375. return;
  4376. }
  4377. if (to.year - from.year > 100) {
  4378. alert('[AnimePahe Improvements]\n\nYear interval cannot be more than 100 years.');
  4379. return;
  4380. }
  4381. removeSeasonFilters(); // Put here so it doesn't remove existing filters if input is invalid
  4382. addFilter({
  4383. type: 'season',
  4384. value: {
  4385. from: from,
  4386. to: to
  4387. },
  4388. exclude: inverted
  4389. });
  4390. }
  4391. else {
  4392. removeSeasonFilters();
  4393. $('#anitracker-time-search-button').removeClass('anitracker-active');
  4394. }
  4395. updateApplyButton();
  4396. timeframeSettings.enabled = enabled;
  4397. timeframeSettings.inverted = inverted;
  4398. closeModal();
  4399. });
  4400.  
  4401. openModal();
  4402. });
  4403.  
  4404. $('#anitracker-random-anime').on('click', function(e) {
  4405. const elem = $(e.currentTarget);
  4406.  
  4407. elem.find('i').removeClass('fa-random').addClass('fa-refresh').css('animation', 'anitracker-spin 1s linear infinite');
  4408.  
  4409. getFilteredList(selectedFilters).then(results => {
  4410. elem.find('i').removeClass('fa-refresh').addClass('fa-random').css('animation', '');
  4411.  
  4412. const storage = getStorage();
  4413. storage.cache = filterSearchCache;
  4414. saveData(storage);
  4415.  
  4416. const params = getSearchParams(selectedFilters, filterRules);
  4417. params.set('anitracker-random', '1');
  4418.  
  4419. getRandomAnime(results, getSearchParamsString(params));
  4420. });
  4421. });
  4422.  
  4423. $.getScript('https://cdn.jsdelivr.net/npm/fuse.js@7.0.0', function() {
  4424. let typingTimer;
  4425. const elem = $('#anitracker-anime-list-search');
  4426. elem.prop('disabled', false).attr('placeholder', 'Search');
  4427.  
  4428. elem.on('anitracker:search', function() {
  4429. if ($(this).val() !== '') animeListSearch();
  4430. })
  4431. .on('keyup', function() {
  4432. clearTimeout(typingTimer);
  4433. typingTimer = setTimeout(animeListSearch, 150);
  4434. })
  4435. .on('keydown', function() {
  4436. clearTimeout(typingTimer);
  4437. });
  4438.  
  4439. function animeListSearch() {
  4440. const value = elem.val();
  4441. if (value === '') {
  4442. layoutAnime(JSON.parse(JSON.stringify(animeList)));
  4443. searchParams.delete('search');
  4444. }
  4445. else {
  4446. const matches = searchList(Fuse, animeList, value);
  4447.  
  4448. layoutTabless(matches);
  4449. searchParams.set('search', encodeURIComponent(value));
  4450. }
  4451. updateSearchParams();
  4452. }
  4453.  
  4454. const loadedParams = new URLSearchParams(window.location.search);
  4455. if (loadedParams.has('search')) {
  4456. elem.val(decodeURIComponent(loadedParams.get('search')));
  4457. animeListSearch();
  4458. }
  4459. }).fail(() => {
  4460. console.error("[AnimePahe Improvements] Fuse.js failed to load");
  4461. });
  4462.  
  4463. // From parameters
  4464. const paramRules = getRulesListFromParams(searchParams);
  4465. applyRulesList(paramRules);
  4466. updateRuleButtons();
  4467. const paramFilters = getFiltersFromParams(searchParams);
  4468. if (paramFilters.length === 0) return;
  4469. for (const filter of paramFilters) {
  4470. addFilter(filter);
  4471. }
  4472. searchWithFilters(selectedFilters, true);
  4473. }
  4474.  
  4475. // Search/index page
  4476. if (/^\/anime\/?$/.test(window.location.pathname)) {
  4477. loadIndexPage();
  4478. return;
  4479. }
  4480.  
  4481. function getAnimeList(page = $(document)) {
  4482. const animeList = [];
  4483.  
  4484. for (const anime of page.find('.col-12')) {
  4485. if (anime.children[0] === undefined || $(anime).hasClass('anitracker-filter-result') || $(anime).parent().attr('id') !== undefined) continue;
  4486. animeList.push({
  4487. name: $(anime.children[0]).text(),
  4488. link: anime.children[0].href,
  4489. html: $(anime).html()
  4490. });
  4491. }
  4492.  
  4493. return animeList;
  4494. }
  4495.  
  4496. function randint(min, max) { // min and max included
  4497. return Math.floor(Math.random() * (max - min + 1) + min);
  4498. }
  4499.  
  4500. function isEpisode(url = window.location.toString()) {
  4501. return url.includes('/play/');
  4502. }
  4503.  
  4504. function isAnime(url = window.location.pathname) {
  4505. return /^\/anime\/[\d\w\-]+$/.test(url);
  4506. }
  4507.  
  4508. function download(filename, text) {
  4509. const element = document.createElement('a');
  4510. element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
  4511. element.setAttribute('download', filename);
  4512.  
  4513. element.style.display = 'none';
  4514. document.body.appendChild(element);
  4515.  
  4516. element.click();
  4517.  
  4518. document.body.removeChild(element);
  4519. }
  4520.  
  4521. function deleteEpisodesFromTracker(exclude, nameInput, id = undefined) {
  4522. const storage = getStorage();
  4523. const animeName = nameInput || getAnimeName();
  4524. const linkData = getStoredLinkData(storage);
  4525.  
  4526. storage.linkList = (() => {
  4527. if (id !== undefined) {
  4528. const found = storage.linkList.filter(g => g.type === 'episode' && g.animeId === id && g.episodeNum !== exclude);
  4529. if (found.length > 0) return storage.linkList.filter(g => !(g.type === 'episode' && g.animeId === id && g.episodeNum !== exclude));
  4530. }
  4531.  
  4532. return storage.linkList.filter(g => !(g.type === 'episode' && g.animeName === animeName && g.episodeNum !== exclude));
  4533. })();
  4534.  
  4535. storage.videoTimes = (() => {
  4536. if (id !== undefined) {
  4537. const found = storage.videoTimes.filter(g => g.animeId === id && g.episodeNum !== exclude);
  4538. if (found.length > 0) return storage.videoTimes.filter(g => !(g.animeId === id && g.episodeNum !== exclude));
  4539. }
  4540.  
  4541. return storage.videoTimes.filter(g => !(g.episodeNum !== exclude && stringSimilarity(g.animeName, animeName) > 0.81));
  4542. })();
  4543.  
  4544. if (exclude === undefined && id !== undefined) {
  4545. storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== id);
  4546. }
  4547.  
  4548. saveData(storage);
  4549. }
  4550.  
  4551. function deleteEpisodeFromTracker(animeName, episodeNum, animeId = undefined) {
  4552. const storage = getStorage();
  4553.  
  4554. storage.linkList = (() => {
  4555. if (animeId !== undefined) {
  4556. const found = storage.linkList.find(g => g.type === 'episode' && g.animeId === animeId && g.episodeNum === episodeNum);
  4557. if (found !== undefined) return storage.linkList.filter(g => !(g.type === 'episode' && g.animeId === animeId && g.episodeNum === episodeNum));
  4558. }
  4559.  
  4560. return storage.linkList.filter(g => !(g.type === 'episode' && g.animeName === animeName && g.episodeNum === episodeNum));
  4561. })();
  4562.  
  4563. storage.videoTimes = (() => {
  4564. if (animeId !== undefined) {
  4565. const found = storage.videoTimes.find(g => g.animeId === animeId && g.episodeNum === episodeNum);
  4566. if (found !== undefined) return storage.videoTimes.filter(g => !(g.animeId === animeId && g.episodeNum === episodeNum));
  4567. }
  4568.  
  4569. return storage.videoTimes.filter(g => !(g.episodeNum === episodeNum && stringSimilarity(g.animeName, animeName) > 0.81));
  4570. })();
  4571.  
  4572. if (animeId !== undefined) {
  4573. const episodesRemain = storage.videoTimes.find(g => g.animeId === animeId) !== undefined;
  4574. if (!episodesRemain) {
  4575. storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== animeId);
  4576. }
  4577. }
  4578.  
  4579. saveData(storage);
  4580. }
  4581.  
  4582. function getStoredLinkData(storage) {
  4583. if (isEpisode()) {
  4584. return storage.linkList.find(a => a.type == 'episode' && a.animeSession == animeSession && a.episodeSession == episodeSession);
  4585. }
  4586. return storage.linkList.find(a => a.type == 'anime' && a.animeSession == animeSession);
  4587. }
  4588.  
  4589. function getAnimeName() {
  4590. return isEpisode() ? /Watch (.*) - ([\d\.]+)(?:\-[\d\.]+)? Online/.exec($('.theatre-info h1').text())[1] : $($('.title-wrapper h1 span')[0]).text();
  4591. }
  4592.  
  4593. function getEpisodeNum() {
  4594. if (isEpisode()) return +(/Watch (.*) - ([\d\.]+)(?:\-[\d\.]+)? Online/.exec($('.theatre-info h1').text())[2]);
  4595. else return 0;
  4596. }
  4597.  
  4598. function sortAnimesChronologically(animeList) {
  4599. // Animes (plural)
  4600. animeList.sort((a, b) => {return getSeasonValue(a.season) > getSeasonValue(b.season) ? 1 : -1});
  4601. animeList.sort((a, b) => {return a.year > b.year ? 1 : -1});
  4602.  
  4603. return animeList;
  4604. }
  4605.  
  4606. function asyncGetResponseData(qurl) {
  4607. return new Promise((resolve, reject) => {
  4608. let req = new XMLHttpRequest();
  4609. req.open('GET', qurl, true);
  4610. req.onload = () => {
  4611. if (req.status === 200) {
  4612. resolve(JSON.parse(req.response).data);
  4613. return;
  4614. }
  4615.  
  4616. reject(undefined);
  4617. };
  4618. try {
  4619. req.send();
  4620. }
  4621. catch (err) {
  4622. console.error(err);
  4623. resolve(undefined);
  4624. }
  4625. });
  4626. }
  4627.  
  4628. function getResponseData(qurl) {
  4629. let req = new XMLHttpRequest();
  4630. req.open('GET', qurl, false);
  4631. try {
  4632. req.send();
  4633. }
  4634. catch (err) {
  4635. console.error(err);
  4636. return(undefined);
  4637. }
  4638.  
  4639. if (req.status === 200) {
  4640. return(JSON.parse(req.response).data);
  4641. }
  4642.  
  4643. return(undefined);
  4644. }
  4645.  
  4646. function getAnimeSessionFromUrl(url = window.location.toString()) {
  4647. return new RegExp('^(.*animepahe\.[a-z]+)?/(play|anime)/([^/?#]+)').exec(url)[3];
  4648. }
  4649.  
  4650. function getEpisodeSessionFromUrl(url = window.location.toString()) {
  4651. return new RegExp('^(.*animepahe\.[a-z]+)?/(play|anime)/([^/]+)/([^/?#]+)').exec(url)[4];
  4652. }
  4653.  
  4654. function makeSearchable(string) {
  4655. return encodeURIComponent(string.replace(' -',' '));
  4656. }
  4657.  
  4658. function getAnimeData(name = getAnimeName(), id = undefined, guess = false) {
  4659. const cached = (() => {
  4660. if (id !== undefined) return cachedAnimeData.find(a => a.id === id);
  4661. else return cachedAnimeData.find(a => a.title === name);
  4662. })();
  4663. if (cached !== undefined) {
  4664. return cached;
  4665. }
  4666.  
  4667. if (name.length === 0) return undefined;
  4668. const response = getResponseData('/api?m=search&q=' + makeSearchable(name));
  4669.  
  4670. if (response === undefined) return response;
  4671.  
  4672. for (const anime of response) {
  4673. if (id === undefined && anime.title === name) {
  4674. cachedAnimeData.push(anime);
  4675. return anime;
  4676. }
  4677. if (id !== undefined && anime.id === id) {
  4678. cachedAnimeData.push(anime);
  4679. return anime;
  4680. }
  4681. }
  4682.  
  4683. if (guess && response.length > 0) {
  4684. cachedAnimeData.push(response[0]);
  4685. return response[0];
  4686. }
  4687.  
  4688. return undefined;
  4689. }
  4690.  
  4691. async function asyncGetAnimeData(name = getAnimeName(), id) {
  4692. const cached = cachedAnimeData.find(a => a.id === id);
  4693. const response = cached === undefined ? await getResponseData('/api?m=search&q=' + makeSearchable(name)) : undefined;
  4694. return new Promise((resolve, reject) => {
  4695. if (cached !== undefined) {
  4696. resolve(cached);
  4697. return;
  4698. }
  4699.  
  4700. if (response === undefined) resolve(response);
  4701.  
  4702. for (const anime of response) {
  4703. if (anime.id === id) {
  4704. cachedAnimeData.push(anime);
  4705. resolve(anime);
  4706. }
  4707. }
  4708. reject(`Anime "${name}" not found`);
  4709. });
  4710. }
  4711.  
  4712. // For general animepahe pages that are not episode or anime pages
  4713. if (!url.includes('/play/') && !url.includes('/anime/') && !/anime[\/#]?[^\/]*([\?&][^=]+=[^\?^&])*$/.test(url)) {
  4714. $(`
  4715. <div id="anitracker">
  4716. </div>`).insertAfter('.notification-release');
  4717.  
  4718. addGeneralButtons();
  4719. updateSwitches();
  4720.  
  4721. return;
  4722. }
  4723.  
  4724. let animeSession = getAnimeSessionFromUrl();
  4725. let episodeSession = '';
  4726. if (isEpisode()) {
  4727. episodeSession = getEpisodeSessionFromUrl();
  4728. }
  4729.  
  4730. function getEpisodeSession(aSession, episodeNum) {
  4731. const request = new XMLHttpRequest();
  4732. request.open('GET', '/api?m=release&id=' + aSession, false);
  4733. request.send();
  4734.  
  4735. if (request.status !== 200) return undefined;
  4736.  
  4737. const response = JSON.parse(request.response);
  4738.  
  4739. return (() => {
  4740. for (let i = 1; i <= response.last_page; i++) {
  4741. const episodes = getResponseData(`/api?m=release&sort=episode_asc&page=${i}&id=${aSession}`);
  4742. if (episodes === undefined) return undefined;
  4743. const episode = episodes.find(a => a.episode === episodeNum);
  4744. if (episode === undefined) continue;
  4745. return episode.session;
  4746. }
  4747. return undefined;
  4748. })();
  4749. }
  4750.  
  4751. function refreshSession(from404 = false) {
  4752. /* Return codes:
  4753. * 0: ok!
  4754. * 1: couldn't find stored session at 404 page
  4755. * 2: couldn't get anime data
  4756. * 3: couldn't get episode session
  4757. * 4: idk
  4758. */
  4759.  
  4760. const storage = getStorage();
  4761. const bobj = getStoredLinkData(storage);
  4762.  
  4763. let name = '';
  4764. let episodeNum = 0;
  4765.  
  4766. if (bobj === undefined && from404) return 1;
  4767.  
  4768. if (bobj !== undefined) {
  4769. name = bobj.animeName;
  4770. episodeNum = bobj.episodeNum;
  4771. }
  4772. else {
  4773. name = getAnimeName();
  4774. episodeNum = getEpisodeNum();
  4775. }
  4776.  
  4777. if (isEpisode()) {
  4778. const animeData = getAnimeData(name, bobj?.animeId, true);
  4779.  
  4780. if (animeData === undefined) return 2;
  4781.  
  4782. if (bobj?.animeId === undefined && animeData.title !== name && !refreshGuessWarning(name, animeData.title)) {
  4783. return 2;
  4784. }
  4785.  
  4786. const episodeSession = getEpisodeSession(animeData.session, episodeNum);
  4787.  
  4788. if (episodeSession === undefined) return 3;
  4789.  
  4790. if (bobj !== undefined) {
  4791. storage.linkList = storage.linkList.filter(g => !(g.type === 'episode' && g.animeSession === bobj.animeSession && g.episodeSession === bobj.episodeSession));
  4792. }
  4793.  
  4794. saveData(storage);
  4795.  
  4796. window.location.replace('/play/' + animeData.session + '/' + episodeSession + window.location.search);
  4797.  
  4798. return 0;
  4799. }
  4800. else if (bobj !== undefined && bobj.animeId !== undefined) {
  4801. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === bobj.animeSession));
  4802.  
  4803. saveData(storage);
  4804.  
  4805. window.location.replace('/a/' + bobj.animeId);
  4806. return 0;
  4807. }
  4808. else {
  4809. if (bobj !== undefined) {
  4810. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === bobj.animeSession));
  4811. saveData(storage);
  4812. }
  4813.  
  4814. let animeData = getAnimeData(name, undefined, true);
  4815.  
  4816. if (animeData === undefined) return 2;
  4817.  
  4818. if (animeData.title !== name && !refreshGuessWarning(name, animeData.title)) {
  4819. return 2;
  4820. }
  4821.  
  4822. window.location.replace('/a/' + animeData.id);
  4823. return 0;
  4824. }
  4825.  
  4826. return 4;
  4827. }
  4828.  
  4829. function refreshGuessWarning(name, title) {
  4830. return confirm(`[AnimePahe Improvements]\n\nAn exact match with the anime name "${name}" couldn't be found. Go to "${title}" instead?`);
  4831. }
  4832.  
  4833. const obj = getStoredLinkData(initialStorage);
  4834.  
  4835. if (isEpisode() && !is404) {
  4836. theatreMode(initialStorage.settings.theatreMode);
  4837. $('#downloadMenu').changeElementType('button');
  4838. }
  4839.  
  4840. console.log('[AnimePahe Improvements]', obj, animeSession, episodeSession);
  4841.  
  4842. function setSessionData() {
  4843. const animeName = getAnimeName();
  4844.  
  4845. const storage = getStorage();
  4846. if (isEpisode()) {
  4847. storage.linkList.push({
  4848. animeId: getAnimeData(animeName)?.id,
  4849. animeSession: animeSession,
  4850. episodeSession: episodeSession,
  4851. type: 'episode',
  4852. animeName: animeName,
  4853. episodeNum: getEpisodeNum()
  4854. });
  4855. }
  4856. else {
  4857. storage.linkList.push({
  4858. animeId: getAnimeData(animeName)?.id,
  4859. animeSession: animeSession,
  4860. type: 'anime',
  4861. animeName: animeName
  4862. });
  4863. }
  4864. if (storage.linkList.length > 1000) {
  4865. storage.linkList.splice(0,1);
  4866. }
  4867.  
  4868. saveData(storage);
  4869. }
  4870.  
  4871. if (obj === undefined && !is404) {
  4872. if (!isRandomAnime()) setSessionData();
  4873. }
  4874. else if (obj !== undefined && is404) {
  4875. document.title = "Refreshing session... :: animepahe";
  4876. $('.text-center h1').text('Refreshing session, please wait...');
  4877. const code = refreshSession(true);
  4878. if (code === 1) {
  4879. $('.text-center h1').text('Couldn\'t refresh session: Link not found in tracker');
  4880. }
  4881. else if (code === 2) {
  4882. $('.text-center h1').text('Couldn\'t refresh session: Couldn\'t get anime data');
  4883. }
  4884. else if (code === 3) {
  4885. $('.text-center h1').text('Couldn\'t refresh session: Couldn\'t get episode data');
  4886. }
  4887. else if (code !== 0) {
  4888. $('.text-center h1').text('Couldn\'t refresh session: An unknown error occured');
  4889. }
  4890.  
  4891. if ([2,3].includes(code)) {
  4892. if (obj.episodeNum !== undefined) {
  4893. $(`<h3>
  4894. Try finding the episode using the following info:
  4895. <br>Anime name: ${obj.animeName}
  4896. <br>Episode: ${obj.episodeNum}
  4897. </h3>`).insertAfter('.text-center h1');
  4898. }
  4899. else {
  4900. $(`<h3>
  4901. Try finding the anime using the following info:
  4902. <br>Anime name: ${obj.animeName}
  4903. </h3>`).insertAfter('.text-center h1');
  4904. }
  4905. }
  4906. return;
  4907. }
  4908. else if (obj === undefined && is404) {
  4909. if (document.referrer.length > 0) {
  4910. const bobj = (() => {
  4911. if (!/\/play\/.+/.test(document.referrer) && !/\/anime\/.+/.test(document.referrer)) {
  4912. return true;
  4913. }
  4914. const session = getAnimeSessionFromUrl(document.referrer);
  4915. if (isEpisode(document.referrer)) {
  4916. return initialStorage.linkList.find(a => a.type === 'episode' && a.animeSession === session && a.episodeSession === getEpisodeSessionFromUrl(document.referrer));
  4917. }
  4918. else {
  4919. return initialStorage.linkList.find(a => a.type === 'anime' && a.animeSession === session);
  4920. }
  4921. })();
  4922. if (bobj !== undefined) {
  4923. const prevUrl = new URL(document.referrer);
  4924. const params = new URLSearchParams(prevUrl);
  4925. params.set('ref','404');
  4926. prevUrl.search = params.toString();
  4927. windowOpen(prevUrl.toString(), '_self');
  4928. return;
  4929. }
  4930. }
  4931. $('.text-center h1').text('Cannot refresh session: Link not stored in tracker');
  4932. return;
  4933. }
  4934.  
  4935. function getSubInfo(str) {
  4936. const match = /^\b([^·]+)·\s*(\d{2,4})p(.*)$/.exec(str);
  4937. return {
  4938. name: match[1],
  4939. quality: +match[2],
  4940. other: match[3]
  4941. };
  4942. }
  4943.  
  4944. // Set the quality to best automatically
  4945. function bestVideoQuality() {
  4946. if (!isEpisode()) return;
  4947.  
  4948. const currentSub = getStoredLinkData(getStorage()).subInfo || getSubInfo($('#resolutionMenu .active').text());
  4949.  
  4950. let index = -1;
  4951. for (let i = 0; i < $('#resolutionMenu').children().length; i++) {
  4952. const sub = $('#resolutionMenu').children()[i];
  4953. const subInfo = getSubInfo($(sub).text());
  4954. if (subInfo.name !== currentSub.name || subInfo.other !== currentSub.other) continue;
  4955.  
  4956. if (subInfo.quality >= currentSub.quality) index = i;
  4957. }
  4958.  
  4959. if (index === -1) {
  4960. return;
  4961. }
  4962.  
  4963. const newSub = $('#resolutionMenu').children()[index];
  4964.  
  4965.  
  4966. if (!["","Loading..."].includes($('#fansubMenu').text())) {
  4967. if ($(newSub).text() === $('#resolutionMenu .active').text()) return;
  4968. newSub.click();
  4969. return;
  4970. }
  4971.  
  4972. new MutationObserver(function(mutationList, observer) {
  4973. newSub.click();
  4974. observer.disconnect();
  4975. }).observe($('#fansubMenu')[0], { childList: true });
  4976. }
  4977.  
  4978. function setIframeUrl(url) {
  4979. $('.embed-responsive-item').remove();
  4980. $(`
  4981. <iframe class="embed-responsive-item" scrolling="no" allowfullscreen="" allowtransparency="" src="${url}"></iframe>
  4982. `).prependTo('.embed-responsive');
  4983. $('.embed-responsive-item')[0].contentWindow.focus();
  4984. }
  4985.  
  4986. // Fix the quality dropdown buttons
  4987. if (isEpisode()) {
  4988. new MutationObserver(function(mutationList, observer) {
  4989. $('.click-to-load').remove();
  4990. $('#resolutionMenu').off('click');
  4991. $('#resolutionMenu').on('click', (el) => {
  4992. const targ = $(el.target);
  4993.  
  4994. if (targ.data('src') === undefined) return;
  4995.  
  4996. setIframeUrl(targ.data('src'));
  4997.  
  4998. $('#resolutionMenu .active').removeClass('active');
  4999. targ.addClass('active');
  5000.  
  5001. $('#fansubMenu').html(targ.html());
  5002.  
  5003. const storage = getStorage();
  5004. const data = getStoredLinkData(storage);
  5005. data.subInfo = getSubInfo(targ.text());
  5006. saveData(storage);
  5007.  
  5008. $.cookie('res', targ.data('resolution'), {
  5009. expires: 365,
  5010. path: '/'
  5011. });
  5012. $.cookie('aud', targ.data('audio'), {
  5013. expires: 365,
  5014. path: '/'
  5015. });
  5016. $.cookie('av1', targ.data('av1'), {
  5017. expires: 365,
  5018. path: '/'
  5019. });
  5020. });
  5021. observer.disconnect();
  5022. }).observe($('#fansubMenu')[0], { childList: true });
  5023.  
  5024.  
  5025.  
  5026. if (initialStorage.settings.bestQuality === true) {
  5027. bestVideoQuality();
  5028. }
  5029. else if (!["","Loading..."].includes($('#fansubMenu').text())) {
  5030. $('#resolutionMenu .active').click();
  5031. } else {
  5032. new MutationObserver(function(mutationList, observer) {
  5033. $('#resolutionMenu .active').click();
  5034. observer.disconnect();
  5035. }).observe($('#fansubMenu')[0], { childList: true });
  5036. }
  5037.  
  5038. const timeArg = paramArray.find(a => a[0] === 'time');
  5039. if (timeArg !== undefined) {
  5040. applyTimeArg(timeArg);
  5041. }
  5042. }
  5043.  
  5044. function applyTimeArg(timeArg) {
  5045. const time = timeArg[1];
  5046.  
  5047. function check() {
  5048. if ($('.embed-responsive-item').attr('src') !== undefined) done();
  5049. else setTimeout(check, 100);
  5050. }
  5051. setTimeout(check, 100);
  5052.  
  5053. function done() {
  5054. setIframeUrl(stripUrl($('.embed-responsive-item').attr('src')) + '?time=' + time);
  5055.  
  5056. window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
  5057. }
  5058. }
  5059.  
  5060.  
  5061. function getTrackerDiv() {
  5062. return $(`<div id="anitracker"></div>`);
  5063. }
  5064.  
  5065. async function asyncGetAllEpisodes(session, sort = "asc") {
  5066. const episodeList = [];
  5067. const request = new XMLHttpRequest();
  5068. request.open('GET', `/api?m=release&sort=episode_${sort}&id=` + session, true);
  5069.  
  5070. return new Promise((resolve, reject) => {
  5071. request.onload = async function() {
  5072. if (request.status !== 200) {
  5073. reject("Received response code " + request.status);
  5074. return;
  5075. }
  5076.  
  5077. const response = JSON.parse(request.response);
  5078. if (response.current_page === response.last_page) {
  5079. episodeList.push(...response.data);
  5080. }
  5081. else for (let i = 1; i <= response.last_page; i++) {
  5082. const episodes = await asyncGetResponseData(`/api?m=release&sort=episode_${sort}&page=${i}&id=${session}`);
  5083. if (episodes === undefined || episodes.length === 0) continue;
  5084. episodeList.push(...episodes);
  5085. }
  5086. resolve(episodeList);
  5087. };
  5088. request.send();
  5089. });
  5090. }
  5091.  
  5092. async function getRelationData(session, relationType) {
  5093. const request = new XMLHttpRequest();
  5094. request.open('GET', '/anime/' + session, false);
  5095. request.send();
  5096.  
  5097. const page = request.status === 200 ? $(request.response) : {};
  5098.  
  5099. if (Object.keys(page).length === 0) return undefined;
  5100.  
  5101. const relationDiv = (() => {
  5102. for (const div of page.find('.anime-relation .col-12')) {
  5103. if ($(div).find('h4 span').text() !== relationType) continue;
  5104. return $(div);
  5105. break;
  5106. }
  5107. return undefined;
  5108. })();
  5109.  
  5110. if (relationDiv === undefined) return undefined;
  5111.  
  5112. const relationSession = new RegExp('^.*animepahe\.[a-z]+/anime/([^/]+)').exec(relationDiv.find('a')[0].href)[1];
  5113.  
  5114. return new Promise(resolve => {
  5115. const episodeList = [];
  5116. asyncGetAllEpisodes(relationSession).then((episodes) => {
  5117. episodeList.push(...episodes);
  5118.  
  5119. if (episodeList.length === 0) {
  5120. resolve(undefined);
  5121. return;
  5122. }
  5123.  
  5124. resolve({
  5125. episodes: episodeList,
  5126. name: $(relationDiv.find('h5')[0]).text(),
  5127. poster: relationDiv.find('img').attr('data-src').replace('.th',''),
  5128. session: relationSession
  5129. });
  5130. });
  5131.  
  5132. });
  5133. }
  5134.  
  5135. function hideSpinner(t, parents = 1) {
  5136. $(t).parents(`:eq(${parents})`).find('.anitracker-download-spinner').hide();
  5137. }
  5138.  
  5139. if (isEpisode()) {
  5140. getTrackerDiv().appendTo('.anime-note');
  5141.  
  5142. $('.prequel,.sequel').addClass('anitracker-thumbnail');
  5143.  
  5144. $(`
  5145. <span relationType="Prequel" class="dropdown-item anitracker-relation-link" id="anitracker-prequel-link">
  5146. Previous Anime
  5147. </span>`).prependTo('.episode-menu #scrollArea');
  5148.  
  5149. $(`
  5150. <span relationType="Sequel" class="dropdown-item anitracker-relation-link" id="anitracker-sequel-link">
  5151. Next Anime
  5152. </span>`).appendTo('.episode-menu #scrollArea');
  5153.  
  5154. $('.anitracker-relation-link').on('click', function() {
  5155. if (this.href !== undefined) {
  5156. $(this).off();
  5157. return;
  5158. }
  5159.  
  5160. $(this).parents(':eq(2)').find('.anitracker-download-spinner').show();
  5161.  
  5162. const animeData = getAnimeData();
  5163.  
  5164. if (animeData === undefined) {
  5165. hideSpinner(this, 2);
  5166. return;
  5167. }
  5168.  
  5169. const relationType = $(this).attr('relationType');
  5170. getRelationData(animeData.session, relationType).then((relationData) => {
  5171. if (relationData === undefined) {
  5172. hideSpinner(this, 2);
  5173. alert(`[AnimePahe Improvements]\n\nNo ${relationType.toLowerCase()} found for this anime.`);
  5174. $(this).remove();
  5175. return;
  5176. }
  5177.  
  5178. const episodeSession = relationType === 'Prequel' ? relationData.episodes[relationData.episodes.length-1].session : relationData.episodes[0].session;
  5179.  
  5180. windowOpen(`/play/${relationData.session}/${episodeSession}`, '_self');
  5181. hideSpinner(this, 2);
  5182. });
  5183.  
  5184. });
  5185.  
  5186. if ($('.prequel').length === 0) setPrequelPoster();
  5187. if ($('.sequel').length === 0) setSequelPoster();
  5188. } else {
  5189. getTrackerDiv().insertAfter('.anime-content');
  5190. }
  5191.  
  5192. async function setPrequelPoster() {
  5193. const relationData = await getRelationData(animeSession, 'Prequel');
  5194. if (relationData === undefined) {
  5195. $('#anitracker-prequel-link').remove();
  5196. return;
  5197. }
  5198. const relationLink = `/play/${relationData.session}/${relationData.episodes[relationData.episodes.length-1].session}`;
  5199. $(`
  5200. <div class="prequel hidden-sm-down anitracker-thumbnail">
  5201. <a href="${relationLink}" title="${toHtmlCodes("Play Last Episode of " + relationData.name)}">
  5202. <img style="filter: none;" src="${relationData.poster}" data-src="${relationData.poster}" alt="">
  5203. </a>
  5204. <i class="fa fa-chevron-left" aria-hidden="true"></i>
  5205. </div>`).appendTo('.player');
  5206.  
  5207. $('#anitracker-prequel-link').attr('href', relationLink);
  5208. $('#anitracker-prequel-link').text(relationData.name);
  5209. $('#anitracker-prequel-link').changeElementType('a');
  5210.  
  5211. // If auto-clear is on, delete this prequel episode from the tracker
  5212. if (getStorage().settings.autoDelete === true) {
  5213. deleteEpisodesFromTracker(undefined, relationData.name);
  5214. }
  5215. }
  5216.  
  5217. async function setSequelPoster() {
  5218. const relationData = await getRelationData(animeSession, 'Sequel');
  5219. if (relationData === undefined) {
  5220. $('#anitracker-sequel-link').remove();
  5221. return;
  5222. }
  5223. const relationLink = `/play/${relationData.session}/${relationData.episodes[0].session}`;
  5224. $(`
  5225. <div class="sequel hidden-sm-down anitracker-thumbnail">
  5226. <a href="${relationLink}" title="${toHtmlCodes("Play First Episode of " + relationData.name)}">
  5227. <img style="filter: none;" src="${relationData.poster}" data-src="${relationData.poster}" alt="">
  5228. </a>
  5229. <i class="fa fa-chevron-right" aria-hidden="true"></i>
  5230. </div>`).appendTo('.player');
  5231.  
  5232. $('#anitracker-sequel-link').attr('href', relationLink);
  5233. $('#anitracker-sequel-link').text(relationData.name);
  5234. $('#anitracker-sequel-link').changeElementType('a');
  5235. }
  5236.  
  5237. if (!isEpisode() && $('#anitracker') != undefined) {
  5238. $('#anitracker').attr('style', "max-width: 1100px;margin-left: auto;margin-right: auto;margin-bottom: 20px;");
  5239. }
  5240.  
  5241. if (isEpisode()) {
  5242. // Replace the download buttons with better ones
  5243. if ($('#pickDownload a').length > 0) replaceDownloadButtons();
  5244. else {
  5245. new MutationObserver(function(mutationList, observer) {
  5246. replaceDownloadButtons();
  5247. observer.disconnect();
  5248. }).observe($('#pickDownload')[0], { childList: true });
  5249. }
  5250.  
  5251.  
  5252. $(document).on('blur', () => {
  5253. $('.dropdown-menu.show').removeClass('show');
  5254. });
  5255.  
  5256. (() => {
  5257. const storage = getStorage();
  5258. const foundNotifEpisode = storage.notifications.episodes.find(a => a.session === episodeSession);
  5259. if (foundNotifEpisode !== undefined) {
  5260. foundNotifEpisode.watched = true;
  5261. saveData(storage);
  5262. }
  5263. })();
  5264. }
  5265.  
  5266. function replaceDownloadButtons() {
  5267. for (const aTag of $('#pickDownload a')) {
  5268. $(aTag).changeElementType('span');
  5269. }
  5270.  
  5271. $('#pickDownload span').on('click', function(e) {
  5272.  
  5273. let request = new XMLHttpRequest();
  5274. //request.open('GET', `https://opsalar.000webhostapp.com/animepahe.php?url=${$(this).attr('href')}`, true);
  5275. request.open('GET', $(this).attr('href'), true);
  5276. try {
  5277. request.send();
  5278. $(this).parents(':eq(1)').find('.anitracker-download-spinner').show();
  5279. }
  5280. catch (err) {
  5281. windowOpen($(this).attr('href')); // When failed, open the link normally
  5282. }
  5283.  
  5284. const dlBtn = $(this);
  5285.  
  5286. request.onload = function(e) {
  5287. hideSpinner(dlBtn);
  5288. if (request.readyState !== 4 || request.status !== 200 ) {
  5289. windowOpen(dlBtn.attr('href'));
  5290. return;
  5291. }
  5292.  
  5293. const htmlText = request.response;
  5294. const link = /https:\/\/kwik.\w+\/f\/[^"]+/.exec(htmlText);
  5295. if (link) {
  5296. dlBtn.attr('href', link[0]);
  5297. dlBtn.off();
  5298. dlBtn.changeElementType('a');
  5299. windowOpen(link[0]);
  5300. }
  5301. else windowOpen(dlBtn.attr('href'));
  5302.  
  5303. };
  5304. });
  5305. }
  5306.  
  5307. function stripUrl(url) {
  5308. if (url === undefined) {
  5309. console.error('[AnimePahe Improvements] stripUrl was used with undefined URL');
  5310. return url;
  5311. }
  5312. const loc = new URL(url);
  5313. return loc.origin + loc.pathname;
  5314. }
  5315.  
  5316. function temporaryHtmlChange(elem, delay, html, timeout = undefined) {
  5317. if (timeout !== undefined) clearTimeout(timeout);
  5318. if ($(elem).attr('og-html') === undefined) {
  5319. $(elem).attr('og-html', $(elem).html());
  5320. }
  5321. elem.html(html);
  5322. return setTimeout(() => {
  5323. $(elem).html($(elem).attr('og-html'));
  5324. }, delay);
  5325. }
  5326.  
  5327. $(`
  5328. <button class="btn btn-dark" id="anitracker-clear-from-tracker" title="Remove this page from the session tracker">
  5329. <i class="fa fa-trash" aria-hidden="true"></i>
  5330. &nbsp;Clear from Tracker
  5331. </button>`).appendTo('#anitracker');
  5332.  
  5333. $('#anitracker-clear-from-tracker').on('click', function() {
  5334. const animeName = getAnimeName();
  5335.  
  5336. if (isEpisode()) {
  5337. deleteEpisodeFromTracker(animeName, getEpisodeNum(), getAnimeData().id);
  5338.  
  5339. if ($('.embed-responsive-item').length > 0) {
  5340. const storage = getStorage();
  5341. const videoUrl = stripUrl($('.embed-responsive-item').attr('src'));
  5342. for (const videoData of storage.videoTimes) {
  5343. if (!videoData.videoUrls.includes(videoUrl)) continue;
  5344. const index = storage.videoTimes.indexOf(videoData);
  5345. storage.videoTimes.splice(index, 1);
  5346. saveData(storage);
  5347. break;
  5348. }
  5349. }
  5350. }
  5351. else {
  5352. const storage = getStorage();
  5353.  
  5354. storage.linkList = storage.linkList.filter(a => !(a.type === 'anime' && a.animeName === animeName));
  5355.  
  5356. saveData(storage);
  5357. }
  5358.  
  5359. temporaryHtmlChange($('#anitracker-clear-from-tracker'), 1500, 'Cleared!');
  5360. });
  5361.  
  5362. function setCoverBlur(img) {
  5363. const cover = $('.anime-cover');
  5364. const ratio = cover.width()/img.width;
  5365. if (ratio <= 1) return;
  5366. cover.css('filter', `blur(${(ratio*Math.max((img.height/img.width)**2, 1))*1.6}px)`);
  5367. }
  5368.  
  5369. function improvePoster() {
  5370. if ($('.anime-poster .youtube-preview').length === 0) {
  5371. $('.anime-poster .poster-image').attr('target','_blank');
  5372. return;
  5373. }
  5374. $('.anime-poster .youtube-preview').removeAttr('href');
  5375. $(`
  5376. <a style="display:block;" target="_blank" href="${$('.anime-poster img').attr('src')}">
  5377. View full poster
  5378. </a>`).appendTo('.anime-poster');
  5379. }
  5380.  
  5381. function setProgressBar(baseElem, epWatched, currentTime, duration) {
  5382. const progress = $(
  5383. `<div class="anitracker-episode-progress"></div>`
  5384. ).appendTo(baseElem);
  5385.  
  5386. if (epWatched) {
  5387. progress.css('width', '100%');
  5388. return;
  5389. }
  5390.  
  5391. progress.css('width', (currentTime / duration) * 100 + '%');
  5392. }
  5393.  
  5394. function updateEpisodesPage() {
  5395. const pageNum = (() => {
  5396. const elem = $('.pagination');
  5397. if (elem.length == 0) return 1;
  5398. return +/^(\d+)/.exec($('.pagination').find('.page-item.active span').text())[0];
  5399. })();
  5400.  
  5401. const episodeSort = $('.episode-bar .btn-group-toggle .active').text().trim();
  5402.  
  5403. const episodes = getResponseData(`/api?m=release&sort=episode_${episodeSort}&page=${pageNum}&id=${animeSession}`);
  5404. if (episodes === undefined) return undefined;
  5405. if (episodes.length === 0) return undefined;
  5406.  
  5407. const episodeElements = $('.episode-wrap');
  5408.  
  5409. const storage = getStorage();
  5410. const animeId = episodes[0].anime_id;
  5411. const watched = decodeWatched(storage.watched);
  5412. const videoTimes = storage.videoTimes.filter(a => (a.animeId === animeId || a.animeName === getAnimeName()));
  5413.  
  5414. for (let i = 0; i < episodeElements.length; i++) {
  5415. const elem = $(episodeElements[i]);
  5416.  
  5417. const date = new Date(episodes[i].created_at + " UTC");
  5418. const episode = episodes[i].episode;
  5419.  
  5420. const durParts = episodes[i].duration.split(':');
  5421. const duration = (+durParts[0] * 3600) + (+durParts[1] * 60) + (+durParts[2]);
  5422.  
  5423. elem.find('.episode-duration').text(secondsToHMS(duration));
  5424.  
  5425. if (elem.find('.anitracker-episode-time').length === 0) {
  5426. $(`
  5427. <a class="anitracker-episode-time" href="${$(elem.find('a.play')).attr('href')}" tabindex="-1" title="${date.toDateString() + " " + date.toLocaleTimeString()}">${date.toLocaleDateString()}</a>
  5428. `).appendTo(elem.find('.episode-title-wrap'));
  5429. }
  5430.  
  5431. const epWatched = isWatched(animeId, episode, watched);
  5432.  
  5433. if (elem.find('.anitracker-episode-menu-button').length === 0) {
  5434. $(`
  5435. <button class="anitracker-episode-menu-button" title="View episode options">
  5436. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512">
  5437. <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
  5438. <path fill="currentColor" d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"></path>
  5439. </svg>
  5440. </button>
  5441. <div class="dropdown-menu anitracker-dropdown-content anitracker-episode-menu-dropdown" data-ep="${episode}" data-duration="${Math.floor(duration)}">
  5442. <button title="Copy a link to this episode" data-action="copy">Copy link</button>
  5443. <button title="Toggle this episode being fully watched" data-action="toggle-watched">Mark ${epWatched ? 'unwatched' : 'watched'}</button>
  5444. </div>
  5445. `).appendTo(elem.find('.episode')).data('watched', epWatched);
  5446. }
  5447. else {
  5448. elem.find('.anitracker-episode-menu-dropdown>button[data-action="toggle-watched"]').text(`Mark ${epWatched ? 'unwatched' : 'watched'}`);
  5449. elem.find('.anitracker-episode-menu-dropdown').data('watched', epWatched);
  5450. }
  5451.  
  5452. elem.find('.anitracker-episode-progress').remove();
  5453.  
  5454. const foundProgress = videoTimes.find(e => e.episodeNum === episode);
  5455. if (!epWatched && foundProgress === undefined) continue;
  5456.  
  5457. setProgressBar(elem.find('.episode-snapshot'), epWatched, foundProgress?.time, duration);
  5458. }
  5459.  
  5460. $('.anitracker-episode-menu-button').off('click').on('click', (e) => {
  5461. const elem = $(e.currentTarget);
  5462. const dropdown = elem.parent().find('.anitracker-episode-menu-dropdown');
  5463. dropdown.toggle();
  5464. if (!dropdown.is(':visible')) elem.blur();
  5465. })
  5466. .off('blur').on('blur', (e) => {
  5467. const dropdown = $(e.currentTarget).parent().find('.anitracker-episode-menu-dropdown');
  5468. const dropdownBtns = dropdown.find('button');
  5469. setTimeout(() => {
  5470. if (dropdownBtns.is(':focus')) return;
  5471. dropdown.hide();
  5472. }, 100);
  5473. });
  5474.  
  5475. $('.anitracker-episode-menu-dropdown>button').off('click').on('click', (e) => {
  5476. const elem = $(e.currentTarget);
  5477. const dropdown = elem.parent();
  5478. const episode = +dropdown.data('ep');
  5479. const action = elem.data('action');
  5480. dropdown.hide();
  5481.  
  5482. if (action === 'copy') {
  5483. const name = encodeURIComponent(getAnimeName());
  5484. navigator.clipboard.writeText(window.location.origin + '/customlink?a=' + name + '&e=' + episode);
  5485. return;
  5486. }
  5487. if (action === 'toggle-watched') {
  5488. const epWatched = dropdown.data('watched');
  5489. dropdown.data('watched', !epWatched);
  5490. const animeData = getAnimeData();
  5491. const animeId = animeData.id;
  5492. const progressContainer = dropdown.parent().find('.episode-snapshot');
  5493. const videoTime = getStoredTime(animeData.title, episode, getStorage(), animeId);
  5494. if (epWatched) {
  5495. removeWatched(animeId, episode);
  5496. progressContainer.find('.anitracker-episode-progress').remove();
  5497. }
  5498. else {
  5499. addWatched(animeId, episode);
  5500. }
  5501.  
  5502. // epWatched is the opposite of what it *will* be
  5503. if (!epWatched || videoTime !== undefined) setProgressBar(progressContainer, !epWatched, videoTime?.time, +dropdown.data('duration'));
  5504.  
  5505. elem.text('Mark ' + (epWatched ? 'watched' : 'unwatched'));
  5506. }
  5507. })
  5508. .off('blur').on('blur', (e) => {
  5509. const dropdown = $(e.currentTarget).parent();
  5510. const btn = dropdown.parent().find('.anitracker-episode-menu-button');
  5511. const dropdownBtns = dropdown.find('button');
  5512. setTimeout(() => {
  5513. if (btn.is(':focus') || dropdownBtns.is(':focus')) return;
  5514. $(e.currentTarget).parent().hide();
  5515. }, 100);
  5516. });
  5517. }
  5518.  
  5519. if (isAnime()) {
  5520. if ($('.anime-poster img').attr('src') !== undefined) {
  5521. improvePoster();
  5522. }
  5523. else $('.anime-poster img').on('load', (e) => {
  5524. improvePoster();
  5525. $(e.target).off('load');
  5526. });
  5527.  
  5528. $(`
  5529. <button class="btn btn-dark" id="anitracker-clear-episodes-from-tracker" title="Clear all episodes from this anime from the session tracker">
  5530. <i class="fa fa-trash" aria-hidden="true"></i>
  5531. <i class="fa fa-window-maximize" aria-hidden="true"></i>
  5532. &nbsp;Clear Episodes from Tracker
  5533. </button>`).appendTo('#anitracker');
  5534.  
  5535. $('#anitracker-clear-episodes-from-tracker').on('click', function() {
  5536. const animeData = getAnimeData();
  5537. deleteEpisodesFromTracker(undefined, animeData.title, animeData.id);
  5538.  
  5539. temporaryHtmlChange($('#anitracker-clear-episodes-from-tracker'), 1500, 'Cleared!');
  5540.  
  5541. updateEpisodesPage();
  5542. });
  5543.  
  5544. const storedObj = getStoredLinkData(initialStorage);
  5545.  
  5546. if (storedObj === undefined || storedObj?.coverImg === undefined) updateAnimeCover();
  5547. else
  5548. {
  5549. new MutationObserver(function(mutationList, observer) {
  5550. $('.anime-cover').css('background-image', `url("${storedObj.coverImg}")`);
  5551. $('.anime-cover').addClass('anitracker-replaced-cover');
  5552. const img = new Image();
  5553. img.src = storedObj.coverImg;
  5554. img.onload = () => {
  5555. setCoverBlur(img);
  5556. };
  5557. observer.disconnect();
  5558. }).observe($('.anime-cover')[0], { attributes: true });
  5559. }
  5560.  
  5561. if (isRandomAnime()) {
  5562. const sourceParams = new URLSearchParams(window.location.search);
  5563. window.history.replaceState({}, document.title, "/anime/" + animeSession);
  5564.  
  5565. const storage = getStorage();
  5566. if (storage.cache) {
  5567. for (const [key, value] of Object.entries(storage.cache)) {
  5568. filterSearchCache[key] = value;
  5569. }
  5570. delete storage.cache;
  5571. saveData(storage);
  5572. }
  5573.  
  5574. $(`
  5575. <div style="margin-left: 240px;">
  5576. <div class="btn-group">
  5577. <button class="btn btn-dark" id="anitracker-reroll-button" title="Go to another random anime"><i class="fa fa-random" aria-hidden="true"></i>&nbsp;Reroll Anime</button>
  5578. </div>
  5579. <div class="btn-group">
  5580. <button class="btn btn-dark" id="anitracker-save-session" title="Save this page's session"><i class="fa fa-floppy-o" aria-hidden="true"></i>&nbsp;Save Session</button>
  5581. </div>
  5582. </div>`).appendTo('.title-wrapper');
  5583.  
  5584. $('#anitracker-reroll-button').on('click', function() {
  5585. $(this).text('Rerolling...');
  5586.  
  5587. const sourceFilters = new URLSearchParams(sourceParams.toString());
  5588. getFilteredList(getFiltersFromParams(sourceFilters)).then((animeList) => {
  5589. const storage = getStorage();
  5590. storage.cache = filterSearchCache;
  5591. saveData(storage);
  5592.  
  5593. getRandomAnime(animeList, '?' + sourceParams.toString(), '_self');
  5594. });
  5595.  
  5596. });
  5597.  
  5598. $('#anitracker-save-session').on('click', function() {
  5599. setSessionData();
  5600. $('#anitracker-save-session').off();
  5601. $(this).text('Saved!');
  5602.  
  5603. setTimeout(() => {
  5604. $(this).parent().remove();
  5605. }, 1500);
  5606. });
  5607. }
  5608.  
  5609. // Show episode upload time & episode progress
  5610. new MutationObserver(function(mutationList, observer) {
  5611. updateEpisodesPage();
  5612.  
  5613. observer.disconnect();
  5614. setTimeout(observer.observe($('.episode-list-wrapper')[0], { childList: true, subtree: false }), 1);
  5615. }).observe($('.episode-list-wrapper')[0], { childList: true, subtree: false });
  5616.  
  5617. // Bookmark icon
  5618. const animename = getAnimeName();
  5619. const animeid = getAnimeData(animename).id;
  5620. $('h1 .fa').remove();
  5621.  
  5622. const notifIcon = (() => {
  5623. if (initialStorage.notifications.anime.find(a => a.name === animename) !== undefined) return true;
  5624. for (const info of $('.anime-info p>strong')) {
  5625. if (!$(info).text().startsWith('Status:')) continue;
  5626. return $(info).text().includes("Not yet aired") || $(info).find('a').text() === "Currently Airing";
  5627. }
  5628. return false;
  5629. })() ?
  5630. `<i title="Add to episode feed" class="fa fa-bell anitracker-title-icon anitracker-notifications-toggle">
  5631. <i style="display: none;" class="fa fa-check anitracker-title-icon-check" aria-hidden="true"></i>
  5632. </i>` : '';
  5633.  
  5634. $(`
  5635. <i title="Bookmark this anime" class="fa fa-bookmark anitracker-title-icon anitracker-bookmark-toggle">
  5636. <i style="display: none;" class="fa fa-check anitracker-title-icon-check" aria-hidden="true"></i>
  5637. </i>${notifIcon}<a href="/a/${animeid}" title="Get Link" class="fa fa-link btn anitracker-title-icon" data-toggle="modal" data-target="#modalBookmark"></a>
  5638. `).appendTo('.title-wrapper>h1');
  5639.  
  5640. if (initialStorage.bookmarks.find(g => g.id === animeid) !== undefined) {
  5641. $('.anitracker-bookmark-toggle .anitracker-title-icon-check').show();
  5642. }
  5643.  
  5644. if (initialStorage.notifications.anime.find(g => g.id === animeid) !== undefined) {
  5645. $('.anitracker-notifications-toggle .anitracker-title-icon-check').show();
  5646. }
  5647.  
  5648. $('.anitracker-bookmark-toggle').on('click', (e) => {
  5649. const check = $(e.currentTarget).find('.anitracker-title-icon-check');
  5650.  
  5651. if (toggleBookmark(animeid, animename)) {
  5652. check.show();
  5653. return;
  5654. }
  5655. check.hide();
  5656.  
  5657. });
  5658.  
  5659. $('.anitracker-notifications-toggle').on('click', (e) => {
  5660. const check = $(e.currentTarget).find('.anitracker-title-icon-check');
  5661.  
  5662. if (toggleNotifications(animename, animeid)) {
  5663. check.show();
  5664. return;
  5665. }
  5666. check.hide();
  5667.  
  5668. });
  5669. }
  5670.  
  5671. function getRandomAnime(list, args, openType = '_blank') {
  5672. if (list.length === 0) {
  5673. alert("[AnimePahe Improvements]\n\nThere is no anime that matches the selected filters.");
  5674. return;
  5675. }
  5676. const random = randint(0, list.length-1);
  5677. windowOpen(list[random].link + args, openType);
  5678. }
  5679.  
  5680. function isRandomAnime() {
  5681. return new URLSearchParams(window.location.search).has('anitracker-random');
  5682. }
  5683.  
  5684. function getBadCovers() {
  5685. const storage = getStorage();
  5686. return ['https://s.pximg.net/www/images/pixiv_logo.png',
  5687. 'https://st.deviantart.net/minish/main/logo/card_black_large.png',
  5688. 'https://www.wcostream.com/wp-content/themes/animewp78712/images/logo.gif',
  5689. 'https://s.pinimg.com/images/default_open_graph',
  5690. 'https://share.redd.it/preview/post/',
  5691. 'https://i.redd.it/o0h58lzmax6a1.png',
  5692. 'https://ir.ebaystatic.com/cr/v/c1/ebay-logo',
  5693. 'https://i.ebayimg.com/images/g/7WgAAOSwQ7haxTU1/s-l1600.jpg',
  5694. 'https://www.rottentomatoes.com/assets/pizza-pie/head-assets/images/RT_TwitterCard',
  5695. 'https://m.media-amazon.com/images/G/01/social_share/amazon_logo',
  5696. 'https://zoro.to/images/capture.png',
  5697. 'https://cdn.myanimelist.net/img/sp/icon/twitter-card.png',
  5698. 'https://s2.bunnycdn.ru/assets/sites/animesuge/images/preview.jpg',
  5699. 'https://s2.bunnycdn.ru/assets/sites/anix/preview.jpg',
  5700. 'https://cdn.myanimelist.net/images/company_no_picture.png',
  5701. 'https://myanimeshelf.com/eva2/handlers/generateSocialImage.php',
  5702. 'https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon',
  5703. 'https://m.media-amazon.com/images/G/01/imdb/images/social',
  5704. 'https://forums.animeuknews.net/styles/default/',
  5705. 'https://honeysanime.com/wp-content/uploads/2016/12/facebook_cover_2016_851x315.jpg',
  5706. 'https://fi.somethingawful.com/images/logo.png',
  5707. 'https://static.hidive.com/misc/HIDIVE-Logo-White.png',
  5708. ...storage.badCovers];
  5709. }
  5710.  
  5711. async function updateAnimeCover() {
  5712. $(`<div id="anitracker-cover-spinner" class="anitracker-spinner">
  5713. <div class="spinner-border" role="status">
  5714. <span class="sr-only">Loading...</span>
  5715. </div>
  5716. </div>`).prependTo('.anime-cover');
  5717.  
  5718. const request = new XMLHttpRequest();
  5719. let beforeYear = 2022;
  5720. for (const info of $('.anime-info p')) {
  5721. if (!$(info).find('strong').html().startsWith('Season:')) continue;
  5722. const year = +/(\d+)$/.exec($(info).find('a').text())[0];
  5723. if (year >= beforeYear) beforeYear = year + 1;
  5724. }
  5725. request.open('GET', 'https://customsearch.googleapis.com/customsearch/v1?key=AIzaSyCzrHsVOqJ4vbjNLpGl8XZcxB49TGDGEFk&cx=913e33346cc3d42bf&tbs=isz:l&q=' + encodeURIComponent(getAnimeName()) + '%20anime%20hd%20wallpaper%20-phone%20-ai%20before:' + beforeYear, true);
  5726. request.onload = function() {
  5727. if (request.status !== 200) {
  5728. $('#anitracker-cover-spinner').remove();
  5729. return;
  5730. }
  5731. if ($('.anime-cover').css('background-image').length > 10) {
  5732. decideAnimeCover(request.response);
  5733. }
  5734. else {
  5735. new MutationObserver(function(mutationList, observer) {
  5736. if ($('.anime-cover').css('background-image').length <= 10) return;
  5737. decideAnimeCover(request.response);
  5738. observer.disconnect();
  5739. }).observe($('.anime-cover')[0], { attributes: true });
  5740. }
  5741. };
  5742. request.send();
  5743. }
  5744.  
  5745. function trimHttp(string) {
  5746. return string.replace(/^https?:\/\//,'');
  5747. }
  5748.  
  5749. async function setAnimeCover(src) {
  5750. return new Promise(resolve => {
  5751. $('.anime-cover').css('background-image', `url("${storedObj.coverImg}")`);
  5752. $('.anime-cover').addClass('anitracker-replaced-cover');
  5753. const img = new Image();
  5754. img.src = src;
  5755. img.onload = () => {
  5756. setCoverBlur(img);
  5757. }
  5758.  
  5759. $('.anime-cover').addClass('anitracker-replaced-cover');
  5760. $('.anime-cover').css('background-image', `url("${src}")`);
  5761. $('.anime-cover').attr('image', src);
  5762.  
  5763. $('#anitracker-replace-cover').remove();
  5764. $(`<button class="btn btn-dark" id="anitracker-replace-cover" title="Use another cover instead">
  5765. <i class="fa fa-refresh" aria-hidden="true"></i>
  5766. </button>`).appendTo('.anime-cover');
  5767.  
  5768. $('#anitracker-replace-cover').on('click', e => {
  5769. const storage = getStorage();
  5770. storage.badCovers.push($('.anime-cover').attr('image'));
  5771. saveData(storage);
  5772. updateAnimeCover();
  5773. $(e.target).off();
  5774. playAnimation($(e.target).find('i'), 'spin', 'infinite', 1);
  5775. });
  5776.  
  5777. setCoverBlur(image);
  5778. });
  5779. }
  5780.  
  5781. async function decideAnimeCover(response) {
  5782. const badCovers = getBadCovers();
  5783. const candidates = [];
  5784. let results = [];
  5785. try {
  5786. results = JSON.parse(response).items;
  5787. }
  5788. catch (e) {
  5789. return;
  5790. }
  5791. if (results === undefined) {
  5792. $('#anitracker-cover-spinner').remove();
  5793. return;
  5794. }
  5795. for (const result of results) {
  5796. let imgUrl = result['pagemap']?.['metatags']?.[0]?.['og:image'] ||
  5797. result['pagemap']?.['cse_image']?.[0]?.['src'] || result['pagemap']?.['webpage']?.[0]?.['image'] ||
  5798. result['pagemap']?.['metatags']?.[0]?.['twitter:image:src'];
  5799.  
  5800.  
  5801. const width = result['pagemap']?.['cse_thumbnail']?.[0]?.['width'];
  5802. const height = result['pagemap']?.['cse_thumbnail']?.[0]?.['height'];
  5803.  
  5804. if (imgUrl === undefined || height < 100 || badCovers.find(a=> trimHttp(imgUrl).startsWith(trimHttp(a))) !== undefined || imgUrl.endsWith('.gif')) continue;
  5805.  
  5806. if (imgUrl.startsWith('https://static.wikia.nocookie.net')) {
  5807. imgUrl = imgUrl.replace(/\/revision\/latest.*\?cb=\d+$/, '');
  5808. }
  5809.  
  5810. candidates.push({
  5811. src: imgUrl,
  5812. width: width,
  5813. height: height,
  5814. aspectRatio: width / height
  5815. });
  5816. }
  5817.  
  5818. if (candidates.length === 0) return;
  5819.  
  5820. candidates.sort((a, b) => {return a.aspectRatio < b.aspectRatio ? 1 : -1});
  5821.  
  5822. if (candidates[0].src.includes('"')) return;
  5823.  
  5824. const originalBg = $('.anime-cover').css('background-image');
  5825.  
  5826. function badImg() {
  5827. $('.anime-cover').css('background-image', originalBg);
  5828.  
  5829. const storage = getStorage();
  5830. for (const anime of storage.linkList) {
  5831. if (anime.type === 'anime' && anime.animeSession === animeSession) {
  5832. anime.coverImg = /^url\("?([^"]+)"?\)$/.exec(originalBg)[1];
  5833. break;
  5834. }
  5835. }
  5836. saveData(storage);
  5837.  
  5838. $('#anitracker-cover-spinner').remove();
  5839. }
  5840.  
  5841. const image = new Image();
  5842. image.onload = () => {
  5843. if (image.width >= 250) {
  5844.  
  5845. $('.anime-cover').addClass('anitracker-replaced-cover');
  5846. $('.anime-cover').css('background-image', `url("${candidates[0].src}")`);
  5847. $('.anime-cover').attr('image', candidates[0].src);
  5848. setCoverBlur(image);
  5849. const storage = getStorage();
  5850. for (const anime of storage.linkList) {
  5851. if (anime.type === 'anime' && anime.animeSession === animeSession) {
  5852. anime.coverImg = candidates[0].src;
  5853. break;
  5854. }
  5855. }
  5856. saveData(storage);
  5857.  
  5858. $('#anitracker-cover-spinner').remove();
  5859. }
  5860. else badImg();
  5861. };
  5862.  
  5863. image.addEventListener('error', function() {
  5864. badImg();
  5865. });
  5866.  
  5867. image.src = candidates[0].src;
  5868. }
  5869.  
  5870. function hideThumbnails() {
  5871. $('.main').addClass('anitracker-hide-thumbnails');
  5872. }
  5873.  
  5874. function resetPlayer() {
  5875. setIframeUrl(stripUrl($('.embed-responsive-item').attr('src')));
  5876. }
  5877.  
  5878. function addGeneralButtons() {
  5879. $(`
  5880. <button class="btn btn-dark" id="anitracker-show-data" title="View and handle stored sessions and video progress">
  5881. <i class="fa fa-floppy-o" aria-hidden="true"></i>
  5882. &nbsp;Manage Data...
  5883. </button>
  5884. <button class="btn btn-dark" id="anitracker-settings" title="Options">
  5885. <i class="fa fa-sliders" aria-hidden="true"></i>
  5886. &nbsp;Options...
  5887. </button>`).appendTo('#anitracker');
  5888.  
  5889. $('#anitracker-settings').on('click', () => {
  5890. $('#anitracker-modal-body').empty();
  5891.  
  5892. if (isAnime() || isEpisode())
  5893. $(`<div class="btn-group">
  5894. <button class="btn btn-secondary" id="anitracker-refresh-session" title="Refresh the session for the current page">
  5895. <i class="fa fa-refresh" aria-hidden="true"></i>
  5896. &nbsp;Refresh Session
  5897. </button></div>`).appendTo('#anitracker-modal-body');
  5898.  
  5899. $('<span style="display:block;margin-top:10px">Video player:</span>').appendTo('#anitracker-modal-body');
  5900.  
  5901. addOptionSwitch('autoPlayVideo', 'Auto-Play Video', 'Automatically play the video when it is loaded.');
  5902. addOptionSwitch('theatreMode', 'Theatre Mode', 'Expand the video player for a better experience on bigger screens.');
  5903. addOptionSwitch('bestQuality', 'Default to Best Quality', 'Automatically select the best resolution quality available.');
  5904. addOptionSwitch('seekThumbnails', 'Seek Thumbnails', 'Show thumbnail images while seeking through the progress bar. May cause performance issues on weak systems.');
  5905. addOptionSwitch('seekPoints', 'Seek Points', 'Show points on the progress bar.');
  5906. addOptionSwitch('skipButton', 'Skip Button', 'Show a button to skip sections of episodes.');
  5907. addOptionSwitch('copyScreenshots', 'Copy Screenshots', 'Copy screenshots to the clipboard, instead of downloading them.');
  5908.  
  5909. if (isEpisode()) {
  5910. const data = getAnimeData();
  5911. $(`
  5912. <div class="btn-group">
  5913. <button class="btn btn-secondary" id="anitracker-reset-player" title="Reset the video player">
  5914. <i class="fa fa-rotate-right" aria-hidden="true"></i>
  5915. &nbsp;Reset Player
  5916. </button>
  5917. </div><br>
  5918. <a class="btn-group" style="margin-top: 5px;" href="https://github.com/Ellivers/open-anime-timestamps/issues/new?title=Anime%20%22${encodeURIComponent(data.title)}%22%20has%20incorrect%20timestamps&body=Anime%20ID:%20${data.id}%0AAffected%20episode(s):%20${getEpisodeNum()}%0A%0A(Add%20more%20info%20here...)" target="_blank">
  5919. <button class="btn btn-secondary anitracker-flat-button" id="anitracker-report-timestamps" title="Open a new issue for incorrect timestamps on this episode">
  5920. <i class="fa fa-external-link"></i>
  5921. &nbsp;Report Timestamp Issue
  5922. </button>
  5923. </a>`).appendTo('#anitracker-modal-body');
  5924.  
  5925. $('#anitracker-reset-player').on('click', function() {
  5926. closeModal();
  5927. resetPlayer();
  5928. });
  5929. }
  5930.  
  5931. $('<span style="display:block;margin-top:10px;">Site:</span>').appendTo('#anitracker-modal-body');
  5932. addOptionSwitch('hideThumbnails', 'Hide Thumbnails', 'Hide thumbnails and preview images.');
  5933. addOptionSwitch('autoDelete', 'Auto-Clear Links', 'Only one episode of a series is stored in the tracker at a time.');
  5934. addOptionSwitch('autoDownload', 'Automatic Download', 'Automatically download the episode when visiting a download page.');
  5935. addOptionSwitch('reduceMotion', 'Reduce Motion', 'Don\'t show animations for opening/closing modal menus.');
  5936.  
  5937. if (isAnime()) {
  5938. $(`
  5939. <span style="display:block;margin-top:10px;">This anime:</span>
  5940. <div class="btn-group">
  5941. <button class="btn btn-secondary" id="anitracker-mark-watched" title="Mark all episodes of this anime as fully watched">
  5942. <i class="fa fa-eye" aria-hidden="true"></i>
  5943. &nbsp;Mark As Watched
  5944. </button>
  5945. </div>
  5946. <div class="anitracker-mark-watched-spinner anitracker-spinner" style="display: none;vertical-align: bottom;">
  5947. <div class="spinner-border" role="status">
  5948. <span class="sr-only">Loading...</span>
  5949. </div>
  5950. </div>
  5951. <div class="btn-group" style="display:block;margin-top: 5px;">
  5952. <button class="btn btn-secondary" id="anitracker-unmark-watched" title="Unmark all fully watched episodes of this anime">
  5953. <i class="fa fa-eye-slash" aria-hidden="true"></i>
  5954. &nbsp;Unmark Watched Episodes
  5955. </button>
  5956. </div>`).appendTo('#anitracker-modal-body');
  5957.  
  5958. $('#anitracker-mark-watched').on('click', function(e) {
  5959. $(e.currentTarget).prop('disabled', true);
  5960. $('.anitracker-mark-watched-spinner').css('display','inline');
  5961. asyncGetAllEpisodes(animeSession).then((episodes) => {
  5962. $(e.currentTarget).prop('disabled', false);
  5963.  
  5964. if (episodes.length === 0) {
  5965. $('.anitracker-mark-watched-spinner').css('display','none');
  5966. return;
  5967. }
  5968.  
  5969. const converted = episodes.map(e => e.episode);
  5970.  
  5971. const storage = getStorage();
  5972. const watched = decodeWatched(storage.watched);
  5973. const animeId = getAnimeData().id;
  5974.  
  5975. const found = watched.find(a => a.animeId === animeId);
  5976. if (found !== undefined) {
  5977. found.episodes = converted;
  5978. }
  5979. else {
  5980. watched.push({
  5981. animeId: animeId,
  5982. episodes: converted
  5983. });
  5984. }
  5985.  
  5986. storage.watched = encodeWatched(watched);
  5987. saveData(storage);
  5988.  
  5989. closeModal();
  5990. updateEpisodesPage();
  5991. });
  5992. });
  5993.  
  5994. $('#anitracker-unmark-watched').on('click', function() {
  5995. closeModal();
  5996. removeWatchedAnime(getAnimeData().id);
  5997. updateEpisodesPage();
  5998. });
  5999. }
  6000.  
  6001. $('#anitracker-refresh-session').on('click', function(e) {
  6002. const elem = $('#anitracker-refresh-session');
  6003. let timeout = temporaryHtmlChange(elem, 2200, 'Waiting...');
  6004.  
  6005. const result = refreshSession();
  6006.  
  6007. if (result === 0) {
  6008. temporaryHtmlChange(elem, 2200, '<i class="fa fa-refresh" aria-hidden="true" style="animation: anitracker-spin 1s linear infinite;"></i>&nbsp;&nbsp;Refreshing...', timeout);
  6009. }
  6010. else if ([2,3].includes(result)) {
  6011. temporaryHtmlChange(elem, 2200, 'Failed: Couldn\'t find session', timeout);
  6012. }
  6013. else {
  6014. temporaryHtmlChange(elem, 2200, 'Failed.', timeout);
  6015. }
  6016. });
  6017.  
  6018. openModal();
  6019. });
  6020.  
  6021. function openShowDataModal() {
  6022. $('#anitracker-modal-body').empty();
  6023. $(`
  6024. <div class="anitracker-modal-list-container">
  6025. <div class="anitracker-storage-data" title="Expand or retract the storage entry for page sessions" tabindex="0" key="linkList">
  6026. <span>Session Data</span>
  6027. </div>
  6028. </div>
  6029. <div class="anitracker-modal-list-container">
  6030. <div class="anitracker-storage-data" title="Expand or retract the storage entry for video progress" tabindex="0" key="videoTimes">
  6031. <span>Video Progress</span>
  6032. </div>
  6033. </div>
  6034. <div class="anitracker-modal-list-container">
  6035. <div class="anitracker-storage-data" title="Expand or retract the storage entry for episodes marked as watched" tabindex="0" key="watched">
  6036. <span>Watched Episodes</span>
  6037. </div>
  6038. </div>
  6039. <div class="anitracker-modal-list-container">
  6040. <div class="anitracker-storage-data" title="Expand or retract the storage entry for anime-specific video playback speed" tabindex="0" key="videoSpeed">
  6041. <span>Video Playback Speed</span>
  6042. </div>
  6043. </div>
  6044. <div class="btn-group">
  6045. <button class="btn btn-danger" id="anitracker-reset-data" title="Remove stored data and reset all settings">
  6046. <i class="fa fa-undo" aria-hidden="true"></i>
  6047. &nbsp;Reset Data
  6048. </button>
  6049. </div>
  6050. <div class="btn-group">
  6051. <button class="btn btn-secondary" id="anitracker-raw-data" title="View data in JSON format">
  6052. <i class="fa fa-code" aria-hidden="true"></i>
  6053. &nbsp;Raw
  6054. </button>
  6055. </div>
  6056. <div class="btn-group">
  6057. <button class="btn btn-secondary" id="anitracker-export-data" title="Export and download the JSON data">
  6058. <i class="fa fa-download" aria-hidden="true"></i>
  6059. &nbsp;Export Data
  6060. </button>
  6061. </div>
  6062. <label class="btn btn-secondary" id="anitracker-import-data-label" tabindex="0" for="anitracker-import-data" style="margin-bottom:0;" title="Import a JSON file with AnimePahe Improvements data. This does not delete any existing data.">
  6063. <i class="fa fa-upload" aria-hidden="true"></i>
  6064. &nbsp;Import Data
  6065. </label>
  6066. <div class="btn-group">
  6067. <button class="btn btn-dark" id="anitracker-edit-data" title="Edit a key">
  6068. <i class="fa fa-pencil" aria-hidden="true"></i>
  6069. &nbsp;Edit...
  6070. </button>
  6071. </div>
  6072. <input type="file" id="anitracker-import-data" style="visibility: hidden; width: 0;" accept=".json">
  6073. `).appendTo('#anitracker-modal-body');
  6074.  
  6075. const expandIcon = `<i class="fa fa-plus anitracker-expand-data-icon" aria-hidden="true"></i>`;
  6076. const contractIcon = `<i class="fa fa-minus anitracker-expand-data-icon" aria-hidden="true"></i>`;
  6077.  
  6078. $(expandIcon).appendTo('.anitracker-storage-data');
  6079.  
  6080. $('.anitracker-storage-data').on('click keydown', (e) => {
  6081. if (e.type === 'keydown' && e.key !== "Enter") return;
  6082. toggleExpandData($(e.currentTarget));
  6083. });
  6084.  
  6085. function toggleExpandData(elem) {
  6086. if (elem.hasClass('anitracker-expanded')) {
  6087. contractData(elem);
  6088. }
  6089. else {
  6090. expandData(elem);
  6091. }
  6092. }
  6093.  
  6094. $('#anitracker-reset-data').on('click', function() {
  6095. if (confirm('[AnimePahe Improvements]\n\nThis will remove all saved data and reset it to its default state.\nAre you sure?') === true) {
  6096. saveData(getDefaultData());
  6097. updatePage();
  6098. openShowDataModal();
  6099. }
  6100. });
  6101.  
  6102. $('#anitracker-raw-data').on('click', function() {
  6103. const blob = new Blob([JSON.stringify(getStorage())], {type : 'application/json'});
  6104. windowOpen(URL.createObjectURL(blob));
  6105. });
  6106.  
  6107. $('#anitracker-edit-data').on('click', function() {
  6108. $('#anitracker-modal-body').empty();
  6109. $(`
  6110. <b>Warning: for developer use.<br>Back up your data before messing with this.</b>
  6111. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-edit-data-key" placeholder="Key (Path)">
  6112. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-edit-data-value" placeholder="Value (JSON)">
  6113. <p>Leave value empty to get the existing value</p>
  6114. <div class="btn-group">
  6115. <button class="btn dropdown-toggle btn-secondary anitracker-edit-mode-dropdown-button" data-bs-toggle="dropdown" data-toggle="dropdown" data-value="replace">Replace</button>
  6116. <div class="dropdown-menu anitracker-dropdown-content anitracker-edit-mode-dropdown"></div>
  6117. </div>
  6118. <div class="btn-group">
  6119. <button class="btn btn-primary anitracker-confirm-edit-button">Confirm</button>
  6120. </div>
  6121. `).appendTo('#anitracker-modal-body');
  6122.  
  6123. [{t:'Replace',i:'replace'},{t:'Append',i:'append'},{t:'Delete from list',i:'delList'}].forEach(g => { $(`<button ref="${g.i}">${g.t}</button>`).appendTo('.anitracker-edit-mode-dropdown') });
  6124.  
  6125. $('.anitracker-edit-mode-dropdown button').on('click', (e) => {
  6126. const pressed = $(e.target)
  6127. const btn = pressed.parents().eq(1).find('.anitracker-edit-mode-dropdown-button');
  6128. btn.data('value', pressed.attr('ref'));
  6129. btn.text(pressed.text());
  6130. });
  6131.  
  6132. $('.anitracker-confirm-edit-button').on('click', () => {
  6133. const storage = getStorage();
  6134. const key = $('.anitracker-edit-data-key').val();
  6135. let keyValue = undefined;
  6136. try {
  6137. keyValue = eval("storage." + key); // lots of evals here because I'm lazy
  6138. }
  6139. catch (e) {
  6140. console.error(e);
  6141. alert("Nope didn't work");
  6142. return;
  6143. }
  6144.  
  6145. if ($('.anitracker-edit-data-value').val() === '') {
  6146. alert(JSON.stringify(keyValue));
  6147. return;
  6148. }
  6149.  
  6150. if (keyValue === undefined) {
  6151. alert("Undefined");
  6152. return;
  6153. }
  6154.  
  6155. const mode = $('.anitracker-edit-mode-dropdown-button').data('value');
  6156.  
  6157. let value = undefined;
  6158. if (mode === 'delList') {
  6159. value = $('.anitracker-edit-data-value').val();
  6160. }
  6161. else if ($('.anitracker-edit-data-value').val() !== "undefined") {
  6162. try {
  6163. value = JSON.parse($('.anitracker-edit-data-value').val());
  6164. }
  6165. catch (e) {
  6166. console.error(e);
  6167. alert("Invalid JSON");
  6168. return;
  6169. }
  6170. }
  6171.  
  6172. const delFromListMessage = "Please enter a comparison in the 'value' field, with 'a' being the variable for the element.\neg. 'a.id === \"apple\"'\nWhichever elements that match this will be deleted.";
  6173.  
  6174. switch (mode) {
  6175. case 'replace':
  6176. eval(`storage.${key} = value`);
  6177. break;
  6178. case 'append':
  6179. if (keyValue.constructor.name !== 'Array') {
  6180. alert("Not a list");
  6181. return;
  6182. }
  6183. eval(`storage.${key}.push(value)`);
  6184. break;
  6185. case 'delList':
  6186. if (keyValue.constructor.name !== 'Array') {
  6187. alert("Not a list");
  6188. return;
  6189. }
  6190. try {
  6191. eval(`storage.${key} = storage.${key}.filter(a => !(${value}))`);
  6192. }
  6193. catch (e) {
  6194. console.error(e);
  6195. alert(delFromListMessage);
  6196. return;
  6197. }
  6198. break;
  6199. default:
  6200. alert("This message isn't supposed to show up. Uh...");
  6201. return;
  6202. }
  6203. if (JSON.stringify(storage) === JSON.stringify(getStorage())) {
  6204. alert("Nothing changed.");
  6205. if (mode === 'delList') {
  6206. alert(delFromListMessage);
  6207. }
  6208. return;
  6209. }
  6210. else alert("Probably worked!");
  6211.  
  6212. saveData(storage);
  6213. });
  6214.  
  6215. openModal(openShowDataModal);
  6216. });
  6217.  
  6218. $('#anitracker-export-data').on('click', function() {
  6219. const storage = getStorage();
  6220.  
  6221. if (storage.cache) {
  6222. delete storage.cache;
  6223. saveData(storage);
  6224. }
  6225. download('animepahe-tracked-data-' + Date.now() + '.json', JSON.stringify(getStorage(), null, 2));
  6226. });
  6227.  
  6228. $('#anitracker-import-data-label').on('keydown', (e) => {
  6229. if (e.key === "Enter") $("#" + $(e.currentTarget).attr('for')).click();
  6230. });
  6231.  
  6232. $('#anitracker-import-data').on('change', function(event) {
  6233. const file = this.files[0];
  6234. const fileReader = new FileReader();
  6235. $(fileReader).on('load', function() {
  6236. let newData = {};
  6237. try {
  6238. newData = JSON.parse(fileReader.result);
  6239. }
  6240. catch (err) {
  6241. alert('[AnimePahe Improvements]\n\nPlease input a valid JSON file.');
  6242. return;
  6243. }
  6244.  
  6245. const storage = getStorage();
  6246. const diffBefore = importData(storage, newData, false);
  6247.  
  6248. let totalChanged = 0;
  6249. for (const [key, value] of Object.entries(diffBefore)) {
  6250. totalChanged += value;
  6251. }
  6252.  
  6253. if (totalChanged === 0) {
  6254. alert('[AnimePahe Improvements]\n\nThis file contains no changes to import.');
  6255. return;
  6256. }
  6257.  
  6258. $('#anitracker-modal-body').empty();
  6259.  
  6260. $(`
  6261. <h4>Choose what to import</h4>
  6262. <br>
  6263. <div class="form-check">
  6264. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-link-list-check" ${diffBefore.linkListAdded > 0 ? "checked" : "disabled"}>
  6265. <label class="form-check-label" for="anitracker-link-list-check">
  6266. Session entries (${diffBefore.linkListAdded})
  6267. </label>
  6268. </div>
  6269. <div class="form-check">
  6270. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-video-times-check" ${(diffBefore.videoTimesAdded + diffBefore.videoTimesUpdated) > 0 ? "checked" : "disabled"}>
  6271. <label class="form-check-label" for="anitracker-video-times-check">
  6272. Video progress times (${diffBefore.videoTimesAdded + diffBefore.videoTimesUpdated})
  6273. </label>
  6274. </div>
  6275. <div class="form-check">
  6276. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-bookmarks-check" ${diffBefore.bookmarksAdded > 0 ? "checked" : "disabled"}>
  6277. <label class="form-check-label" for="anitracker-bookmarks-check">
  6278. Bookmarks (${diffBefore.bookmarksAdded})
  6279. </label>
  6280. </div>
  6281. <div class="form-check">
  6282. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-notifications-check" ${(diffBefore.notificationsAdded + diffBefore.episodeFeedUpdated) > 0 ? "checked" : "disabled"}>
  6283. <label class="form-check-label" for="anitracker-notifications-check">
  6284. Episode feed entries (${diffBefore.notificationsAdded})
  6285. <ul style="margin-bottom:0;margin-left:-24px;"><li>Episode feed entries updated: ${diffBefore.episodeFeedUpdated}</li></ul>
  6286. </label>
  6287. </div>
  6288. <div class="form-check">
  6289. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-watched-check" ${diffBefore.watchedEpisodesAdded > 0 ? "checked" : "disabled"}>
  6290. <label class="form-check-label" for="anitracker-watched-check">
  6291. Watched episodes (${diffBefore.watchedEpisodesAdded})
  6292. </label>
  6293. </div>
  6294. <div class="form-check">
  6295. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-video-speed-check" ${diffBefore.videoSpeedUpdated > 0 ? "checked" : "disabled"}>
  6296. <label class="form-check-label" for="anitracker-video-speed-check">
  6297. Video speed entries (${diffBefore.videoSpeedUpdated})
  6298. </label>
  6299. </div>
  6300. <div class="form-check">
  6301. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-settings-check" ${diffBefore.settingsUpdated > 0 ? "checked" : "disabled"}>
  6302. <label class="form-check-label" for="anitracker-settings-check">
  6303. Settings (${diffBefore.settingsUpdated})
  6304. </label>
  6305. </div>
  6306. <div class="btn-group" style="float: right;">
  6307. <button class="btn btn-primary" id="anitracker-confirm-import" title="Confirm import">
  6308. <i class="fa fa-upload" aria-hidden="true"></i>
  6309. &nbsp;Import
  6310. </button>
  6311. </div>
  6312. `).appendTo('#anitracker-modal-body');
  6313.  
  6314. $('.anitracker-import-data-input').on('change', (e) => {
  6315. let checksOn = 0;
  6316. for (const elem of $('.anitracker-import-data-input')) {
  6317. if ($(elem).prop('checked')) checksOn++;
  6318. }
  6319. if (checksOn === 0) {
  6320. $('#anitracker-confirm-import').attr('disabled', true);
  6321. }
  6322. else {
  6323. $('#anitracker-confirm-import').attr('disabled', false);
  6324. }
  6325. });
  6326.  
  6327. $('#anitracker-confirm-import').on('click', () => {
  6328. const diffAfter = importData(getStorage(), newData, true, {
  6329. linkList: !$('#anitracker-link-list-check').prop('checked'),
  6330. videoTimes: !$('#anitracker-video-times-check').prop('checked'),
  6331. bookmarks: !$('#anitracker-bookmarks-check').prop('checked'),
  6332. notifications: !$('#anitracker-notifications-check').prop('checked'),
  6333. watchedEpisodes: !$('#anitracker-watched-check').prop('checked'),
  6334. videoSpeed: !$('#anitracker-video-speed-check').prop('checked'),
  6335. settings: !$('#anitracker-settings-check').prop('checked')
  6336. });
  6337.  
  6338. if ((diffAfter.bookmarksAdded + diffAfter.notificationsAdded + diffAfter.settingsUpdated) > 0) updatePage();
  6339. if (diffAfter.watchedEpisodesAdded > 0 && isAnime()) updateEpisodesPage();
  6340. if ((diffAfter.videoTimesUpdated + diffAfter.videoTimesAdded) > 0 && isEpisode()) {
  6341. sendMessage({action:"change_time", time:getStorage().videoTimes.find(a => a.videoUrls.includes($('.embed-responsive-item')[0].src))?.time});
  6342. }
  6343. alert('[AnimePahe Improvements]\n\nImported!');
  6344. openShowDataModal();
  6345. });
  6346.  
  6347. openModal(openShowDataModal);
  6348. });
  6349. fileReader.readAsText(file);
  6350. });
  6351.  
  6352. function importData(data, importedData, save = true, ignored = {settings:{}}) {
  6353. const changed = {
  6354. linkListAdded: 0, // Session entries added
  6355. videoTimesAdded: 0, // Video progress entries added
  6356. videoTimesUpdated: 0, // Video progress times updated
  6357. bookmarksAdded: 0, // Bookmarks added
  6358. notificationsAdded: 0, // Anime added to episode feed
  6359. episodeFeedUpdated: 0, // Episodes either added to episode feed or that had their watched status updated
  6360. videoSpeedUpdated: 0, // Video speed entries added or updated
  6361. watchedEpisodesAdded: 0, // Amount of episodes marked as watched that are added
  6362. settingsUpdated: 0 // Settings updated
  6363. }
  6364.  
  6365. const defaultData = getDefaultData();
  6366.  
  6367. if (importedData.version !== defaultData.version) {
  6368. upgradeData(importedData, importedData.version);
  6369. }
  6370.  
  6371. for (const [key, value] of Object.entries(importedData)) {
  6372. if (defaultData[key] === undefined) continue;
  6373.  
  6374. if (!ignored.linkList && key === 'linkList') {
  6375. const added = [];
  6376. if (value.length === undefined) {
  6377. console.warn('[AnimePahe Improvements] Imported "linkList" has an incorrect format.');
  6378. continue;
  6379. }
  6380. value.forEach(g => {
  6381. if ((g.type === 'episode' && data.linkList.find(h => h.type === 'episode' && h.animeSession === g.animeSession && h.episodeSession === g.episodeSession) === undefined)
  6382. || (g.type === 'anime' && data.linkList.find(h => h.type === 'anime' && h.animeSession === g.animeSession) === undefined)) {
  6383. added.push(g);
  6384. changed.linkListAdded++;
  6385. }
  6386. });
  6387. data.linkList.splice(0,0,...added);
  6388. continue;
  6389. }
  6390. else if (!ignored.videoTimes && key === 'videoTimes') {
  6391. const added = [];
  6392. if (value.length === undefined) {
  6393. console.warn('[AnimePahe Improvements] Imported "videoTimes" has an incorrect format.');
  6394. continue;
  6395. }
  6396. value.forEach(g => {
  6397. const foundTime = data.videoTimes.find(h => h.videoUrls.includes(g.videoUrls[0]));
  6398. if (foundTime === undefined) {
  6399. added.push(g);
  6400. changed.videoTimesAdded++;
  6401. }
  6402. else if (foundTime.time < g.time) {
  6403. foundTime.time = g.time;
  6404. changed.videoTimesUpdated++;
  6405. }
  6406. });
  6407. data.videoTimes.splice(0,0,...added);
  6408. continue;
  6409. }
  6410. else if (!ignored.bookmarks && key === 'bookmarks') {
  6411. if (value.length === undefined) {
  6412. console.warn('[AnimePahe Improvements] Imported "bookmarks" has an incorrect format.');
  6413. continue;
  6414. }
  6415. value.forEach(g => {
  6416. if (data.bookmarks.find(h => h.id === g.id) !== undefined) return;
  6417. data.bookmarks.push(g);
  6418. changed.bookmarksAdded++;
  6419. });
  6420. continue;
  6421. }
  6422. else if (!ignored.notifications && key === 'notifications') {
  6423. if (value.anime?.length === undefined || value.episodes?.length === undefined) {
  6424. console.warn('[AnimePahe Improvements] Imported "notifications" has an incorrect format.');
  6425. continue;
  6426. }
  6427. value.anime.forEach(g => {
  6428. if (data.notifications.anime.find(h => h.id === g.id) !== undefined) return;
  6429. data.notifications.anime.push(g);
  6430. changed.notificationsAdded++;
  6431. });
  6432.  
  6433. // Checking if there exists any gap between the imported episodes and the existing ones
  6434. if (save) data.notifications.anime.forEach(g => {
  6435. const existingEpisodes = data.notifications.episodes.filter(a => (a.animeName === g.name || a.animeId === g.id));
  6436. const addedEpisodes = value.episodes.filter(a => (a.animeName === g.name || a.animeId === g.id));
  6437. if (existingEpisodes.length > 0 && (existingEpisodes[existingEpisodes.length-1].episode - addedEpisodes[0].episode > 1.5)) {
  6438. g.updateFrom = new Date(addedEpisodes[0].time + " UTC").getTime();
  6439. }
  6440. });
  6441.  
  6442. value.episodes.forEach(g => {
  6443. const anime = (() => {
  6444. if (g.animeId !== undefined) return data.notifications.anime.find(a => a.id === g.animeId);
  6445.  
  6446. const fromNew = data.notifications.anime.find(a => a.name === g.animeName);
  6447. if (fromNew !== undefined) return fromNew;
  6448. const id = value.anime.find(a => a.name === g.animeName);
  6449. return data.notifications.anime.find(a => a.id === id);
  6450. })();
  6451. if (anime === undefined) return;
  6452. if (g.animeName !== anime.name) g.animeName = anime.name;
  6453. if (g.animeId === undefined) g.animeId = anime.id;
  6454. const foundEpisode = data.notifications.episodes.find(h => h.animeId === anime.id && h.episode === g.episode);
  6455. if (foundEpisode !== undefined) {
  6456. if (g.watched === true && !foundEpisode.watched) {
  6457. foundEpisode.watched = true;
  6458. changed.episodeFeedUpdated++;
  6459. }
  6460. return;
  6461. }
  6462. data.notifications.episodes.push(g);
  6463. changed.episodeFeedUpdated++;
  6464. });
  6465. if (save) {
  6466. data.notifications.episodes.sort((a,b) => a.time < b.time ? 1 : -1);
  6467. if (value.episodes.length > 0) {
  6468. data.notifications.lastUpdated = new Date(value.episodes[0].time + " UTC").getTime();
  6469. }
  6470. }
  6471. continue;
  6472. }
  6473. else if (!ignored.watchedEpisodes && key === 'watched') {
  6474. const watched = decodeWatched(data.watched);
  6475. const watchedNew = decodeWatched(value);
  6476.  
  6477. if (value.length === undefined || (value.length > 0 && watchedNew.length === 0)) {
  6478. console.warn('[AnimePahe Improvements] Imported "watched" has an incorrect format.');
  6479. continue;
  6480. }
  6481.  
  6482. for (const anime of watchedNew) {
  6483. const found = watched.find(a => a.animeId === anime.animeId);
  6484.  
  6485. if (found === undefined) {
  6486. watched.push({
  6487. animeId: anime.animeId,
  6488. episodes: anime.episodes
  6489. });
  6490. changed.watchedEpisodesAdded += anime.episodes.length;
  6491. }
  6492. else for (const ep of anime.episodes) {
  6493. if (found.episodes.includes(ep)) continue;
  6494. found.episodes.push(ep);
  6495. changed.watchedEpisodesAdded++;
  6496. }
  6497. }
  6498.  
  6499. data.watched = encodeWatched(watched);
  6500. }
  6501. else if (!ignored.videoSpeed && key === 'videoSpeed') {
  6502. if (value.length === undefined) {
  6503. console.warn('[AnimePahe Improvements] Imported "videoSpeed" has an incorrect format.');
  6504. continue;
  6505. }
  6506.  
  6507. for (const anime of value) {
  6508. const found = data.videoSpeed.find(a => a.animeId === anime.animeId);
  6509. if (found !== undefined) {
  6510. if (found.speed === anime.speed) continue;
  6511. found.speed = anime.speed;
  6512. changed.videoSpeedUpdated++;
  6513. continue;
  6514. }
  6515.  
  6516. data.videoSpeed.push(anime);
  6517. changed.videoSpeedUpdated++;
  6518. }
  6519. }
  6520. else if (ignored.settings !== true && key === 'settings') {
  6521. for (const [key, value2] of Object.entries(value)) {
  6522. if (defaultData.settings[key] === undefined || ignored.settings[key] || ![true,false].includes(value2)) continue;
  6523. if (data.settings[key] === value2) continue;
  6524. data.settings[key] = value2;
  6525. changed.settingsUpdated++;
  6526. }
  6527. }
  6528. }
  6529.  
  6530. if (save) saveData(data);
  6531.  
  6532. return changed;
  6533. }
  6534.  
  6535. function getCleanType(type) {
  6536. if (type === 'linkList') return "Clean up older duplicate entries";
  6537. else if (type === 'videoTimes') return "Remove entries with no progress (0s)";
  6538. else return "[Message not found]";
  6539. }
  6540.  
  6541. function expandData(elem) {
  6542. const storage = getStorage();
  6543. const dataType = elem.attr('key');
  6544.  
  6545. elem.find('.anitracker-expand-data-icon').replaceWith(contractIcon);
  6546. const dataEntries = $('<div class="anitracker-modal-list"></div>').appendTo(elem.parent());
  6547.  
  6548. const cleanButton = ['linkList','videoTimes'].includes(dataType) ? `<button class="btn btn-secondary anitracker-clean-data-button anitracker-list-btn" style="text-wrap:nowrap;" title="${getCleanType(dataType)}">Clean Up</button>` : '';
  6549. $(`
  6550. <div class="btn-group anitracker-storage-filter">
  6551. <input title="Search within this storage entry" autocomplete="off" class="form-control anitracker-text-input-bar anitracker-modal-search" placeholder="Search">
  6552. <button dir="down" class="btn btn-secondary dropdown-toggle anitracker-reverse-order-button anitracker-list-btn" title="Sort direction (down is default, and means newest first)"></button>
  6553. ${cleanButton}
  6554. </div>
  6555. `).appendTo(dataEntries);
  6556. elem.parent().find('.anitracker-modal-search').focus();
  6557.  
  6558. elem.parent().find('.anitracker-modal-search').on('input', (e) => {
  6559. setTimeout(() => {
  6560. const query = $(e.target).val();
  6561. for (const entry of elem.parent().find('.anitracker-modal-list-entry')) {
  6562. if ($($(entry).find('a,span')[0]).text().toLowerCase().includes(query)) {
  6563. $(entry).show();
  6564. continue;
  6565. }
  6566. $(entry).hide();
  6567. }
  6568. }, 10);
  6569. });
  6570.  
  6571. elem.parent().find('.anitracker-clean-data-button').on('click', () => {
  6572. if (!confirm("[AnimePahe Improvements]\n\n" + getCleanType(dataType) + '?')) return;
  6573.  
  6574. const updatedStorage = getStorage();
  6575.  
  6576. const removed = [];
  6577. if (dataType === 'linkList') {
  6578. for (let i = 0; i < updatedStorage.linkList.length; i++) {
  6579. const link = updatedStorage.linkList[i];
  6580.  
  6581. const similar = updatedStorage.linkList.filter(a => a.animeName === link.animeName && a.episodeNum === link.episodeNum);
  6582. if (similar[similar.length-1] !== link) {
  6583. removed.push(link);
  6584. }
  6585. }
  6586. updatedStorage.linkList = updatedStorage.linkList.filter(a => !removed.includes(a));
  6587. }
  6588. else if (dataType === 'videoTimes') {
  6589. for (const timeEntry of updatedStorage.videoTimes) {
  6590. if (timeEntry.time > 5) continue;
  6591. removed.push(timeEntry);
  6592. }
  6593. updatedStorage.videoTimes = updatedStorage.videoTimes.filter(a => !removed.includes(a));
  6594. }
  6595.  
  6596. alert(`[AnimePahe Improvements]\n\nCleaned up ${removed.length} ${removed.length === 1 ? "entry" : "entries"}.`);
  6597.  
  6598. saveData(updatedStorage);
  6599. dataEntries.remove();
  6600. expandData(elem);
  6601. });
  6602.  
  6603. // When clicking the reverse order button
  6604. elem.parent().find('.anitracker-reverse-order-button').on('click', (e) => {
  6605. const btn = $(e.target);
  6606. if (btn.attr('dir') === 'down') {
  6607. btn.attr('dir', 'up');
  6608. btn.addClass('anitracker-up');
  6609. }
  6610. else {
  6611. btn.attr('dir', 'down');
  6612. btn.removeClass('anitracker-up');
  6613. }
  6614.  
  6615. const entries = [];
  6616. for (const entry of elem.parent().find('.anitracker-modal-list-entry')) {
  6617. entries.push(entry.outerHTML);
  6618. }
  6619. entries.reverse();
  6620. elem.parent().find('.anitracker-modal-list-entry').remove();
  6621. for (const entry of entries) {
  6622. $(entry).appendTo(elem.parent().find('.anitracker-modal-list'));
  6623. }
  6624. applyDeleteEvents();
  6625. });
  6626.  
  6627. function applyDeleteEvents() {
  6628. $('.anitracker-modal-list-entry .anitracker-delete-session-button').on('click', function() {
  6629. const storage = getStorage();
  6630.  
  6631. const href = $(this).parent().find('a').attr('href');
  6632. const animeSession = getAnimeSessionFromUrl(href);
  6633.  
  6634. if (isEpisode(href)) {
  6635. const episodeSession = getEpisodeSessionFromUrl(href);
  6636. storage.linkList = storage.linkList.filter(g => !(g.type === 'episode' && g.animeSession === animeSession && g.episodeSession === episodeSession));
  6637. saveData(storage);
  6638. }
  6639. else {
  6640. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === animeSession));
  6641. saveData(storage);
  6642. }
  6643.  
  6644. $(this).parent().remove();
  6645. });
  6646.  
  6647. $('.anitracker-modal-list-entry .anitracker-delete-progress-button').on('click', function() {
  6648. const storage = getStorage();
  6649. storage.videoTimes = storage.videoTimes.filter(g => !g.videoUrls.includes($(this).attr('lookForUrl')));
  6650. saveData(storage);
  6651.  
  6652. $(this).parent().remove();
  6653. });
  6654.  
  6655. $('.anitracker-modal-list-entry .anitracker-delete-watched-button').on('click', function() {
  6656. const id = +$(this).parent().attr('animeid');
  6657. removeWatchedAnime(id);
  6658.  
  6659. $(this).parent().remove();
  6660. });
  6661.  
  6662. $('.anitracker-modal-list-entry .anitracker-delete-speed-entry-button').on('click', function() {
  6663. const storage = getStorage();
  6664. const idString = $(this).attr('animeid');
  6665. if (idString !== undefined) storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== parseInt(idString));
  6666. else storage.videoSpeed = storage.videoSpeed.filter(g => g.animeName !== $(this).attr('animename'));
  6667. saveData(storage);
  6668.  
  6669. $(this).parent().remove();
  6670. });
  6671. }
  6672.  
  6673. if (dataType === 'linkList') {
  6674. [...storage.linkList].reverse().forEach(g => {
  6675. const name = g.animeName + (g.type === 'episode' ? (' - Episode ' + g.episodeNum) : '');
  6676. $(`
  6677. <div class="anitracker-modal-list-entry">
  6678. <a target="_blank" href="/${(g.type === 'episode' ? 'play/' : 'anime/') + g.animeSession + (g.type === 'episode' ? ('/' + g.episodeSession) : '')}" title="${toHtmlCodes(name)}">
  6679. ${toHtmlCodes(name)}
  6680. </a><br>
  6681. <button class="btn btn-danger anitracker-delete-session-button anitracker-flat-button" title="Delete this stored session">
  6682. <i class="fa fa-trash" aria-hidden="true"></i>
  6683. &nbsp;Delete
  6684. </button>
  6685. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6686. });
  6687.  
  6688. applyDeleteEvents();
  6689. }
  6690. else if (dataType === 'videoTimes') {
  6691. [...storage.videoTimes].reverse().forEach(g => {
  6692. $(`
  6693. <div class="anitracker-modal-list-entry">
  6694. <span>
  6695. ${g.animeId !== undefined ? `<a href="/a/${g.animeId}" target="_blank">${toHtmlCodes(g.animeName)}</a>` : toHtmlCodes(g.animeName)} - Episode ${g.episodeNum}
  6696. </span><br>
  6697. <span>
  6698. Current time: ${secondsToHMS(g.time)}
  6699. </span><br>
  6700. <button class="btn btn-danger anitracker-delete-progress-button anitracker-flat-button" lookForUrl="${g.videoUrls[0]}" title="Delete this video progress">
  6701. <i class="fa fa-trash" aria-hidden="true"></i>
  6702. &nbsp;Delete
  6703. </button>
  6704. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6705. });
  6706.  
  6707. applyDeleteEvents();
  6708. }
  6709. else if (dataType === 'watched') {
  6710. decodeWatched(storage.watched).forEach(g => {
  6711. const linkListObj = storage.linkList.find(a => a.animeId === g.animeId);
  6712. const episodes = g.episodes;
  6713. $(`
  6714. <div class="anitracker-modal-list-entry" animeid="${g.animeId}">
  6715. <span>
  6716. <a class="anitracker-watched-anime-id" href="/a/${g.animeId}" target="_blank">${linkListObj !== undefined ? toHtmlCodes(linkListObj.animeName) : `ID ${g.animeId}`}</a> - ${episodes.length} episode${episodes.length === 1 ? '' : 's'}
  6717. </span><br>
  6718. <span class="anitracker-watched-episodes-list">
  6719. ${episodes.join()}
  6720. </span><br>
  6721. ${linkListObj === undefined ? `<button class="btn btn-secondary anitracker-get-name-button anitracker-flat-button" title="Get the name for this anime">
  6722. <i class="fa fa-search" aria-hidden="true"></i>
  6723. &nbsp;Get Name
  6724. </button>` : ''}
  6725. <button class="btn btn-danger anitracker-delete-watched-button anitracker-flat-button" title="Delete this video progress">
  6726. <i class="fa fa-trash" aria-hidden="true"></i>
  6727. &nbsp;Delete
  6728. </button>
  6729. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6730. });
  6731.  
  6732. applyDeleteEvents();
  6733.  
  6734. $('.anitracker-get-name-button').on('click', function() {
  6735. const id = +$(this).parent().attr('animeid');
  6736. const spinner = $(`
  6737. <div class="anitracker-get-name-spinner anitracker-spinner" style="display: inline;vertical-align: bottom;">
  6738. <div class="spinner-border" role="status" style="height: 24px; width: 24px;">
  6739. <span class="sr-only">Loading...</span>
  6740. </div>
  6741. </div>`).insertAfter(this);
  6742. // Get the anime name from its ID
  6743. getAnimeNameFromId(id).then(name => {
  6744. $(this).prop('disabled', true);
  6745. spinner.remove();
  6746. if (name === undefined) {
  6747. alert("[AnimePahe Improvements]\n\nCouldn't get anime name");
  6748. return;
  6749. }
  6750. $(this).parent().find('.anitracker-watched-anime-id').text(name);
  6751. });
  6752. });
  6753. }
  6754. else if (dataType === 'videoSpeed') {
  6755. [...storage.videoSpeed].reverse().forEach(g => {
  6756. const identifier = (() => {
  6757. if (g.animeId !== undefined) return `animeid="${g.animeId}"`;
  6758. else return `animename="${toHtmlCodes(g.animeName)}"`;
  6759. })();
  6760. $(`
  6761. <div class="anitracker-modal-list-entry">
  6762. <span>
  6763. ${g.animeId !== undefined ? `<a href="/a/${g.animeId}" target="_blank">${toHtmlCodes(g.animeName)}</a>` : toHtmlCodes(g.animeName)}
  6764. </span><br>
  6765. <span>
  6766. Playback speed: ${g.speed}
  6767. </span><br>
  6768. <button class="btn btn-danger anitracker-delete-speed-entry-button anitracker-flat-button" ${identifier} title="Delete this video speed entry">
  6769. <i class="fa fa-trash" aria-hidden="true"></i>
  6770. &nbsp;Delete
  6771. </button>
  6772. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6773. });
  6774.  
  6775. applyDeleteEvents();
  6776. }
  6777.  
  6778. elem.addClass('anitracker-expanded');
  6779. }
  6780.  
  6781. function contractData(elem) {
  6782. elem.find('.anitracker-expand-data-icon').replaceWith(expandIcon);
  6783.  
  6784. elem.parent().find('.anitracker-modal-list').remove();
  6785.  
  6786. elem.removeClass('anitracker-expanded');
  6787. elem.blur();
  6788. }
  6789.  
  6790. openModal();
  6791. }
  6792.  
  6793. $('#anitracker-show-data').on('click', openShowDataModal);
  6794. }
  6795.  
  6796. addGeneralButtons();
  6797. if (isEpisode()) {
  6798. $(`
  6799. <span style="margin-left: 30px;"><i class="fa fa-files-o" aria-hidden="true"></i>&nbsp;Copy:</span>
  6800. <div class="btn-group">
  6801. <button class="btn btn-dark anitracker-copy-button" copy="link" data-placement="top" data-content="Copied!">Link</button>
  6802. </div>
  6803. <div class="btn-group" style="margin-right:30px;">
  6804. <button class="btn btn-dark anitracker-copy-button" copy="link-time" data-placement="top" data-content="Copied!">Link & Time</button>
  6805. </div>`).appendTo('#anitracker');
  6806. addOptionSwitch('autoPlayNext','Auto-Play Next','Automatically go to the next episode when the current one has ended.','#anitracker');
  6807.  
  6808. $('.anitracker-copy-button').on('click', (e) => {
  6809. const targ = $(e.currentTarget);
  6810. const type = targ.attr('copy');
  6811. const name = encodeURIComponent(getAnimeName());
  6812. const episode = getEpisodeNum();
  6813. if (['link','link-time'].includes(type)) {
  6814. navigator.clipboard.writeText(window.location.origin + '/customlink?a=' + name + '&e=' + episode + (type !== 'link-time' ? '' : ('&t=' + currentEpisodeTime.toString())));
  6815. }
  6816. targ.popover('show');
  6817. setTimeout(() => {
  6818. targ.popover('hide');
  6819. }, 1000);
  6820. });
  6821. }
  6822.  
  6823. if (initialStorage.settings.autoDelete === true && isEpisode() && paramArray.find(a => a[0] === 'ref' && a[1] === 'customlink') === undefined) {
  6824. const animeData = getAnimeData();
  6825. deleteEpisodesFromTracker(getEpisodeNum(), animeData.title, animeData.id);
  6826. }
  6827.  
  6828. function updateSwitches() {
  6829. const storage = getStorage();
  6830.  
  6831. for (const s of optionSwitches) {
  6832. const different = s.value !== storage.settings[s.optionId];
  6833. if (!different) continue;
  6834.  
  6835. s.value = storage.settings[s.optionId];
  6836. $(`#anitracker-${s.switchId}-switch`).prop('checked', s.value === true);
  6837.  
  6838. if (s.value === true) {
  6839. if (s.onEvent !== undefined) s.onEvent();
  6840. }
  6841. else if (s.offEvent !== undefined) {
  6842. s.offEvent();
  6843. }
  6844. }
  6845. }
  6846.  
  6847. updateSwitches();
  6848.  
  6849. function addOptionSwitch(optionId, name, desc = '', parent = '#anitracker-modal-body') {
  6850. const option = optionSwitches.find(s => s.optionId === optionId);
  6851.  
  6852. $(`
  6853. <div class="custom-control custom-switch anitracker-switch" id="anitracker-${option.switchId}" title="${desc}">
  6854. <input type="checkbox" class="custom-control-input" id="anitracker-${option.switchId}-switch">
  6855. <label class="custom-control-label" for="anitracker-${option.switchId}-switch">${name}</label>
  6856. </div>`).appendTo(parent);
  6857. const switc = $(`#anitracker-${option.switchId}-switch`);
  6858. switc.prop('checked', option.value);
  6859.  
  6860. const events = [option.onEvent, option.offEvent];
  6861.  
  6862. switc.on('change', (e) => {
  6863. const checked = $(e.currentTarget).is(':checked');
  6864. const storage = getStorage();
  6865.  
  6866. if (checked !== storage.settings[optionId]) {
  6867. storage.settings[optionId] = checked;
  6868. option.value = checked;
  6869. saveData(storage);
  6870. }
  6871.  
  6872. if (checked) {
  6873. if (events[0] !== undefined) events[0]();
  6874. }
  6875. else if (events[1] !== undefined) events[1]();
  6876. });
  6877. }
  6878.  
  6879. $(`
  6880. <div class="anitracker-download-spinner anitracker-spinner" style="display: none;">
  6881. <div class="spinner-border" role="status">
  6882. <span class="sr-only">Loading...</span>
  6883. </div>
  6884. </div>`).prependTo('#downloadMenu,#episodeMenu');
  6885. $('.prequel img,.sequel img').attr('loading','');
  6886. }