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.3.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. * Allows 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 options 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-modal-bottom-buttons {
  1539. display: flex;
  1540. gap: 5px;
  1541. flex-wrap: wrap;
  1542. }
  1543. .anitracker-relation-link {
  1544. text-overflow: ellipsis;
  1545. overflow: hidden;
  1546. }
  1547. .anitracker-spinner {
  1548. color: #d5015b;
  1549. }
  1550. .anitracker-spinner>span {
  1551. color: white;
  1552. }
  1553. #anitracker-cover-spinner .spinner-border {
  1554. width:2rem;
  1555. height:2rem;
  1556. }
  1557. .anime-cover {
  1558. display: flex;
  1559. justify-content: center;
  1560. align-items: center;
  1561. image-rendering: optimizequality;
  1562. }
  1563. .anitracker-filter-input {
  1564. width: 12.2rem;
  1565. display: inline-block;
  1566. cursor: text;
  1567. }
  1568. .anitracker-filter-input > div {
  1569. height:56px;
  1570. width:100%;
  1571. border-bottom: 2px solid #454d54;
  1572. overflow-y: auto;
  1573. }
  1574. .anitracker-filter-input.active > div {
  1575. border-color: rgb(213, 1, 91);
  1576. }
  1577. .anitracker-filter-rules {
  1578. background: black;
  1579. border: 1px solid #bbb;
  1580. color: #bbb;
  1581. padding: 5px;
  1582. float: right;
  1583. border-radius: 5px;
  1584. font-size: .8em;
  1585. width: 2em;
  1586. aspect-ratio: 1;
  1587. margin-bottom: -10px;
  1588. z-index: 1;
  1589. position: relative;
  1590. min-height: 0;
  1591. }
  1592. .anitracker-filter-rules>i {
  1593. vertical-align: super;
  1594. }
  1595. .anitracker-filter-rules.anitracker-active {
  1596. border-color: rgb(213, 1, 91);
  1597. }
  1598. .anitracker-filter-rules:hover, .anitracker-filter-rules:focus-visible {
  1599. background: white;
  1600. color: black;
  1601. border-color: white;
  1602. }
  1603. .anitracker-filter-input-search {
  1604. position: absolute;
  1605. max-width: 150px;
  1606. max-height: 45px;
  1607. min-width: 150px;
  1608. min-height: 45px;
  1609. overflow-wrap: break-word;
  1610. overflow-y: auto;
  1611. }
  1612. .anitracker-filter-input .placeholder {
  1613. color: #999;
  1614. position: absolute;
  1615. z-index: -1;
  1616. }
  1617. .anitracker-filter-icon {
  1618. padding: 0;
  1619. padding-right: 4px;
  1620. border-radius: 12px;
  1621. display: inline-block;
  1622. cursor: pointer;
  1623. border: 2px solid white;
  1624. margin-right: 5px;
  1625. transition: background-color .3s, border-color .3s;
  1626. vertical-align: text-top;
  1627. font-size: .95em;
  1628. }
  1629. .anitracker-filter-icon>i {
  1630. margin: 2px;
  1631. margin-left: 3px;
  1632. font-size: .8em;
  1633. }
  1634. .anitracker-filter-icon.included {
  1635. background-color: rgba(20, 113, 30, 0.64);
  1636. border-color: rgb(62, 181, 62);
  1637. }
  1638. .anitracker-filter-icon.included>i {
  1639. color: rgb(83, 255, 83);
  1640. }
  1641. .anitracker-filter-icon.excluded {
  1642. background-color: rgba(187, 62, 62, 0.41);
  1643. border-color: #d75a5a;
  1644. }
  1645. .anitracker-filter-icon.excluded>i {
  1646. color: rgb(227, 96, 96);
  1647. }
  1648. .anitracker-filter-icon:hover {
  1649. border-color: white;
  1650. }
  1651. #anitracker-settings-invert-switch:checked ~ .custom-control-label::before {
  1652. border-color: red;
  1653. background-color: red;
  1654. }
  1655. #anitracker-settings-invert-switch:checked[disabled=""] ~ .custom-control-label::before {
  1656. border-color: #e88b8b;
  1657. background-color: #e88b8b;
  1658. }
  1659. .anitracker-text-input {
  1660. display: inline-block;
  1661. height: 1em;
  1662. line-break: anywhere;
  1663. min-width: 50px;
  1664. }
  1665. .anitracker-text-input-bar {
  1666. background: #333;
  1667. box-shadow: none;
  1668. color: #bbb;
  1669. }
  1670. .anitracker-text-input-bar:focus {
  1671. border-color: #d5015b;
  1672. background: none;
  1673. box-shadow: none;
  1674. color: #ddd;
  1675. }
  1676. .anitracker-text-input-bar[disabled=""] {
  1677. background: rgb(89, 89, 89);
  1678. border-color: gray;
  1679. cursor: not-allowed;
  1680. }
  1681. .anitracker-applied-filters {
  1682. display: inline-block;
  1683. }
  1684. .anitracker-placeholder {
  1685. color: gray;
  1686. }
  1687. .anitracker-filter-dropdown>button {
  1688. transition: background-color .3s;
  1689. }
  1690. .anitracker-filter-dropdown>button.included {
  1691. background-color: rgb(6, 130, 54);
  1692. }
  1693. .anitracker-filter-dropdown>button.included:focus {
  1694. border: 2px dashed rgb(141, 234, 141);
  1695. }
  1696. .anitracker-filter-dropdown>button.excluded {
  1697. background-color: rgb(117, 17, 17);
  1698. }
  1699. .anitracker-filter-dropdown>button.excluded:focus {
  1700. border: 2px dashed rgb(215, 90, 90);
  1701. }
  1702. .anitracker-filter-dropdown>button.anitracker-active:focus {
  1703. border: 2px dashed #ffd7eb;
  1704. }
  1705. #anitracker-season-copy-to-lower {
  1706. color:white;
  1707. margin-left:14px;
  1708. border-radius:5px;
  1709. }
  1710. .anitracker-filter-spinner.small {
  1711. display: inline-flex;
  1712. margin-left: 10px;
  1713. justify-content: center;
  1714. align-items: center;
  1715. vertical-align: bottom;
  1716. }
  1717. .anitracker-filter-spinner.screen {
  1718. width:100%;
  1719. height:100%;
  1720. background-color:rgba(0, 0, 0, 0.9);
  1721. position:fixed;
  1722. z-index:999;
  1723. display:flex;
  1724. justify-content:center;
  1725. align-items:center;
  1726. }
  1727. .anitracker-filter-spinner.screen .spinner-border {
  1728. width:5rem;
  1729. height:5rem;
  1730. border-width: 10px;
  1731. }
  1732. .anitracker-filter-spinner>span {
  1733. position: absolute;
  1734. font-weight: bold;
  1735. }
  1736. .anitracker-filter-spinner.small>span {
  1737. font-size: .5em;
  1738. }
  1739. .anitracker-filter-rule-selection {
  1740. margin-bottom: 2px;
  1741. display: grid;
  1742. grid-template-columns: 1.5em 32% auto;
  1743. align-items: center;
  1744. grid-gap: 5px;
  1745. border-radius: 20px;
  1746. padding: 5px;
  1747. }
  1748. .anitracker-filter-rule-selection[disabled=""]>* {
  1749. opacity: 0.5;
  1750. pointer-events: none;
  1751. }
  1752. .anitracker-filter-rule-selection>i {
  1753. text-align: center;
  1754. border-radius: 35%;
  1755. padding: 2px;
  1756. aspect-ratio: 1;
  1757. }
  1758. .anitracker-filter-rule-selection>i::before {
  1759. vertical-align: middle;
  1760. }
  1761. .anitracker-filter-rule-selection>.fa-plus {
  1762. color: rgb(72, 223, 58);
  1763. background-color: #148214;
  1764. }
  1765. .anitracker-filter-rule-selection>.fa-minus {
  1766. color: #ff0000;
  1767. background-color: #911212;
  1768. }
  1769. .anitracker-filter-rule-selection button {
  1770. padding: 0;
  1771. padding-bottom: 1px;
  1772. width: 2.5em;
  1773. height: 2em;
  1774. background-color: var(--secondary);
  1775. border: 3px solid var(--dark);
  1776. border-radius: 10px;
  1777. outline: rgb(94, 96, 100) solid 3px;
  1778. margin: 5px;
  1779. color: white;
  1780. }
  1781. .anitracker-filter-rule-selection button.anitracker-active {
  1782. outline: rgb(213, 1, 91) solid 3px;
  1783. }
  1784. .anitracker-filter-rule-selection button:hover:not([disabled=""]), .anitracker-filter-rule-selection button:focus-visible:not([disabled=""]) {
  1785. outline: white solid 3px;
  1786. }
  1787. .anitracker-flat-button {
  1788. padding-top: 0;
  1789. padding-bottom: 0;
  1790. }
  1791. .anitracker-list-btn {
  1792. height: 42px;
  1793. border-radius: 7px!important;
  1794. color: #ddd!important;
  1795. margin-left: 10px!important;
  1796. }
  1797. .anitracker-reverse-order-button {
  1798. font-size: 2em;
  1799. }
  1800. .anitracker-reverse-order-button::after {
  1801. vertical-align: 20px;
  1802. }
  1803. .anitracker-reverse-order-button.anitracker-up::after {
  1804. border-top: 0;
  1805. border-bottom: .3em solid;
  1806. vertical-align: 22px;
  1807. }
  1808. #anitracker-time-search-button {
  1809. float: right;
  1810. }
  1811. #anitracker-time-search-button svg {
  1812. width: 24px;
  1813. vertical-align: bottom;
  1814. }
  1815. .anitracker-season-group {
  1816. display: grid;
  1817. grid-template-columns: 10% 30% 20% 10%;
  1818. margin-bottom: 5px;
  1819. }
  1820. .anitracker-season-group .btn-group {
  1821. margin-left: 5px;
  1822. }
  1823. .anitracker-season-group>span {
  1824. align-self: center;
  1825. }
  1826. a.youtube-preview::before {
  1827. -webkit-transition: opacity .2s linear!important;
  1828. -moz-transition: opacity .2s linear!important;
  1829. transition: opacity .2s linear!important;
  1830. }
  1831. .anitracker-replaced-cover {background-position-y: 25%;\n}
  1832. .anitracker-text-button {
  1833. color:#d5015b;
  1834. cursor:pointer;
  1835. user-select:none;
  1836. }
  1837. .anitracker-text-button:hover, .anitracker-text-button:focus-visible {
  1838. color:white;
  1839. }
  1840. .nav-search {
  1841. float: left!important;
  1842. }
  1843. .anitracker-title-icon {
  1844. margin-left: 1rem!important;
  1845. opacity: .8!important;
  1846. color: #ff006c!important;
  1847. font-size: 2rem!important;
  1848. vertical-align: middle;
  1849. cursor: pointer;
  1850. padding: 0;
  1851. box-shadow: none!important;
  1852. }
  1853. .anitracker-title-icon:hover {
  1854. opacity: 1!important;
  1855. }
  1856. .anitracker-title-icon-check {
  1857. color: white;
  1858. margin-left: -.7rem!important;
  1859. font-size: 1rem!important;
  1860. vertical-align: super;
  1861. text-shadow: none;
  1862. opacity: 1!important;
  1863. }
  1864. .anitracker-header {
  1865. display: flex;
  1866. justify-content: left;
  1867. gap: 18px;
  1868. flex-grow: 0.05;
  1869. }
  1870. .anitracker-header-button {
  1871. color: white;
  1872. background: none;
  1873. border: 2px solid white;
  1874. border-radius: 5px;
  1875. width: 2rem;
  1876. }
  1877. .anitracker-header-button:hover {
  1878. border-color: #ff006c;
  1879. color: #ff006c;
  1880. }
  1881. .anitracker-header-button:focus {
  1882. border-color: #ff006c;
  1883. color: #ff006c;
  1884. }
  1885. .anitracker-header-notifications-circle {
  1886. color: rgb(255, 0, 108);
  1887. margin-left: -.3rem;
  1888. font-size: 0.7rem;
  1889. position: absolute;
  1890. }
  1891. .anitracker-notification-item .anitracker-main-text {
  1892. color: rgb(153, 153, 153);
  1893. }
  1894. .anitracker-notification-item-unwatched {
  1895. background-color: rgb(119, 62, 70);
  1896. }
  1897. .anitracker-notification-item-unwatched .anitracker-main-text {
  1898. color: white!important;
  1899. }
  1900. .anitracker-notification-item-unwatched .anitracker-subtext {
  1901. color: white!important;
  1902. }
  1903. .anitracker-watched-toggle {
  1904. font-size: 1.7em;
  1905. float: right;
  1906. margin-right: 5px;
  1907. margin-top: 5px;
  1908. cursor: pointer;
  1909. background-color: rgb(64, 64, 72);
  1910. padding: 5px;
  1911. border-radius: 5px;
  1912. }
  1913. .anitracker-watched-toggle:hover,.anitracker-watched-toggle:focus {
  1914. box-shadow: 0 0 0 .2rem rgb(255, 255, 255);
  1915. }
  1916. #anitracker-replace-cover {
  1917. z-index: 99;
  1918. right: 10px;
  1919. position: absolute;
  1920. bottom: 6em;
  1921. }
  1922. header.main-header nav .main-nav li.nav-item > a:focus {
  1923. color: #fff;
  1924. background-color: #bc0150;
  1925. }
  1926. .theatre-settings .dropup .btn:focus {
  1927. box-shadow: 0 0 0 .15rem rgb(100, 100, 100)!important;
  1928. }
  1929. .anitracker-episode-time {
  1930. margin-left: 5%;
  1931. font-size: 0.75rem!important;
  1932. cursor: default!important;
  1933. }
  1934. .anitracker-episode-time:hover {
  1935. text-decoration: none!important;
  1936. }
  1937. .anitracker-episode-progress {
  1938. height: 8px;
  1939. position: absolute;
  1940. bottom: 0;
  1941. background-color: #bc0150;
  1942. z-index: 1;
  1943. }
  1944. .anitracker-episode-menu-button {
  1945. top: 0;
  1946. position: absolute;
  1947. right: 0;
  1948. width: 32px;
  1949. height: 32px;
  1950. z-index: 1;
  1951. color: white;
  1952. background: none;
  1953. border: 0;
  1954. transition: background-color .3s ease;
  1955. border-radius: 10%;
  1956. border-top-right-radius: 0;
  1957. }
  1958. .anitracker-episode-menu-button>svg {
  1959. width: 6px;
  1960. display: block;
  1961. margin: auto;
  1962. stroke: #424242;
  1963. stroke-width: 32px;
  1964. }
  1965. .anitracker-episode-menu-button:hover, .anitracker-episode-menu-button:focus {
  1966. background-color: rgba(0,0,0,0.8);
  1967. color: #bc0150;
  1968. }
  1969. .anitracker-episode-menu-button:hover>svg, .anitracker-episode-menu-button:focus>svg {
  1970. stroke-width: 0;
  1971. }
  1972. .anitracker-episode-menu-dropdown {
  1973. z-index: 2;
  1974. right:0;
  1975. left: auto;
  1976. max-width: 160px;
  1977. top: 32px;
  1978. }
  1979. .anitracker-watched-episodes-list {
  1980. display: inline-block;
  1981. max-width: 500px;
  1982. overflow: hidden;
  1983. text-overflow: ellipsis;
  1984. color: gray;
  1985. }
  1986. .index>* {
  1987. width: 100%;
  1988. }
  1989. @media screen and (min-width: 1375px) {
  1990. .theatre.anitracker-theatre-mode {
  1991. margin-top: 10px!important;
  1992. }
  1993. .theatre.anitracker-theatre-mode>* {
  1994. max-width: 81%!important;
  1995. }
  1996. }
  1997. @keyframes anitracker-modalOpen {
  1998. 0% {
  1999. transform: scale(0.5);
  2000. }
  2001. 50% {
  2002. transform: scale(1.07);
  2003. }
  2004. 100% {
  2005. transform: scale(1);
  2006. }
  2007. }
  2008. @keyframes anitracker-fadeIn {
  2009. from {
  2010. opacity: 0;
  2011. }
  2012. to {
  2013. opacity: 1;
  2014. }
  2015. }
  2016. @keyframes anitracker-spin {
  2017. from {
  2018. transform: rotate(0deg);
  2019. }
  2020. to {
  2021. transform: rotate(360deg);
  2022. }
  2023. }
  2024. `;
  2025.  
  2026. applyCssSheet(_css);
  2027.  
  2028.  
  2029. const optionSwitches = [
  2030. {
  2031. optionId: 'autoDelete',
  2032. switchId: 'auto-delete',
  2033. value: initialStorage.settings.autoDelete
  2034. },
  2035. {
  2036. optionId: 'theatreMode',
  2037. switchId: 'theatre-mode',
  2038. value: initialStorage.settings.theatreMode,
  2039. onEvent: () => {
  2040. theatreMode(true);
  2041. },
  2042. offEvent: () => {
  2043. theatreMode(false);
  2044. }
  2045. },
  2046. {
  2047. optionId: 'hideThumbnails',
  2048. switchId: 'hide-thumbnails',
  2049. value: initialStorage.settings.hideThumbnails,
  2050. onEvent: hideThumbnails,
  2051. offEvent: () => {
  2052. $('.main').removeClass('anitracker-hide-thumbnails');
  2053. }
  2054. },
  2055. {
  2056. optionId: 'bestQuality',
  2057. switchId: 'best-quality',
  2058. value: initialStorage.settings.bestQuality,
  2059. onEvent: bestVideoQuality
  2060. },
  2061. {
  2062. optionId: 'autoDownload',
  2063. switchId: 'auto-download',
  2064. value: initialStorage.settings.autoDownload
  2065. },
  2066. {
  2067. optionId: 'autoPlayNext',
  2068. switchId: 'autoplay-next',
  2069. value: initialStorage.settings.autoPlayNext
  2070. },
  2071. {
  2072. optionId: 'autoPlayVideo',
  2073. switchId: 'autoplay-video',
  2074. value: initialStorage.settings.autoPlayVideo
  2075. },
  2076. {
  2077. optionId: 'seekThumbnails',
  2078. switchId: 'seek-thumbnails',
  2079. value: initialStorage.settings.seekThumbnails
  2080. },
  2081. {
  2082. optionId: 'seekPoints',
  2083. switchId: 'seek-points',
  2084. value: initialStorage.settings.seekPoints,
  2085. onEvent: () => {
  2086. sendMessage({action:'setting_changed',type:'seek_points',value:true});
  2087. },
  2088. offEvent: () => {
  2089. sendMessage({action:'setting_changed',type:'seek_points',value:false});
  2090. }
  2091. },
  2092. {
  2093. optionId: 'skipButton',
  2094. switchId: 'skip-button',
  2095. value: initialStorage.settings.skipButton,
  2096. onEvent: () => {
  2097. sendMessage({action:'setting_changed',type:'skip_button',value:true});
  2098. },
  2099. offEvent: () => {
  2100. sendMessage({action:'setting_changed',type:'skip_button',value:false});
  2101. }
  2102. },
  2103. {
  2104. optionId: 'copyScreenshots',
  2105. switchId: 'copy-screenshots',
  2106. value: initialStorage.settings.copyScreenshots,
  2107. onEvent: () => {
  2108. sendMessage({action:'setting_changed',type:'screenshot_mode',value:'copy'});
  2109. },
  2110. offEvent: () => {
  2111. sendMessage({action:'setting_changed',type:'screenshot_mode',value:'download'});
  2112. }
  2113. },
  2114. {
  2115. optionId: 'reduceMotion',
  2116. switchId: 'reduced-motion',
  2117. value: initialStorage.settings.reduceMotion
  2118. }];
  2119.  
  2120. const cachedAnimeData = [];
  2121.  
  2122. // Things that update when focusing this tab
  2123. $(document).on('visibilitychange', () => {
  2124. if (document.hidden) return;
  2125. updatePage();
  2126. });
  2127.  
  2128. function updatePage() {
  2129. updateSwitches();
  2130.  
  2131. const storage = getStorage();
  2132. const data = url.includes('/anime/') ? getAnimeData() : undefined;
  2133.  
  2134. if (data !== undefined) {
  2135. const isBookmarked = storage.bookmarks.find(a => a.id === data.id) !== undefined;
  2136. if (isBookmarked) $('.anitracker-bookmark-toggle .anitracker-title-icon-check').show();
  2137. else $('.anitracker-bookmark-toggle .anitracker-title-icon-check').hide();
  2138.  
  2139. const hasNotifications = storage.notifications.anime.find(a => a.id === data.id) !== undefined;
  2140. if (hasNotifications) $('.anitracker-notifications-toggle .anitracker-title-icon-check').show();
  2141. else $('.anitracker-notifications-toggle .anitracker-title-icon-check').hide();
  2142. }
  2143.  
  2144. if (!modalIsOpen() || $('.anitracker-view-notif-animes').length === 0) return;
  2145.  
  2146. for (const item of $('.anitracker-notification-item-unwatched')) {
  2147. const entry = storage.notifications.episodes.find(a => a.animeId === +$(item).attr('anime-data') && a.episode === +$(item).attr('episode-data') && a.watched === true);
  2148. if (entry === undefined) continue;
  2149. $(item).removeClass('anitracker-notification-item-unwatched');
  2150. const eye = $(item).find('.anitracker-watched-toggle');
  2151. eye.replaceClass('fa-eye', 'fa-eye-slash');
  2152. }
  2153. }
  2154.  
  2155. function theatreMode(on) {
  2156. if (on) $('.theatre').addClass('anitracker-theatre-mode');
  2157. else $('.theatre').removeClass('anitracker-theatre-mode');
  2158. }
  2159.  
  2160. function playAnimation(elem, anim, type = '', duration) {
  2161. return new Promise(resolve => {
  2162. elem.css('animation', `anitracker-${anim} ${duration || animationTimes[anim]}s forwards linear ${type}`);
  2163. if (animationTimes[anim] === undefined) resolve();
  2164. setTimeout(() => {
  2165. elem.css('animation', '');
  2166. resolve();
  2167. }, animationTimes[anim] * 1000);
  2168. });
  2169. }
  2170.  
  2171. let modalCloseFunction = closeModal;
  2172. // AnimePahe Improvements modal
  2173. function addModal() {
  2174. $(`
  2175. <div id="anitracker-modal" tabindex="-1">
  2176. <div id="anitracker-modal-content">
  2177. <i tabindex="0" id="anitracker-modal-close" class="fa fa-close" title="Close modal">
  2178. </i>
  2179. <div id="anitracker-modal-body"></div>
  2180. </div>
  2181. </div>`).insertBefore('.main-header');
  2182.  
  2183. $('#anitracker-modal').on('click', (e) => {
  2184. if (e.target !== e.currentTarget) return;
  2185. modalCloseFunction();
  2186. });
  2187.  
  2188. $('#anitracker-modal-close').on('click keydown', (e) => {
  2189. if (e.type === 'keydown' && e.key !== "Enter") return;
  2190. modalCloseFunction();
  2191. });
  2192. }
  2193. addModal();
  2194.  
  2195. function openModal(closeFunction = closeModal) {
  2196. if (closeFunction !== closeModal) $('#anitracker-modal-close').replaceClass('fa-close', 'fa-arrow-left');
  2197. else $('#anitracker-modal-close').replaceClass('fa-arrow-left', 'fa-close');
  2198.  
  2199. const storage = getStorage();
  2200.  
  2201. return new Promise(resolve => {
  2202. if (storage.settings.reduceMotion !== true) {
  2203. playAnimation($('#anitracker-modal-content'), 'modalOpen');
  2204. playAnimation($('#anitracker-modal'), 'fadeIn').then(() => {
  2205. $('#anitracker-modal').focus();
  2206. resolve();
  2207. });
  2208. }
  2209. else {
  2210. $('#anitracker-modal').focus();
  2211. resolve();
  2212. }
  2213.  
  2214. $('#anitracker-modal').css('display','flex');
  2215. modalCloseFunction = closeFunction;
  2216. });
  2217. }
  2218.  
  2219. function closeModal() {
  2220. const storage = getStorage();
  2221. if (storage.settings.reduceMotion === true || $('#anitracker-modal').css('animation') !== 'none') {
  2222. $('#anitracker-modal').hide();
  2223. return;
  2224. }
  2225.  
  2226. playAnimation($('#anitracker-modal'), 'fadeIn', 'reverse', 0.1).then(() => {
  2227. $('#anitracker-modal').hide();
  2228. });
  2229. }
  2230.  
  2231. function modalIsOpen() {
  2232. return $('#anitracker-modal').is(':visible');
  2233. }
  2234.  
  2235. let currentEpisodeTime = 0;
  2236. // Messages received from iframe
  2237. if (isEpisode()) {
  2238. window.onmessage = function(e) {
  2239. const data = e.data;
  2240.  
  2241. if (typeof(data) === 'number') {
  2242. currentEpisodeTime = Math.trunc(data);
  2243. return;
  2244. }
  2245.  
  2246. const action = data.action;
  2247. if (action === 'id_request') {
  2248. sendMessage({action:"id_response",id:getAnimeData().id});
  2249. }
  2250. else if (action === 'anidb_id_request') {
  2251. getAnidbId(data.id).then(result => {
  2252. sendMessage({action:"anidb_id_response",id:result,originalId:data.id});
  2253. });
  2254. }
  2255. else if (action === 'video_url_request') {
  2256. const selected = {
  2257. src: undefined,
  2258. res: undefined,
  2259. audio: undefined
  2260. }
  2261. for (const btn of $('#resolutionMenu>button')) {
  2262. const src = $(btn).data('src');
  2263. const res = +$(btn).data('resolution');
  2264. const audio = $(btn).data('audio');
  2265. if (selected.src !== undefined && selected.res < res) continue;
  2266. if (selected.audio !== undefined && audio === 'jp' && selected.res <= res) continue; // Prefer dubs, since they don't have subtitles
  2267. selected.src = src;
  2268. selected.res = res;
  2269. selected.audio = audio;
  2270. }
  2271. if (selected.src === undefined) {
  2272. console.error("[AnimePahe Improvements] Didn't find video URL");
  2273. return;
  2274. }
  2275. console.log('[AnimePahe Improvements] Found lowest resolution URL ' + selected.src);
  2276. sendMessage({action:"video_url_response", url:selected.src});
  2277. }
  2278. else if (action === 'key') {
  2279. if (data.key === 't') {
  2280. toggleTheatreMode();
  2281. }
  2282. }
  2283. else if (data === 'ended') {
  2284. const storage = getStorage();
  2285. if (storage.settings.autoPlayNext !== true) return;
  2286. const elem = $('.sequel a');
  2287. if (elem.length > 0) elem[0].click();
  2288. }
  2289. else if (action === 'next') {
  2290. const elem = $('.sequel a');
  2291. if (elem.length > 0) elem[0].click();
  2292. }
  2293. else if (action === 'previous') {
  2294. const elem = $('.prequel a');
  2295. if (elem.length > 0) elem[0].click();
  2296. }
  2297. };
  2298. }
  2299.  
  2300. function sendMessage(message) {
  2301. const iframe = $('.embed-responsive-item');
  2302. if (iframe.length === 0) return;
  2303. iframe[0].contentWindow.postMessage(message,'*');
  2304. }
  2305.  
  2306. function toggleTheatreMode() {
  2307. const storage = getStorage();
  2308. theatreMode(!storage.settings.theatreMode);
  2309.  
  2310. storage.settings.theatreMode = !storage.settings.theatreMode;
  2311. saveData(storage);
  2312. updateSwitches();
  2313. }
  2314.  
  2315. async function getAnidbId(paheId) {
  2316. return new Promise(resolve => {
  2317. const req = new XMLHttpRequest();
  2318. req.open('GET', `/a/${paheId}`, true);
  2319. req.onload = () => {
  2320. for (const link of $(req.response).find('.external-links a')) {
  2321. const elem = $(link);
  2322. if (elem.text() !== 'AniDB') continue;
  2323. resolve(/\/\/anidb.net\/anime\/(\d+)/.exec(elem.attr('href'))[1]);
  2324. }
  2325. resolve(undefined);
  2326. }
  2327. req.send();
  2328. })
  2329. }
  2330.  
  2331. async function getAnimeNameFromId(id) {
  2332. return new Promise(resolve => {
  2333. const req = new XMLHttpRequest();
  2334. req.open('GET', `/a/${id}`, true);
  2335. req.onload = () => {
  2336. if (!isAnime(new URL(req.responseURL).pathname)) {
  2337. resolve(undefined);
  2338. return;
  2339. }
  2340. resolve($($(req.response).find('.title-wrapper h1 span')[0]).text());
  2341. }
  2342. req.send();
  2343. })
  2344. }
  2345.  
  2346. function getSeasonValue(season) {
  2347. return ({winter:0, spring:1, summer:2, fall:3})[season.toLowerCase()];
  2348. }
  2349.  
  2350. function getSeasonName(season) {
  2351. return ["winter","spring","summer","fall"][season];
  2352. }
  2353.  
  2354. function stringSimilarity(s1, s2) {
  2355. let longer = s1;
  2356. let shorter = s2;
  2357. if (s1.length < s2.length) {
  2358. longer = s2;
  2359. shorter = s1;
  2360. }
  2361. const longerLength = longer.length;
  2362. if (longerLength == 0) {
  2363. return 1.0;
  2364. }
  2365. return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength);
  2366. }
  2367.  
  2368. function editDistance(s1, s2) {
  2369. s1 = s1.toLowerCase();
  2370. s2 = s2.toLowerCase();
  2371. const costs = [];
  2372. for (let i = 0; i <= s1.length; i++) {
  2373. let lastValue = i;
  2374. for (let j = 0; j <= s2.length; j++) {
  2375. if (i == 0)
  2376. costs[j] = j;
  2377. else {
  2378. if (j > 0) {
  2379. let newValue = costs[j - 1];
  2380. if (s1.charAt(i - 1) != s2.charAt(j - 1))
  2381. newValue = Math.min(Math.min(newValue, lastValue),
  2382. costs[j]) + 1;
  2383. costs[j - 1] = lastValue;
  2384. lastValue = newValue;
  2385. }
  2386. }
  2387. }
  2388. if (i > 0)
  2389. costs[s2.length] = lastValue;
  2390. }
  2391. return costs[s2.length];
  2392. }
  2393.  
  2394. function searchForCollections() {
  2395. if ($('.search-results a').length === 0) return;
  2396.  
  2397. const baseName = $($('.search-results .result-title')[0]).text();
  2398.  
  2399. const request = new XMLHttpRequest();
  2400. request.open('GET', '/api?m=search&q=' + encodeURIComponent(baseName), true);
  2401.  
  2402. request.onload = () => {
  2403. if (request.readyState !== 4 || request.status !== 200 ) return;
  2404.  
  2405. response = JSON.parse(request.response).data;
  2406.  
  2407. if (response == undefined) return;
  2408.  
  2409. let seriesList = [];
  2410.  
  2411. for (const anime of response) {
  2412. if (stringSimilarity(baseName, anime.title) >= 0.42 || (anime.title.startsWith(baseName) && stringSimilarity(baseName, anime.title) >= 0.25)) {
  2413. seriesList.push(anime);
  2414. }
  2415. }
  2416.  
  2417. if (seriesList.length < 2) return;
  2418. seriesList = sortAnimesChronologically(seriesList);
  2419.  
  2420. displayCollection(seriesList);
  2421. };
  2422.  
  2423. request.send();
  2424. }
  2425.  
  2426. new MutationObserver(function(mutationList, observer) {
  2427. if (!searchComplete()) return;
  2428. searchForCollections();
  2429. }).observe($('.search-results-wrap')[0], { childList: true });
  2430.  
  2431. function searchComplete() {
  2432. return $('.search-results').length !== 0 && $('.search-results a').length > 0;
  2433. }
  2434.  
  2435. function displayCollection(seriesList) {
  2436. $(`
  2437. <li class="anitracker-collection" data-index="-1">
  2438. <a title="${toHtmlCodes(seriesList[0].title + " - Collection")}" href="javascript:;">
  2439. <img src="${seriesList[0].poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" style="pointer-events: all !important;max-width: 30px;">
  2440. <img src="${seriesList[1].poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" style="pointer-events: all !important;max-width: 30px;left:30px;">
  2441. <div class="result-title">${toHtmlCodes(seriesList[0].title)}</div>
  2442. <div class="result-status"><strong>Collection</strong> - ${seriesList.length} Entries</div>
  2443. </a>
  2444. </li>`).prependTo('.search-results');
  2445.  
  2446. function displayInModal() {
  2447. $('#anitracker-modal-body').empty();
  2448. $(`
  2449. <h4>Collection</h4>
  2450. <div class="anitracker-modal-list-container">
  2451. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;"></div>
  2452. </div>`).appendTo('#anitracker-modal-body');
  2453.  
  2454. for (const anime of seriesList) {
  2455. $(`
  2456. <div class="anitracker-big-list-item anitracker-collection-item">
  2457. <a href="/anime/${anime.session}" title="${toHtmlCodes(anime.title)}">
  2458. <img src="${anime.poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" alt="[Thumbnail of ${anime.title}]">
  2459. <div class="anitracker-main-text">${anime.title}</div>
  2460. <div class="anitracker-subtext"><strong>${anime.type}</strong> - ${anime.episodes > 0 ? anime.episodes : '?'} Episode${anime.episodes === 1 ? '' : 's'} (${anime.status})</div>
  2461. <div class="anitracker-subtext">${anime.season} ${anime.year}</div>
  2462. </a>
  2463. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  2464. }
  2465.  
  2466. openModal();
  2467. }
  2468.  
  2469. $('.anitracker-collection').on('click', displayInModal);
  2470. $('.input-search').on('keyup', (e) => {
  2471. if (e.key === "Enter" && $('.anitracker-collection').hasClass('selected')) displayInModal();
  2472. });
  2473. }
  2474.  
  2475. $('.input-search').attr('title','Search for anime');
  2476.  
  2477. function getSeasonTimeframe(from, to) {
  2478. const filters = [];
  2479. for (let i = from.year; i <= to.year; i++) {
  2480. const start = i === from.year ? from.season : 0;
  2481. const end = i === to.year ? to.season : 3;
  2482. for (let d = start; d <= end; d++) {
  2483. filters.push({type: 'season_entry', value: {year: i, season: d}});
  2484. }
  2485. }
  2486. return filters;
  2487. }
  2488.  
  2489. const is404 = $('h1').text().includes('404');
  2490.  
  2491. if (!isRandomAnime() && initialStorage.temp !== undefined) {
  2492. const storage = getStorage();
  2493. delete storage.temp;
  2494. saveData(storage);
  2495. }
  2496.  
  2497. const filterSearchCache = {};
  2498.  
  2499. const filterValues = {
  2500. "genre":[
  2501. {"name":"Comedy","value":"comedy"},{"name":"Slice of Life","value":"slice-of-life"},{"name":"Romance","value":"romance"},{"name":"Ecchi","value":"ecchi"},{"name":"Drama","value":"drama"},
  2502. {"name":"Supernatural","value":"supernatural"},{"name":"Sports","value":"sports"},{"name":"Horror","value":"horror"},{"name":"Sci-Fi","value":"sci-fi"},{"name":"Action","value":"action"},
  2503. {"name":"Fantasy","value":"fantasy"},{"name":"Mystery","value":"mystery"},{"name":"Suspense","value":"suspense"},{"name":"Adventure","value":"adventure"},{"name":"Boys Love","value":"boys-love"},
  2504. {"name":"Girls Love","value":"girls-love"},{"name":"Hentai","value":"hentai"},{"name":"Gourmet","value":"gourmet"},{"name":"Erotica","value":"erotica"},{"name":"Avant Garde","value":"avant-garde"},
  2505. {"name":"Award Winning","value":"award-winning"}
  2506. ],
  2507. "theme":[
  2508. {"name":"Adult Cast","value":"adult-cast"},{"name":"Anthropomorphic","value":"anthropomorphic"},{"name":"Detective","value":"detective"},{"name":"Love Polygon","value":"love-polygon"},
  2509. {"name":"Mecha","value":"mecha"},{"name":"Music","value":"music"},{"name":"Psychological","value":"psychological"},{"name":"School","value":"school"},{"name":"Super Power","value":"super-power"},
  2510. {"name":"Space","value":"space"},{"name":"CGDCT","value":"cgdct"},{"name":"Romantic Subtext","value":"romantic-subtext"},{"name":"Historical","value":"historical"},{"name":"Video Game","value":"video-game"},
  2511. {"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"},
  2512. {"name":"Performing Arts","value":"performing-arts"},{"name":"Military","value":"military"},{"name":"Harem","value":"harem"},{"name":"Reverse Harem","value":"reverse-harem"},{"name":"Samurai","value":"samurai"},
  2513. {"name":"Vampire","value":"vampire"},{"name":"Mythology","value":"mythology"},{"name":"High Stakes Game","value":"high-stakes-game"},{"name":"Strategy Game","value":"strategy-game"},
  2514. {"name":"Magical Sex Shift","value":"magical-sex-shift"},{"name":"Racing","value":"racing"},{"name":"Isekai","value":"isekai"},{"name":"Workplace","value":"workplace"},{"name":"Iyashikei","value":"iyashikei"},
  2515. {"name":"Time Travel","value":"time-travel"},{"name":"Gore","value":"gore"},{"name":"Educational","value":"educational"},{"name":"Delinquents","value":"delinquents"},{"name":"Organized Crime","value":"organized-crime"},
  2516. {"name":"Otaku Culture","value":"otaku-culture"},{"name":"Medical","value":"medical"},{"name":"Survival","value":"survival"},{"name":"Reincarnation","value":"reincarnation"},{"name":"Showbiz","value":"showbiz"},
  2517. {"name":"Team Sports","value":"team-sports"},{"name":"Mahou Shoujo","value":"mahou-shoujo"},{"name":"Combat Sports","value":"combat-sports"},{"name":"Crossdressing","value":"crossdressing"},
  2518. {"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"},
  2519. {"name":"Villainess","value":"villainess"}
  2520. ],
  2521. "type":[
  2522. {"name":"TV","value":"tv"},{"name":"Movie","value":"movie"},{"name":"OVA","value":"ova"},{"name":"ONA","value":"ona"},{"name":"Special","value":"special"},{"name":"Music","value":"music"}
  2523. ],
  2524. "demographic":[
  2525. {"name":"Shounen","value":"shounen"},{"name":"Shoujo","value":"shoujo"},{"name":"Seinen","value":"seinen"},{"name":"Kids","value":"kids"},{"name":"Josei","value":"josei"}
  2526. ],
  2527. "status":[
  2528. {"value":"airing"},{"value":"completed"}
  2529. ]
  2530. };
  2531.  
  2532. const filterDefaultRules = {
  2533. genre: {
  2534. include: "and",
  2535. exclude: "and"
  2536. },
  2537. theme: {
  2538. include: "and",
  2539. exclude: "and"
  2540. },
  2541. demographic: {
  2542. include: "or",
  2543. exclude: "and"
  2544. },
  2545. type: {
  2546. include: "or",
  2547. exclude: "and"
  2548. },
  2549. season: {
  2550. include: "or",
  2551. exclude: "and"
  2552. },
  2553. status: {
  2554. include: "or"
  2555. }
  2556. };
  2557.  
  2558. const filterRules = JSON.parse(JSON.stringify(filterDefaultRules));
  2559.  
  2560. function buildFilterString(type, value) {
  2561. if (type === 'status') return value;
  2562. if (type === 'season_entry') return `season/${getSeasonName(value.season)}-${value.year}`;
  2563.  
  2564. return type + '/' + value;
  2565. }
  2566.  
  2567. const seasonFilterRegex = /^!?(spring|summer|winter|fall)-(\d{4})\.\.(spring|summer|winter|fall)-(\d{4})$/;
  2568.  
  2569. function getFilteredList(filtersInput) {
  2570. let filtersChecked = 0;
  2571. let filtersTotal = 0;
  2572.  
  2573. function getPage(pageUrl) {
  2574. return new Promise((resolve, reject) => {
  2575. const cached = filterSearchCache[pageUrl];
  2576. if (cached !== undefined) { // If cache exists
  2577. if (cached === 'invalid') { // Not sure if it ever is 'invalid'
  2578. resolve([]);
  2579. return;
  2580. }
  2581. resolve(cached);
  2582. return;
  2583. }
  2584. const req = new XMLHttpRequest();
  2585. req.open('GET', pageUrl, true);
  2586. try {
  2587. req.send();
  2588. }
  2589. catch (err) {
  2590. console.error(err);
  2591. reject('A network error occured.');
  2592. return;
  2593. }
  2594.  
  2595. req.onload = () => {
  2596. if (req.status !== 200) {
  2597. filterSearchCache[pageUrl] = [];
  2598. resolve([]);
  2599. return;
  2600. }
  2601. const animeList = getAnimeList($(req.response));
  2602. filterSearchCache[pageUrl] = animeList;
  2603. resolve(animeList);
  2604. };
  2605. });
  2606. }
  2607.  
  2608. function getLists(filters) {
  2609. const lists = [];
  2610.  
  2611. return new Promise((resolve, reject) => {
  2612. function check() {
  2613. if (filters.length > 0) {
  2614. repeat(filters.shift());
  2615. }
  2616. else {
  2617. resolve(lists);
  2618. }
  2619. }
  2620.  
  2621. function repeat(filter) {
  2622. const filterType = filter.type;
  2623. if (filter.value === 'none') {
  2624. filtersTotal += filterValues[filterType].length;
  2625.  
  2626. getLists(filterValues[filterType].map(a => {return {type: filterType, value: a.value, exclude: false};})).then((filtered) => {
  2627. getPage('/anime').then((unfiltered) => {
  2628. const none = [];
  2629. for (const entry of unfiltered) {
  2630. const found = filtered.find(list => list.entries.find(a => a.name === entry.name));
  2631. if (!filter.exclude && found !== undefined) continue;
  2632. if (filter.exclude && found === undefined) continue;
  2633. none.push(entry);
  2634. }
  2635.  
  2636. lists.push({
  2637. type: filterType,
  2638. excludedFilter: false,
  2639. entries: none
  2640. });
  2641.  
  2642. check();
  2643. });
  2644. });
  2645. return;
  2646. }
  2647. if (filterType === 'season') {
  2648. const seasonFilters = getSeasonTimeframe(filter.value.from, filter.value.to);
  2649. filtersTotal += seasonFilters.length;
  2650.  
  2651. getLists(seasonFilters).then((filtered) => {
  2652. const filtersResult = [];
  2653. if (filter.exclude) getPage('/anime').then((unfiltered) => {
  2654. for (const entry of unfiltered) {
  2655. if (filtered.find(list => list.entries.find(a => a.name === entry.name)) !== undefined) continue;
  2656. filtersResult.push(entry);
  2657. }
  2658.  
  2659. lists.push({
  2660. type: 'season',
  2661. excludedFilter: true,
  2662. entries: filtersResult
  2663. });
  2664.  
  2665. check();
  2666. });
  2667. else {
  2668. for (const list of filtered) {
  2669. filtersResult.push(...list.entries);
  2670. }
  2671.  
  2672. lists.push({
  2673. type: 'season',
  2674. excludedFilter: false,
  2675. entries: filtersResult
  2676. });
  2677.  
  2678. check();
  2679. }
  2680. });
  2681.  
  2682. return;
  2683. }
  2684. if (filter.exclude) {
  2685. getPage('/anime/' + buildFilterString(filterType, filter.value)).then((filtered) => {
  2686. getPage('/anime').then((unfiltered) => {
  2687. const included = [];
  2688. for (const entry of unfiltered) {
  2689. if (filtered.find(a => a.name === entry.name) !== undefined) continue;
  2690. included.push(entry);
  2691. }
  2692.  
  2693. lists.push({
  2694. type: filterType,
  2695. excludedFilter: true,
  2696. entries: included
  2697. });
  2698.  
  2699. check();
  2700. });
  2701. });
  2702. return;
  2703. }
  2704. getPage('/anime/' + buildFilterString(filterType, filter.value)).then((result) => {
  2705. if (result !== undefined) {
  2706. lists.push({
  2707. type: filterType,
  2708. excludedFilter: false,
  2709. entries: result
  2710. });
  2711. }
  2712. if (filtersTotal > 0) {
  2713. filtersChecked++;
  2714. $($('.anitracker-filter-spinner>span')[0]).text(Math.floor((filtersChecked/filtersTotal) * 100).toString() + '%');
  2715. }
  2716.  
  2717. check();
  2718. });
  2719. }
  2720.  
  2721. check();
  2722. });
  2723. }
  2724.  
  2725. function combineLists(lists, rule) {
  2726. if (lists.length === 0) return [];
  2727.  
  2728. // Start with the first filter list result, then compare others to it
  2729. let combinedList = lists[0];
  2730. lists.splice(0,1); // Remove the first entry
  2731. for (const list of lists) {
  2732. // If the rule of this filter type is 'or,' start from the current list
  2733. // Otherwise, start from an empty list
  2734. const updatedList = rule === 'or' ? combinedList : [];
  2735. if (rule === 'and') for (const anime of list) {
  2736. // The anime has to exist in both the current and the checked list
  2737. if (combinedList.find(a => a.name === anime.name) === undefined) continue;
  2738. updatedList.push(anime);
  2739. }
  2740. else if (rule === 'or') for (const anime of list) {
  2741. // The anime just has to not already exist in the current list
  2742. if (combinedList.find(a => a.name === anime.name) !== undefined) continue;
  2743. updatedList.push(anime);
  2744. }
  2745. combinedList = updatedList;
  2746. }
  2747. return combinedList;
  2748. }
  2749.  
  2750. return new Promise((resolve, reject) => {
  2751. const filters = JSON.parse(JSON.stringify(filtersInput));
  2752.  
  2753. if (filters.length === 0) {
  2754. getPage('/anime').then((response) => {
  2755. if (response === undefined) {
  2756. alert('Page loading failed.');
  2757. reject('Anime index page not reachable.');
  2758. return;
  2759. }
  2760.  
  2761. resolve(response);
  2762. });
  2763. return;
  2764. }
  2765.  
  2766. filtersTotal = filters.length;
  2767.  
  2768.  
  2769. getLists(filters).then((listsInput) => {
  2770. const lists = JSON.parse(JSON.stringify(listsInput));
  2771.  
  2772. // groupedLists entries have the following format:
  2773. /* {
  2774. type, // the type of filter, eg. 'genre'
  2775. includeLists: [
  2776. <list of included anime>
  2777. ],
  2778. excludeLists: [
  2779. <list of excluded anime>
  2780. ]
  2781. }
  2782. */
  2783. const groupedLists = [];
  2784. for (const list of lists) {
  2785. let foundGroup = groupedLists.find(a => a.type === list.type);
  2786. if (foundGroup === undefined) {
  2787. groupedLists.push({
  2788. type: list.type,
  2789. includeLists: [],
  2790. excludeLists: []
  2791. });
  2792. foundGroup = groupedLists[groupedLists.length - 1];
  2793. }
  2794.  
  2795. if (list.excludedFilter) foundGroup.excludeLists.push(list.entries);
  2796. else foundGroup.includeLists.push(list.entries);
  2797. }
  2798.  
  2799. let finalList;
  2800.  
  2801. for (const group of groupedLists) {
  2802. const includeList = combineLists(group.includeLists, filterRules[group.type].include);
  2803. const excludeList = combineLists(group.excludeLists, filterRules[group.type].exclude);
  2804.  
  2805. // Combine the include and exclude lists
  2806.  
  2807. // If the exclude list exists, start from an empty list
  2808. // Otherwise, just default to the include list
  2809. let groupFinalList = [];
  2810. if (excludeList.length > 0 && includeList.length > 0) {
  2811. const combineRule = filterRules[group.type].combined;
  2812. for (const entry of excludeList) {
  2813. if (groupFinalList.find(a => a.name === entry.name) !== undefined) continue; // Don't include duplicates
  2814. if (combineRule === 'or') {
  2815. if (includeList.find(a => a.name === entry.name) !== undefined) continue;
  2816. groupFinalList.push(entry);
  2817. continue;
  2818. }
  2819. // Otherwise, the rule is 'and'
  2820. if (includeList.find(a => a.name === entry.name) === undefined) continue;
  2821. groupFinalList.push(entry);
  2822. }
  2823. }
  2824. else if (excludeList.length === 0) groupFinalList = includeList;
  2825. else if (includeList.length === 0) groupFinalList = excludeList;
  2826.  
  2827. // If the current final list is undefined, just add the resulting list to it and continue
  2828. if (finalList === undefined) {
  2829. finalList = groupFinalList;
  2830. continue;
  2831. }
  2832.  
  2833. const newFinalList = [];
  2834. // Loop through the resulting list
  2835. // Join together with 'and'
  2836. for (const anime of groupFinalList) {
  2837. if (finalList.find(a => a.name === anime.name) === undefined) continue;
  2838. newFinalList.push(anime);
  2839. }
  2840. finalList = newFinalList;
  2841. }
  2842.  
  2843. resolve(finalList);
  2844. });
  2845. });
  2846. }
  2847.  
  2848. function searchList(fuseClass, list, query, limit = 80) {
  2849. const fuse = new fuseClass(list, {
  2850. keys: ['name'],
  2851. findAllMatches: true
  2852. });
  2853.  
  2854. const matching = fuse.search(query);
  2855. return matching.map(a => {return a.item}).splice(0,limit);
  2856. }
  2857.  
  2858. function timeSince(date) {
  2859. const seconds = Math.floor((new Date() - date) / 1000);
  2860.  
  2861. let interval = Math.floor(seconds / 31536000);
  2862.  
  2863. if (interval >= 1) {
  2864. return interval + " year" + (interval > 1 ? 's' : '');
  2865. }
  2866. interval = Math.floor(seconds / 2592000);
  2867. if (interval >= 1) {
  2868. return interval + " month" + (interval > 1 ? 's' : '');
  2869. }
  2870. interval = Math.floor(seconds / 86400);
  2871. if (interval >= 1) {
  2872. return interval + " day" + (interval > 1 ? 's' : '');
  2873. }
  2874. interval = Math.floor(seconds / 3600);
  2875. if (interval >= 1) {
  2876. return interval + " hour" + (interval > 1 ? 's' : '');
  2877. }
  2878. interval = Math.floor(seconds / 60);
  2879. if (interval >= 1) {
  2880. return interval + " minute" + (interval > 1 ? 's' : '');
  2881. }
  2882. return seconds + " second" + (seconds > 1 ? 's' : '');
  2883. }
  2884.  
  2885. if (window.location.pathname.startsWith('/customlink')) {
  2886. const parts = {
  2887. animeSession: '',
  2888. episodeSession: '',
  2889. time: -1
  2890. };
  2891. const entries = Array.from(new URLSearchParams(window.location.search).entries()).sort((a,b) => a[0] > b[0] ? 1 : -1);
  2892. for (const entry of entries) {
  2893. if (entry[0] === 'a') {
  2894. const name = decodeURIComponent(entry[1]);
  2895. const animeData = getAnimeData(name, undefined, true);
  2896. if (animeData === undefined) return;
  2897. if (animeData.title !== name && !confirm(`[AnimePahe Improvements]\n\nCouldn't find any anime with name "${name}".\nGo to "${animeData.title}" instead?`)) {
  2898. return;
  2899. }
  2900. parts.animeSession = animeData.session;
  2901. continue;
  2902. }
  2903. if (entry[0] === 'e') {
  2904. if (parts.animeSession === '') return;
  2905. parts.episodeSession = getEpisodeSession(parts.animeSession, +entry[1]);
  2906. if (parts.episodeSession === undefined) parts.episodeSession = '';
  2907. continue;
  2908. }
  2909. if (entry[0] === 't') {
  2910. if (parts.animeSession === '') return;
  2911. if (parts.episodeSession === '') continue;
  2912.  
  2913. parts.time = +entry[1];
  2914. continue;
  2915. }
  2916. }
  2917.  
  2918. const destination = (() => {
  2919. if (parts.animeSession !== '' && parts.episodeSession === '' && parts.time === -1) {
  2920. return '/anime/' + parts.animeSession + '?ref=customlink';
  2921. }
  2922. if (parts.animeSession !== '' && parts.episodeSession !== '' && parts.time === -1) {
  2923. return '/play/' + parts.animeSession + '/' + parts.episodeSession + '?ref=customlink';
  2924. }
  2925. if (parts.animeSession !== '' && parts.episodeSession !== '' && parts.time >= 0) {
  2926. return '/play/' + parts.animeSession + '/' + parts.episodeSession + '?time=' + parts.time + '&ref=customlink';
  2927. }
  2928. return undefined;
  2929. })();
  2930.  
  2931. if (destination !== undefined) {
  2932. document.title = "Redirecting... :: animepahe";
  2933. $('h1').text('Redirecting...');
  2934. window.location.replace(destination);
  2935. }
  2936. return;
  2937. }
  2938.  
  2939. // Main key events
  2940. if (!is404) $(document).on('keydown', (e) => {
  2941. const isTextInput = $(e.target).is('input[type=text],input[type=""],input:not([type])');
  2942.  
  2943. if (modalIsOpen() && (e.key === 'Escape' || e.key === 'Backspace' && !isTextInput)) {
  2944. modalCloseFunction();
  2945. return;
  2946. }
  2947.  
  2948. if (isTextInput) return;
  2949.  
  2950. if (!isEpisode() || modalIsOpen()) return;
  2951. if (e.key === 't') {
  2952. toggleTheatreMode();
  2953. }
  2954. else {
  2955. sendMessage({action:"key",key:e.key});
  2956. $('.embed-responsive-item')[0].contentWindow.focus();
  2957. if ([" "].includes(e.key)) e.preventDefault();
  2958. }
  2959. });
  2960.  
  2961. if (window.location.pathname.startsWith('/queue')) {
  2962. $(`
  2963. <span style="font-size:.6em;">&nbsp;&nbsp;&nbsp;(Incoming episodes)</span>
  2964. `).appendTo('h2');
  2965. }
  2966.  
  2967. // Redirect filter pages
  2968. if (/^\/anime\/\w+(\/[\w\-\.]+)?$/.test(window.location.pathname)) {
  2969. if (is404) return;
  2970.  
  2971. const filter = /\/anime\/([^\/]+)\/?([^\/]+)?/.exec(window.location.pathname);
  2972.  
  2973. if (filter[2] !== undefined) {
  2974. if (filterRules[filter[1]] === undefined) return;
  2975. if (filter[1] === 'season') {
  2976. window.location.replace(`/anime?${filter[1]}=${filter[2]}..${filter[2]}`);
  2977. return;
  2978. }
  2979. window.location.replace(`/anime?${filter[1]}=${filter[2]}`);
  2980. }
  2981. else {
  2982. window.location.replace(`/anime?status=${filter[1]}`);
  2983. }
  2984. return;
  2985. }
  2986.  
  2987. function getDayName(day) {
  2988. return [
  2989. "Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"
  2990. ][day];
  2991. }
  2992.  
  2993. function toHtmlCodes(string) {
  2994. return $('<div>').text(string).html().replace(/"/g, "&quot;").replace(/'/g, "&#039;");
  2995. }
  2996.  
  2997. // Bookmark & episode feed header buttons
  2998. $(`
  2999. <div class="anitracker-header">
  3000. <button class="anitracker-header-notifications anitracker-header-button" title="View episode feed">
  3001. <i class="fa fa-bell" aria-hidden="true"></i>
  3002. <i style="display:none;" aria-hidden="true" class="fa fa-circle anitracker-header-notifications-circle"></i>
  3003. </button>
  3004. <button class="anitracker-header-bookmark anitracker-header-button" title="View bookmarks"><i class="fa fa-bookmark" aria-hidden="true"></i></button>
  3005. </div>`).insertAfter('.navbar-nav');
  3006.  
  3007. let currentNotificationIndex = 0;
  3008.  
  3009. function openNotificationsModal() {
  3010. currentNotificationIndex = 0;
  3011. const oldStorage = getStorage();
  3012. $('#anitracker-modal-body').empty();
  3013.  
  3014. $(`
  3015. <h4>Episode Feed</h4>
  3016. <div class="btn-group" style="margin-bottom: 10px;">
  3017. <button class="btn btn-secondary anitracker-view-notif-animes">
  3018. Handle Feed...
  3019. </button>
  3020. </div>
  3021. <div class="anitracker-modal-list-container">
  3022. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;">
  3023. <div id="anitracker-notifications-list-spinner" class="anitracker-spinner" style="display:flex;justify-content:center;">
  3024. <div class="spinner-border" role="status">
  3025. <span class="sr-only">Loading...</span>
  3026. </div>
  3027. </div>
  3028. </div>
  3029. </div>`).appendTo('#anitracker-modal-body');
  3030.  
  3031. $('.anitracker-view-notif-animes').on('click', () => {
  3032. $('#anitracker-modal-body').empty();
  3033. const storage = getStorage();
  3034. $(`
  3035. <h4>Handle Episode Feed</h4>
  3036. <div class="anitracker-modal-list-container">
  3037. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;"></div>
  3038. </div>
  3039. `).appendTo('#anitracker-modal-body');
  3040. [...storage.notifications.anime].sort((a,b) => a.latest_episode > b.latest_episode ? 1 : -1).forEach(g => {
  3041. const latestEp = new Date(g.latest_episode + " UTC");
  3042. const latestEpString = g.latest_episode !== undefined ? `${getDayName(latestEp.getDay())} ${latestEp.toLocaleTimeString()}` : "None found";
  3043. $(`
  3044. <div class="anitracker-modal-list-entry" animeid="${g.id}" animename="${toHtmlCodes(g.name)}">
  3045. <a href="/a/${g.id}" target="_blank" title="${toHtmlCodes(g.name)}">
  3046. ${g.name}
  3047. </a><br>
  3048. <span>
  3049. Latest episode: ${latestEpString}
  3050. </span><br>
  3051. <div class="btn-group">
  3052. <button class="btn btn-danger anitracker-delete-button anitracker-flat-button" title="Remove this anime from the episode feed">
  3053. <i class="fa fa-trash" aria-hidden="true"></i>
  3054. &nbsp;Remove
  3055. </button>
  3056. </div>
  3057. <div class="btn-group">
  3058. <button class="btn btn-secondary anitracker-get-all-button anitracker-flat-button" title="Put all episodes in the feed" ${g.hasFirstEpisode ? 'disabled=""' : ''}>
  3059. <i class="fa fa-rotate-right" aria-hidden="true"></i>
  3060. &nbsp;Get All
  3061. </button>
  3062. </div>
  3063. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3064. });
  3065. if (storage.notifications.anime.length === 0) {
  3066. $(`<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');
  3067. }
  3068.  
  3069. $('.anitracker-modal-list-entry .anitracker-get-all-button').on('click', (e) => {
  3070. const elem = $(e.currentTarget);
  3071. const id = +elem.parents().eq(1).attr('animeid');
  3072. const storage = getStorage();
  3073.  
  3074. const found = storage.notifications.anime.find(a => a.id === id);
  3075. if (found === undefined) {
  3076. console.error("[AnimePahe Improvements] Couldn't find feed for anime with ID " + id);
  3077. return;
  3078. }
  3079.  
  3080. found.hasFirstEpisode = true;
  3081. found.updateFrom = 0;
  3082. saveData(storage);
  3083.  
  3084. elem.replaceClass("btn-secondary", "btn-primary");
  3085. setTimeout(() => {
  3086. elem.replaceClass("btn-primary", "btn-secondary");
  3087. elem.prop('disabled', true);
  3088. }, 200);
  3089. });
  3090.  
  3091. $('.anitracker-modal-list-entry .anitracker-delete-button').on('click', (e) => {
  3092. const parent = $(e.currentTarget).parents().eq(1);
  3093. const name = parent.attr('animename');
  3094. toggleNotifications(name, +parent.attr('animeid'));
  3095.  
  3096. const name2 = getAnimeName();
  3097. if (name2.length > 0 && name2 === name) {
  3098. $('.anitracker-notifications-toggle .anitracker-title-icon-check').hide();
  3099. }
  3100.  
  3101. parent.remove();
  3102. });
  3103.  
  3104. openModal();
  3105. });
  3106.  
  3107. const animeData = [];
  3108. const queue = [...oldStorage.notifications.anime];
  3109.  
  3110. openModal().then(() => {
  3111. if (queue.length > 0) next();
  3112. else done();
  3113. });
  3114.  
  3115. async function next() {
  3116. if (queue.length === 0) done();
  3117. const anime = queue.shift();
  3118. const data = await updateNotifications(anime.name);
  3119.  
  3120. if (data === -1) {
  3121. $("<span>An error occured.</span>").appendTo('#anitracker-modal-body .anitracker-modal-list');
  3122. return;
  3123. }
  3124. animeData.push({
  3125. id: anime.id,
  3126. data: data
  3127. });
  3128.  
  3129. if (queue.length > 0 && $('#anitracker-notifications-list-spinner').length > 0) next();
  3130. else done();
  3131. }
  3132.  
  3133. function done() {
  3134. if ($('#anitracker-notifications-list-spinner').length === 0) return;
  3135. const storage = getStorage();
  3136. let removedAnime = 0;
  3137. for (const anime of storage.notifications.anime) {
  3138. if (anime.latest_episode === undefined || anime.dont_ask === true) continue;
  3139. const time = Date.now() - new Date(anime.latest_episode + " UTC").getTime();
  3140. if ((time / 1000 / 60 / 60 / 24 / 7) > 2) {
  3141. 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.`);
  3142. if (remove === true) {
  3143. toggleNotifications(anime.name, anime.id);
  3144. removedAnime++;
  3145. }
  3146. else {
  3147. anime.dont_ask = true;
  3148. saveData(storage);
  3149. }
  3150. }
  3151. }
  3152. if (removedAnime > 0) {
  3153. openNotificationsModal();
  3154. return;
  3155. }
  3156. $('#anitracker-notifications-list-spinner').remove();
  3157. storage.notifications.episodes.sort((a,b) => a.time < b.time ? 1 : -1);
  3158. storage.notifications.lastUpdated = Date.now();
  3159. saveData(storage);
  3160. if (storage.notifications.episodes.length === 0) {
  3161. $("<span>Nothing here yet!</span>").appendTo('#anitracker-modal-body .anitracker-modal-list');
  3162. }
  3163. else addToList(20);
  3164. }
  3165.  
  3166. function addToList(num) {
  3167. const storage = getStorage();
  3168. const index = currentNotificationIndex;
  3169. for (let i = currentNotificationIndex; i < storage.notifications.episodes.length; i++) {
  3170. const ep = storage.notifications.episodes[i];
  3171. if (ep === undefined) break;
  3172. currentNotificationIndex++;
  3173. const data = animeData.find(a => a.id === ep.animeId)?.data;
  3174. if (data === undefined) {
  3175. console.error(`[AnimePahe Improvements] Could not find corresponding anime "${ep.animeName}" with ID ${ep.animeId} (episode ${ep.episode})`);
  3176. continue;
  3177. }
  3178.  
  3179. const releaseTime = new Date(ep.time + " UTC");
  3180. $(`
  3181. <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}">
  3182. <a href="/play/${data.session}/${ep.session}" target="_blank" title="${toHtmlCodes(data.title)}">
  3183. <img src="${data.poster.slice(0, -3) + 'th.jpg'}" referrerpolicy="no-referrer" alt="[Thumbnail of ${toHtmlCodes(data.title)}]"}>
  3184. <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>
  3185. <div class="anitracker-main-text">${data.title}</div>
  3186. <div class="anitracker-subtext"><strong>Episode ${ep.episode}</strong></div>
  3187. <div class="anitracker-subtext">${timeSince(releaseTime)} ago (${releaseTime.toLocaleDateString()})</div>
  3188. </a>
  3189. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3190. if (i > index+num-1) break;
  3191. }
  3192.  
  3193. $('.anitracker-notification-item.anitracker-temp').on('click', (e) => {
  3194. $(e.currentTarget).find('a').blur();
  3195. });
  3196.  
  3197. $('.anitracker-notification-item.anitracker-temp .anitracker-watched-toggle').on('click keydown', (e) => {
  3198. if (e.type === 'keydown' && e.key !== "Enter") return;
  3199. e.preventDefault();
  3200. const storage = getStorage();
  3201. const elem = $(e.currentTarget);
  3202. const parent = elem.parents().eq(1);
  3203. const animeId = +parent.attr('anime-data');
  3204. const episode = +parent.attr('episode-data');
  3205. const ep = storage.notifications.episodes.find(a => a.animeId === animeId && a.episode === episode);
  3206. if (ep === undefined) {
  3207. console.error("[AnimePahe Improvements] couldn't mark episode as watched/unwatched");
  3208. return;
  3209. }
  3210. parent.toggleClass('anitracker-notification-item-unwatched');
  3211. elem.toggleClass('fa-eye').toggleClass('fa-eye-slash');
  3212.  
  3213. if (e.type === 'click') elem.blur();
  3214.  
  3215. ep.watched = !ep.watched;
  3216. elem.attr('title', `Mark this episode as ${ep.watched ? 'unwatched' : 'watched'}`);
  3217.  
  3218. saveData(storage);
  3219.  
  3220. if (ep.watched) {
  3221. addWatched(animeId, episode, storage);
  3222. }
  3223. else {
  3224. removeWatched(animeId, episode, storage);
  3225. }
  3226.  
  3227. if (isAnime() && getAnimeData().id === animeId) updateEpisodesPage();
  3228. });
  3229.  
  3230. $('.anitracker-notification-item.anitracker-temp').removeClass('anitracker-temp');
  3231.  
  3232. }
  3233.  
  3234. $('#anitracker-modal-body').on('scroll', () => {
  3235. const elem = $('#anitracker-modal-body');
  3236. if (elem.scrollTop() >= elem[0].scrollTopMax) {
  3237. if ($('.anitracker-view-notif-animes').length === 0) return;
  3238. addToList(20);
  3239. }
  3240. });
  3241. }
  3242.  
  3243. $('.anitracker-header-notifications').on('click', openNotificationsModal);
  3244.  
  3245. $('.anitracker-header-bookmark').on('click', () => {
  3246. $('#anitracker-modal-body').empty();
  3247. const storage = getStorage();
  3248. $(`
  3249. <h4>Bookmarks</h4>
  3250. <div class="anitracker-modal-list-container">
  3251. <div class="anitracker-modal-list" style="min-height: 100px;min-width: 200px;">
  3252. <div class="btn-group">
  3253. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-modal-search" placeholder="Search">
  3254. <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>
  3255. </div>
  3256. </div>
  3257. </div>
  3258. `).appendTo('#anitracker-modal-body');
  3259.  
  3260. $('.anitracker-modal-search').on('input', (e) => {
  3261. setTimeout(() => {
  3262. const query = $(e.target).val();
  3263. for (const entry of $('.anitracker-modal-list-entry')) {
  3264. if ($($(entry).find('a,span')[0]).text().toLowerCase().includes(query)) {
  3265. $(entry).show();
  3266. continue;
  3267. }
  3268. $(entry).hide();
  3269. }
  3270. }, 10);
  3271. });
  3272.  
  3273. function applyDeleteEvents() {
  3274. $('.anitracker-modal-list-entry button').on('click', (e) => {
  3275. const id = $(e.currentTarget).parent().attr('animeid');
  3276. toggleBookmark(id);
  3277.  
  3278. const data = getAnimeData();
  3279. if (data !== undefined && data.id === +id) {
  3280. $('.anitracker-bookmark-toggle .anitracker-title-icon-check').hide();
  3281. }
  3282.  
  3283. $(e.currentTarget).parent().remove();
  3284. });
  3285. }
  3286.  
  3287. // When clicking the reverse order button
  3288. $('.anitracker-reverse-order-button').on('click', (e) => {
  3289. const btn = $(e.target);
  3290. if (btn.attr('dir') === 'down') {
  3291. btn.attr('dir', 'up');
  3292. btn.addClass('anitracker-up');
  3293. }
  3294. else {
  3295. btn.attr('dir', 'down');
  3296. btn.removeClass('anitracker-up');
  3297. }
  3298.  
  3299. const entries = [];
  3300. for (const entry of $('.anitracker-modal-list-entry')) {
  3301. entries.push(entry.outerHTML);
  3302. }
  3303. entries.reverse();
  3304. $('.anitracker-modal-list-entry').remove();
  3305. for (const entry of entries) {
  3306. $(entry).appendTo($('.anitracker-modal-list'));
  3307. }
  3308. applyDeleteEvents();
  3309. });
  3310.  
  3311. [...storage.bookmarks].reverse().forEach(g => {
  3312. $(`
  3313. <div class="anitracker-modal-list-entry" animeid="${g.id}">
  3314. <a href="/a/${g.id}" target="_blank" title="${toHtmlCodes(g.name)}">
  3315. ${g.name}
  3316. </a><br>
  3317. <button class="btn btn-danger anitracker-flat-button" title="Remove this bookmark">
  3318. <i class="fa fa-trash" aria-hidden="true"></i>
  3319. &nbsp;Remove
  3320. </button>
  3321. </div>`).appendTo('#anitracker-modal-body .anitracker-modal-list')
  3322. });
  3323. if (storage.bookmarks.length === 0) {
  3324. $(`<span style="display: block;">No bookmarks yet!</span>`).appendTo('#anitracker-modal-body .anitracker-modal-list');
  3325. }
  3326.  
  3327. applyDeleteEvents();
  3328. openModal();
  3329. $('#anitracker-modal-body')[0].scrollTop = 0;
  3330. });
  3331.  
  3332. function toggleBookmark(id, name=undefined) {
  3333. const storage = getStorage();
  3334. const found = storage.bookmarks.find(g => g.id === +id);
  3335.  
  3336. if (found !== undefined) {
  3337. const index = storage.bookmarks.indexOf(found);
  3338. storage.bookmarks.splice(index, 1);
  3339.  
  3340. saveData(storage);
  3341.  
  3342. return false;
  3343. }
  3344.  
  3345. if (name === undefined) return false;
  3346.  
  3347. storage.bookmarks.push({
  3348. id: +id,
  3349. name: name
  3350. });
  3351. saveData(storage);
  3352.  
  3353. return true;
  3354. }
  3355.  
  3356. function toggleNotifications(name, id = undefined) {
  3357. const storage = getStorage();
  3358. const found = (() => {
  3359. if (id !== undefined) return storage.notifications.anime.find(g => g.id === id);
  3360. else return storage.notifications.anime.find(g => g.name === name);
  3361. })();
  3362.  
  3363. if (found !== undefined) {
  3364. const index = storage.notifications.anime.indexOf(found);
  3365. storage.notifications.anime.splice(index, 1);
  3366.  
  3367. 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
  3368.  
  3369. saveData(storage);
  3370.  
  3371. return false;
  3372. }
  3373.  
  3374. const animeData = getAnimeData(name);
  3375.  
  3376. storage.notifications.anime.push({
  3377. name: name,
  3378. id: animeData.id,
  3379. updateFrom: Date.now()
  3380. });
  3381. saveData(storage);
  3382.  
  3383. return true;
  3384. }
  3385.  
  3386. async function updateNotifications(animeName, storage = getStorage()) {
  3387. const nobj = storage.notifications.anime.find(g => g.name === animeName);
  3388. if (nobj === undefined) {
  3389. toggleNotifications(animeName);
  3390. return;
  3391. }
  3392. const data = await asyncGetAnimeData(animeName, nobj.id);
  3393. if (data === undefined) return -1;
  3394. const episodes = await asyncGetAllEpisodes(data.session, 'desc');
  3395. if (episodes === undefined) return 0;
  3396.  
  3397. return new Promise((resolve, reject) => {
  3398. if (episodes.length === 0) resolve(undefined);
  3399.  
  3400. nobj.latest_episode = episodes[0].created_at;
  3401.  
  3402. if (nobj.name !== data.title) {
  3403. for (const ep of storage.notifications.episodes) {
  3404. if (ep.animeName !== nobj.name) continue;
  3405. ep.animeName = data.title;
  3406. }
  3407. nobj.name = data.title;
  3408. }
  3409.  
  3410. const compareUpdateTime = nobj.updateFrom ?? storage.notifications.lastUpdated;
  3411. if (nobj.updateFrom !== undefined) delete nobj.updateFrom;
  3412.  
  3413. const watched = decodeWatched(storage.watched);
  3414.  
  3415. for (const ep of episodes) {
  3416. const epWatched = isWatched(nobj.id, ep.episode, watched);
  3417.  
  3418. 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);
  3419. if (found !== undefined) {
  3420. found.session = ep.session;
  3421. if (!found.watched) found.watched = epWatched;
  3422. if (found.animeId === undefined) found.animeId = nobj.id;
  3423.  
  3424. if (episodes.indexOf(ep) === episodes.length - 1) nobj.hasFirstEpisode = true;
  3425. continue;
  3426. }
  3427.  
  3428. if (new Date(ep.created_at + " UTC").getTime() < compareUpdateTime) {
  3429. continue;
  3430. }
  3431.  
  3432. storage.notifications.episodes.push({
  3433. animeName: nobj.name,
  3434. animeId: nobj.id,
  3435. session: ep.session,
  3436. episode: ep.episode,
  3437. time: ep.created_at,
  3438. watched: epWatched
  3439. });
  3440. }
  3441.  
  3442. const length = storage.notifications.episodes.length;
  3443. if (length > 150) {
  3444. storage.notifications.episodes = storage.notifications.episodes.slice(length - 150);
  3445. }
  3446.  
  3447. saveData(storage);
  3448.  
  3449. resolve(data);
  3450. });
  3451. }
  3452.  
  3453. const paramArray = Array.from(new URLSearchParams(window.location.search));
  3454.  
  3455. const refArg01 = paramArray.find(a => a[0] === 'ref');
  3456. if (refArg01 !== undefined) {
  3457. const ref = refArg01[1];
  3458. if (ref === '404') {
  3459. alert('[AnimePahe Improvements]\n\nThe session was outdated, and has been refreshed. Please try that link again.');
  3460. }
  3461. else if (ref === 'customlink' && isEpisode() && initialStorage.settings.autoDelete) {
  3462. const name = getAnimeName();
  3463. const num = getEpisodeNum();
  3464. if (initialStorage.linkList.find(e => e.animeName === name && e.type === 'episode' && e.episodeNum !== num)) { // If another episode is already stored
  3465. $(`
  3466. <span style="display:block;width:100%;text-align:center;" class="anitracker-from-share-warning">
  3467. The current episode data for this anime was not replaced due to coming from a share link.
  3468. <br>Refresh this page to replace it.
  3469. <br><span class="anitracker-text-button" tabindex="0">Dismiss</span>
  3470. </span>`).prependTo('.content-wrapper');
  3471.  
  3472. $('.anitracker-from-share-warning>span').on('click keydown', function(e) {
  3473. if (e.type === 'keydown' && e.key !== "Enter") return;
  3474. $(e.target).parent().remove();
  3475. });
  3476. }
  3477. }
  3478.  
  3479. window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
  3480. }
  3481.  
  3482. function getCurrentSeason() {
  3483. const month = new Date().getMonth();
  3484. return Math.trunc(month/3);
  3485. }
  3486.  
  3487. function getFiltersFromParams(params) {
  3488. const filters = [];
  3489. for (const [key, value] of params.entries()) {
  3490. const inputFilters = value.split(','); // Get all filters of this filter type
  3491. for (const filter of inputFilters) {
  3492. if (filterRules[key] === undefined) continue;
  3493.  
  3494. const exclude = filter.startsWith('!');
  3495. if (key === 'season' && seasonFilterRegex.test(filter)) {
  3496. const parts = seasonFilterRegex.exec(filter);
  3497. if (!parts.includes(undefined) && ![parseInt(parts[2]),parseInt(parts[4])].includes(NaN)) {
  3498. filters.push({
  3499. type: 'season',
  3500. value: {
  3501. from: { season: getSeasonValue(parts[1]), year: parseInt(parts[2]) },
  3502. to: { season: getSeasonValue(parts[3]), year: parseInt(parts[4]) }
  3503. },
  3504. exclude: exclude
  3505. });
  3506. }
  3507. continue;
  3508. }
  3509.  
  3510. filters.push({
  3511. type: key,
  3512. value: filter.replace(/^!/,''),
  3513. exclude: exclude
  3514. });
  3515. }
  3516. }
  3517. return filters;
  3518. }
  3519.  
  3520. function getSearchParamsString(params) {
  3521. if (Array.from(params.entries()).length === 0) return '';
  3522. return '?' + decodeURIComponent(params.toString());
  3523. }
  3524.  
  3525. function loadIndexPage() {
  3526. const animeList = getAnimeList();
  3527. filterSearchCache['/anime'] = JSON.parse(JSON.stringify(animeList));
  3528.  
  3529. $(`
  3530. <div id="anitracker" class="anitracker-index" style="margin-bottom: 10px;">
  3531.  
  3532. <div class="anitracker-filter-input" data-filter-type="genre">
  3533. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="genre"><i class="fa fa-sliders"></i></button>
  3534. <div>
  3535. <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>
  3536. </div>
  3537. </div>
  3538.  
  3539. <div class="anitracker-filter-input" data-filter-type="theme">
  3540. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="theme"><i class="fa fa-sliders"></i></button>
  3541. <div>
  3542. <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>
  3543. </div>
  3544. </div>
  3545.  
  3546. <div class="anitracker-filter-input" data-filter-type="type">
  3547. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="type"><i class="fa fa-sliders"></i></button>
  3548. <div>
  3549. <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>
  3550. </div>
  3551. </div>
  3552.  
  3553. <div class="anitracker-filter-input" data-filter-type="demographic">
  3554. <button class="anitracker-filter-rules" title="Change filter logic" data-filter-type="demographic"><i class="fa fa-sliders"></i></button>
  3555. <div>
  3556. <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>
  3557. </div>
  3558. </div>
  3559.  
  3560. <div style="margin-left: auto;">
  3561. <div class="btn-group">
  3562. <button class="btn dropdown-toggle btn-dark" id="anitracker-status-button" data-bs-toggle="dropdown" data-toggle="dropdown" title="Choose status">All</button>
  3563. </div>
  3564.  
  3565. <div class="btn-group">
  3566. <button class="btn btn-dark" id="anitracker-time-search-button" title="Set season filter">
  3567. <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">
  3568. <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"/>
  3569. </svg>
  3570. </button>
  3571. </div>
  3572. </div>
  3573.  
  3574. <div id="anitracker-filter-dropdown-container"></div>
  3575. </div>
  3576. <div id="anitracker-row-2">
  3577. <span style="font-size: 1.2em;color:#ddd;" id="anitracker-filter-result-count">Filter results: <span>${animeList.length}</span></span>
  3578. <div style="float: right; margin-right: 6px; margin-bottom: 2rem;">
  3579. <div class="btn-group">
  3580. <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>
  3581. </div>
  3582. <div class="btn-group">
  3583. <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>
  3584. </div>
  3585. <div class="btn-group">
  3586. <button class="btn btn-dark" id="anitracker-random-anime" title="Open a random anime from within the selected filters">
  3587. <i class="fa fa-random" aria-hidden="true"></i>
  3588. &nbsp;Random Anime
  3589. </button>
  3590. </div>
  3591. <div class="btn-group">
  3592. <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...">
  3593. </div>
  3594. </div>
  3595. </div>`).insertBefore('.index');
  3596.  
  3597. function getDropdownButtons(filters, type) {
  3598. 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>`));
  3599. }
  3600.  
  3601. $(`<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');
  3602. getDropdownButtons(filterValues.genre, 'genre').forEach(g => { g.appendTo('#anitracker-genre-dropdown') });
  3603.  
  3604. $(`<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');
  3605. getDropdownButtons(filterValues.theme, 'theme').forEach(g => { g.appendTo('#anitracker-theme-dropdown') });
  3606.  
  3607. $(`<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');
  3608. getDropdownButtons(filterValues.type, 'type').forEach(g => { g.appendTo('#anitracker-type-dropdown') });
  3609.  
  3610. $(`<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');
  3611. getDropdownButtons(filterValues.demographic, 'demographic').forEach(g => { g.appendTo('#anitracker-demographic-dropdown') });
  3612.  
  3613. $(`<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');
  3614. ['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') });
  3615. $(`<button data-filter-type="status" data-filter-value="none">(No status)</button>`).appendTo('#anitracker-status-dropdown');
  3616.  
  3617. const timeframeSettings = {
  3618. enabled: false
  3619. };
  3620.  
  3621. const placeholderTexts = {
  3622. 'genre': 'Genre',
  3623. 'theme': 'Theme',
  3624. 'type': 'Type',
  3625. 'demographic': 'Demographic'
  3626. }
  3627.  
  3628. const selectedFilters = [];
  3629. const appliedFilters = [];
  3630.  
  3631. function getElemsFromFilterType(filterType) {
  3632. const elems = {};
  3633. if (filterType === undefined) return elems;
  3634. for (const inp of $('.anitracker-filter-input')) {
  3635. if ($(inp).data('filter-type') !== filterType) continue;
  3636. elems.parent = $(inp);
  3637. elems.filterIcons = Array.from($(inp).find('.anitracker-filter-icon'));
  3638. elems.filterIconContainer = $(inp).find('.anitracker-applied-filters');
  3639. elems.input = $(inp).find('.anitracker-text-input');
  3640. elems.inputPlaceholder = $(inp).find('.anitracker-placeholder');
  3641. elems.scrollingDiv = $(inp).find('>div');
  3642. elems.filterRuleButton = $(inp).find('.anitracker-filter-rules');
  3643. break;
  3644. }
  3645. for (const drop of $('.anitracker-filter-dropdown')) {
  3646. if ($(drop).data('filter-type') !== filterType) continue;
  3647. elems.dropdown = $(drop);
  3648. }
  3649. return elems;
  3650. }
  3651.  
  3652. function getFilterDataFromElem(jquery) {
  3653. return {
  3654. type: jquery.data('filter-type'),
  3655. value: jquery.data('filter-value'),
  3656. exclude: jquery.data('filter-exclude') === true
  3657. }
  3658. }
  3659.  
  3660. function getInputText(elem) {
  3661. return elem.contents().filter(function() {
  3662. return this.nodeType === Node.TEXT_NODE;
  3663. }).text().trim();
  3664. }
  3665.  
  3666. function clearPlaceholder(elem) {
  3667. elem.find('.anitracker-placeholder').remove();
  3668. }
  3669.  
  3670. function addPlaceholder(elem, filterType) {
  3671. if (getInputText(elem) !== '' || elem.find('.anitracker-placeholder').length > 0) return;
  3672. $(`<span data-filter-type="${filterType}" class="anitracker-placeholder">${placeholderTexts[filterType]}</span>`).prependTo(elem);
  3673. }
  3674.  
  3675. function setChangesToApply(on) {
  3676. const elem = $('#anitracker-apply-filters');
  3677. if (on) elem.addClass('btn-primary').removeClass('btn-dark');
  3678. else elem.removeClass('btn-primary').addClass('btn-dark');
  3679. }
  3680.  
  3681. function updateApplyButton() {
  3682. setChangesToApply(JSON.stringify(selectedFilters) !== JSON.stringify(appliedFilters));
  3683. }
  3684.  
  3685. function showDropdown(elem, parentElem) {
  3686. for (const type of Object.keys(filterRules)) {
  3687. const elems = getElemsFromFilterType(type);
  3688. if (elems.dropdown === undefined || elems.dropdown.length === 0 || elems.dropdown.hasClass('special')) continue;
  3689. elems.dropdown.hide();
  3690. }
  3691.  
  3692. const top = $(parentElem).position().top + $(parentElem).outerHeight(true);
  3693. const left = $(parentElem).position().left;
  3694. elem.css('top',top).css('left',left);
  3695. elem.show();
  3696. elem.scrollTop(0);
  3697. }
  3698.  
  3699. function checkCloseDropdown(elems) {
  3700. setTimeout(() => {
  3701. if (elems.dropdown.is(':focus,:focus-within') || elems.input.is(':focus')) return;
  3702. elems.dropdown.hide();
  3703. }, 1);
  3704. }
  3705.  
  3706. function fixSelection(elem) {
  3707. const sel = window.getSelection();
  3708. if (!$(sel.anchorNode).is('div')) return;
  3709.  
  3710. setSelection(elem);
  3711. }
  3712.  
  3713. function setSelection(elem) {
  3714. const sel = window.getSelection();
  3715. elem.focus();
  3716.  
  3717. const index = elem.text().length - 1 - elem.find('.anitracker-placeholder').text().length - 1;
  3718. const range = document.createRange();
  3719. range.setStart(elem[0], index > 0 ? index : 0);
  3720. range.collapse(true);
  3721.  
  3722. sel.removeAllRanges();
  3723. sel.addRange(range);
  3724. }
  3725.  
  3726. function scrollToBottom(elem) {
  3727. elem.scrollTop(9999);
  3728. }
  3729.  
  3730. ['genre','theme','type','demographic'].forEach((type) => {
  3731. const elems = getElemsFromFilterType(type);
  3732. addPlaceholder(elems.input, type);
  3733. elems.input.css('width','100%').css('height','100%');
  3734. });
  3735.  
  3736. function getActiveFilter(filter) {
  3737. return selectedFilters.find(f => f.type === filter.type && f.value === filter.value && f.exclude === filter.exclude);
  3738. }
  3739.  
  3740. function refreshIconSymbol(elem) {
  3741. const excluded = elem.data('filter-exclude');
  3742. elem.find('i').remove();
  3743. if (excluded === undefined) return;
  3744. $(`<i class="fa fa-${excluded ? 'minus' : 'plus'}"></i>`).prependTo(elem);
  3745. }
  3746.  
  3747. function setStatusFilter(filter) {
  3748. for (const filter of selectedFilters.filter(f => f.type === 'status')) {
  3749. selectedFilters.splice(selectedFilters.indexOf(filter), 1);
  3750. }
  3751.  
  3752. for (const btn of $('#anitracker-status-dropdown>button')) {
  3753. const elem = $(btn);
  3754. const filterValue = elem.data('filter-value')
  3755. if (filterValue !== filter.value) {
  3756. elem.removeClass('anitracker-active');
  3757. continue;
  3758. }
  3759. $('#anitracker-status-button').text(elem.text());
  3760. if (filterValue !== 'all') elem.addClass('anitracker-active');
  3761. }
  3762.  
  3763. if (filter.value !== 'all') selectedFilters.push(filter);
  3764.  
  3765. if (filter.value === 'all') $('#anitracker-status-button').removeClass('anitracker-active');
  3766. else $('#anitracker-status-button').addClass('anitracker-active');
  3767. }
  3768.  
  3769. function addFilter(filter) {
  3770. if (filter.type === 'season') {
  3771. addSeasonFilter(filter);
  3772. return;
  3773. }
  3774. if (filter.type === 'status') {
  3775. setStatusFilter(filter);
  3776. return;
  3777. }
  3778.  
  3779. const elems = getElemsFromFilterType(filter.type);
  3780. elems.parent?.addClass('active');
  3781. elems.input?.css('width','').css('height','');
  3782. if (elems.input !== undefined) clearPlaceholder(elems.input);
  3783. if (getActiveFilter(filter) !== undefined || filterValues[filter.type] === undefined) return;
  3784. const filterEntry = filterValues[filter.type].find(f => f.value === filter.value);
  3785. const name = (() => {
  3786. if (filter.value === 'none') return '(None)';
  3787. else return filterEntry !== undefined ? filterEntry.name : filter.value;
  3788. })();
  3789. 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);
  3790. refreshIconSymbol(icon);
  3791. icon.on('click', (e) => {
  3792. cycleFilter(getFilterDataFromElem($(e.currentTarget)));
  3793. });
  3794.  
  3795. for (const btn of elems.dropdown.find('button')) {
  3796. const elem = $(btn);
  3797. if (elem.data('filter-value') !== filter.value) continue;
  3798. if (filter.exclude !== undefined) elem.data('filter-exclude', filter.exclude);
  3799.  
  3800. if (filter.exclude) elem.addClass('excluded').removeClass('included');
  3801. else elem.addClass('included').removeClass('excluded');
  3802. }
  3803.  
  3804. if (filter.exclude === undefined) filter.exclude = false;
  3805.  
  3806. selectedFilters.push(filter);
  3807. }
  3808.  
  3809. function removeFilter(filter) {
  3810. const elems = getElemsFromFilterType(filter.type);
  3811. const activeFilter = getActiveFilter(filter);
  3812. if (activeFilter === undefined) return;
  3813.  
  3814. for (const icon of elems.filterIcons) {
  3815. const elem = $(icon);
  3816. if (elem.data('filter-value') !== filter.value) continue;
  3817. elem.remove();
  3818. }
  3819.  
  3820. for (const btn of elems.dropdown.find('button')) {
  3821. const elem = $(btn);
  3822. if (elem.data('filter-value') !== filter.value) continue;
  3823. elem.data('filter-exclude', '');
  3824.  
  3825. elem.removeClass('excluded').removeClass('included');
  3826. }
  3827.  
  3828. selectedFilters.splice(selectedFilters.indexOf(activeFilter), 1);
  3829.  
  3830. // Count remaining filters of the same type
  3831. const remainingFilters = selectedFilters.filter(f => f.type === filter.type);
  3832. if (remainingFilters.length === 0) {
  3833. elems.parent?.removeClass('active');
  3834. elems.input?.css('width','100%').css('height','100%');
  3835. if (elems.input !== undefined) addPlaceholder(elems.input, filter.type);
  3836. }
  3837. }
  3838.  
  3839. // Sets the filter to negative, doesn't actually invert it
  3840. function invertFilter(filter) {
  3841. const elems = getElemsFromFilterType(filter.type);
  3842. const activeFilter = getActiveFilter(filter);
  3843. if (activeFilter === undefined) return;
  3844.  
  3845. for (const icon of elems.filterIcons) {
  3846. const elem = $(icon);
  3847. if (elem.data('filter-value') !== filter.value) continue;
  3848. elem.removeClass('included').addClass('excluded');
  3849. elem.data('filter-exclude', true);
  3850. refreshIconSymbol(elem);
  3851. }
  3852.  
  3853. for (const btn of elems.dropdown.find('button')) {
  3854. const elem = $(btn);
  3855. if (elem.data('filter-value') !== filter.value) continue;
  3856.  
  3857. elem.removeClass('included').addClass('excluded');
  3858. elem.data('filter-exclude', true);
  3859. }
  3860.  
  3861. activeFilter.exclude = true;
  3862. }
  3863.  
  3864. function cycleFilter(filter) {
  3865. if (getActiveFilter(filter) === undefined) addFilter(filter);
  3866. else if (filter.exclude === false) invertFilter(filter);
  3867. else if (filter.exclude === true) removeFilter(filter);
  3868. updateApplyButton();
  3869. }
  3870.  
  3871. function removeSeasonFilters() {
  3872. for (const filter of selectedFilters.filter(f => f.type === 'season')) {
  3873. selectedFilters.splice(selectedFilters.indexOf(filter), 1);
  3874. }
  3875. }
  3876.  
  3877. function addSeasonFilter(filter) {
  3878. $('#anitracker-time-search-button').addClass('anitracker-active');
  3879. timeframeSettings.enabled = true;
  3880. timeframeSettings.inverted = filter.exclude === true;
  3881. timeframeSettings.from = filter.value.from;
  3882. timeframeSettings.to = filter.value.to;
  3883. selectedFilters.push(filter);
  3884. }
  3885.  
  3886. const searchParams = new URLSearchParams(window.location.search);
  3887.  
  3888. function setSearchParam(name, value) {
  3889. if (value === undefined) searchParams.delete(name);
  3890. else searchParams.set(name,value);
  3891. }
  3892.  
  3893. function updateSearchParams() {
  3894. window.history.replaceState({}, document.title, "/anime" + getSearchParamsString(searchParams));
  3895. }
  3896.  
  3897. function layoutTabless(entries) { // Tabless = without tabs
  3898. $('.index>').hide();
  3899. $('#anitracker-search-results').remove();
  3900.  
  3901. $(`<div class="row" id="anitracker-search-results"></div>`).prependTo('.index');
  3902.  
  3903. let elements = entries.map(match => {
  3904. return `
  3905. <div class="col-12 col-md-6">
  3906. ${match.html}
  3907. </div>`;
  3908. });
  3909.  
  3910. if (entries.length === 0) elements = `<div class="col-12 col-md-6">No results found.</div>`;
  3911.  
  3912. Array.from($(elements)).forEach(a => {$(a).appendTo('#anitracker-search-results');});
  3913. }
  3914.  
  3915. function layoutAnime(entries) {
  3916. $('#anitracker-filter-result-count>span').text(entries.length);
  3917.  
  3918. const tabs = $('.tab-content>div');
  3919. tabs.find('.col-12').remove();
  3920. $('.nav-link').show();
  3921. $('.index>').show();
  3922. $('#anitracker-search-results').remove();
  3923.  
  3924. const sortedEntries = entries.sort((a,b) => a.name > b.name ? 1 : -1);
  3925. if (entries.length < 100) {
  3926. layoutTabless(sortedEntries);
  3927. $('#anitracker-anime-list-search').trigger('anitracker:search');
  3928. return;
  3929. }
  3930.  
  3931. for (const tab of tabs) {
  3932. const id = $(tab).attr('id');
  3933. const symbol = id.toLowerCase();
  3934. const matchingAnime = (() => {
  3935. if (symbol === 'hash') {
  3936. return sortedEntries.filter(a => /^(?![A-Za-z])./.test(a.name.toLowerCase()));
  3937. }
  3938. else return sortedEntries.filter(a => a.name.toLowerCase().startsWith(symbol));
  3939. })();
  3940. if (matchingAnime.length === 0) {
  3941. $(`.index .nav-link[href="#${id}"]`).hide();
  3942. continue;
  3943. }
  3944.  
  3945. const row = $(tab).find('.row');
  3946. for (const anime of matchingAnime) {
  3947. $(`<div class="col-12 col-md-6">
  3948. ${anime.html}
  3949. </div>`).appendTo(row);
  3950. }
  3951. }
  3952.  
  3953. if (!$('.index .nav-link.active').is(':visible')) {
  3954. $('.index .nav-link:visible:not([href="#hash"])')[0].click();
  3955. }
  3956. $('#anitracker-anime-list-search').trigger('anitracker:search');
  3957. }
  3958.  
  3959. function updateAnimeEntries(entries) {
  3960. animeList.length = 0;
  3961. animeList.push(...entries);
  3962. }
  3963.  
  3964. function setSpinner(coverScreen) {
  3965. const elem = $(`
  3966. <div class="anitracker-filter-spinner anitracker-spinner ${coverScreen ? 'screen' : 'small'}">
  3967. <div class="spinner-border" role="status">
  3968. <span class="sr-only">Loading...</span>
  3969. </div>
  3970. <span>0%</span>
  3971. </div>`);
  3972. if (coverScreen) elem.prependTo(document.body);
  3973. else elem.appendTo('.page-index h1');
  3974. }
  3975.  
  3976. function getSearchParams(filters, rules, inputParams = undefined) {
  3977. const params = inputParams || new URLSearchParams();
  3978. const values = [];
  3979. for (const type of ['genre','theme','type','demographic','status','season']) {
  3980. const foundFilters = filters.filter(f => f.type === type);
  3981. if (foundFilters.length === 0) {
  3982. params.delete(type);
  3983. continue;
  3984. }
  3985.  
  3986. values.push({filters: foundFilters, type: type});
  3987. }
  3988. for (const entry of values) {
  3989. if (entry.type === 'season') {
  3990. const value = entry.filters[0].value;
  3991. params.set('season', (entry.filters[0].exclude ? '!' : '') + `${getSeasonName(value.from.season)}-${value.from.year}..${getSeasonName(value.to.season)}-${value.to.year}`);
  3992. continue;
  3993. }
  3994.  
  3995. params.set(entry.type, entry.filters.map(g => (g.exclude ? '!' : '') + g.value).join(','));
  3996. }
  3997.  
  3998. const existingRules = getRulesListFromParams(params);
  3999. for (const rule of existingRules) {
  4000. params.delete(`rule-${rule.filterType}-${rule.ruleType}`);
  4001. }
  4002. const changedRules = getChangedRulesList(rules);
  4003. if (changedRules.length === 0) return params;
  4004. for (const rule of changedRules) {
  4005. params.set(`rule-${rule.filterType}-${rule.ruleType}`, rule.value);
  4006. }
  4007.  
  4008. return params;
  4009. }
  4010.  
  4011. function searchWithFilters(filters, screenSpinner) {
  4012. if ($('.anitracker-filter-spinner').length > 0) return; // If already searching
  4013. setSpinner(screenSpinner);
  4014.  
  4015. appliedFilters.length = 0;
  4016. appliedFilters.push(...JSON.parse(JSON.stringify(filters)));
  4017.  
  4018. setChangesToApply(false);
  4019.  
  4020. getFilteredList(filters).then(results => {
  4021. updateAnimeEntries(results);
  4022. layoutAnime(results);
  4023. $('.anitracker-filter-spinner').remove();
  4024. getSearchParams(filters, filterRules, searchParams); // Since a reference is passed, this will set the params
  4025. updateSearchParams();
  4026. });
  4027. }
  4028.  
  4029. const searchParamRuleRegex = /^rule\-(\w+)\-(include|exclude|combined)/;
  4030.  
  4031. function getRulesListFromParams(params) {
  4032. const rulesList = [];
  4033. for (const [key, value] of params.entries()) {
  4034. if (!searchParamRuleRegex.test(key) || !['any','or'].includes(value)) continue;
  4035. const parts = searchParamRuleRegex.exec(key);
  4036. if (filterRules[parts[1]] === undefined) continue;
  4037. rulesList.push({
  4038. filterType: parts[1],
  4039. ruleType: parts[2],
  4040. value: value
  4041. });
  4042. }
  4043. return rulesList;
  4044. }
  4045.  
  4046. function applyRulesList(rulesList) {
  4047. for (const rule of rulesList) {
  4048. filterRules[rule.filterType][rule.ruleType] = rule.value;
  4049. }
  4050. }
  4051.  
  4052. function getChangedRulesList(rules, type = undefined) {
  4053. const changed = [];
  4054. for (const [key, value] of Object.entries(rules)) {
  4055. if (type !== undefined && key !== type) continue;
  4056.  
  4057. if (value.include !== filterDefaultRules[key].include) {
  4058. changed.push({filterType: key, ruleType: 'include', value: value.include});
  4059. }
  4060. if (value.exclude !== filterDefaultRules[key].exclude) {
  4061. changed.push({filterType: key, ruleType: 'exclude', value: value.exclude});
  4062. }
  4063. if (![undefined,'and'].includes(value.combined)) {
  4064. changed.push({filterType: key, ruleType: 'combined', value: value.combined});
  4065. }
  4066. }
  4067. return changed;
  4068. }
  4069.  
  4070. function updateRuleButtons() {
  4071. const changedRules = getChangedRulesList(filterRules);
  4072. for (const type of Object.keys(filterRules)) {
  4073. const elems = getElemsFromFilterType(type);
  4074. const btn = elems.filterRuleButton;
  4075. if (btn === undefined || btn.length === 0) continue;
  4076. if (changedRules.find(r => r.filterType === type) === undefined) btn.removeClass('anitracker-active');
  4077. else btn.addClass('anitracker-active');
  4078. }
  4079. }
  4080.  
  4081. // Events
  4082.  
  4083. $('.anitracker-text-input').on('focus', (e) => {
  4084. const elem = $(e.currentTarget);
  4085. const filterType = elem.data('filter-type');
  4086. const elems = getElemsFromFilterType(filterType);
  4087. showDropdown(elems.dropdown, elems.parent);
  4088. clearPlaceholder(elems.input);
  4089. elem.css('width','').css('height','');
  4090. scrollToBottom(elems.scrollingDiv);
  4091. })
  4092. .on('blur', (e) => {
  4093. const elem = $(e.currentTarget);
  4094. const filterType = elem.data('filter-type');
  4095. const elems = getElemsFromFilterType(filterType);
  4096. checkCloseDropdown(elems);
  4097. if (elems.filterIcons.length === 0) {
  4098. addPlaceholder(elems.input, filterType);
  4099. elem.css('width','100%').css('height','100%');
  4100. }
  4101. })
  4102. .on('keydown', (e) => {
  4103. const elem = $(e.currentTarget);
  4104. const filterType = elem.data('filter-type');
  4105. const elems = getElemsFromFilterType(filterType);
  4106.  
  4107. if (e.key === 'Escape') {
  4108. elem.blur();
  4109. return;
  4110. }
  4111. if (e.key === 'ArrowDown') {
  4112. e.preventDefault();
  4113. elems.dropdown.find('button:visible')[0]?.focus();
  4114. return;
  4115. }
  4116. const filterIcons = elems.filterIcons;
  4117. if (e.key === 'Backspace' && getInputText(elem) === '' && filterIcons.length > 0) {
  4118. removeFilter(getFilterDataFromElem($(filterIcons[filterIcons.length - 1])));
  4119. updateApplyButton();
  4120. }
  4121.  
  4122. setTimeout(() => {
  4123. const text = getInputText(elem).toLowerCase();
  4124.  
  4125. for (const btn of elems.dropdown.find('button')) {
  4126. const jqbtn = $(btn);
  4127. if (jqbtn.text().toLowerCase().includes(text)) {
  4128. jqbtn.show();
  4129. continue;
  4130. }
  4131. jqbtn.hide();
  4132. }
  4133. }, 1);
  4134. }).on('click', (e) => {
  4135. fixSelection($(e.currentTarget));
  4136. });
  4137.  
  4138. $('.anitracker-filter-dropdown:not(.special)>button').on('blur', (e) => {
  4139. const elem = $(e.currentTarget);
  4140. const filterType = elem.data('filter-type');
  4141. checkCloseDropdown(getElemsFromFilterType(filterType));
  4142. }).on('click', (e) => {
  4143. const elem = $(e.currentTarget);
  4144. const filter = getFilterDataFromElem(elem);
  4145. cycleFilter(filter);
  4146.  
  4147. const elems = getElemsFromFilterType(elem.data('filter-type'));
  4148. elems.input?.text('').keydown().blur();
  4149. scrollToBottom(elems.scrollingDiv);
  4150. });
  4151.  
  4152. $('.anitracker-filter-dropdown>button').on('keydown', (e) => {
  4153. const elem = $(e.currentTarget);
  4154. const filterType = elem.data('filter-type');
  4155. const elems = getElemsFromFilterType(filterType);
  4156.  
  4157. if (e.key === 'Escape') {
  4158. elem.blur();
  4159. return;
  4160. }
  4161.  
  4162. const direction = {
  4163. ArrowUp: -1,
  4164. ArrowDown: 1
  4165. }[e.key];
  4166. if (direction === undefined) return;
  4167.  
  4168. const activeButtons = elems.dropdown.find('button:visible');
  4169. let activeIndex = 0;
  4170. for (let i = 0; i < activeButtons.length; i++) {
  4171. const btn = activeButtons[i];
  4172. if (!$(btn).is(':focus')) continue;
  4173. activeIndex = i;
  4174. break;
  4175. }
  4176. const nextIndex = activeIndex + direction;
  4177. if (activeButtons[nextIndex] !== undefined) {
  4178. activeButtons[nextIndex].focus();
  4179. return;
  4180. }
  4181. if (direction === -1 && activeIndex === 0) {
  4182. elems.input?.focus();
  4183. return;
  4184. }
  4185. });
  4186.  
  4187. $('.anitracker-filter-input').on('click', (e) => {
  4188. const elem = $(e.target);
  4189. if (!elem.is('.anitracker-filter-input,.anitracker-applied-filters,.anitracker-filter-input>div')) return;
  4190.  
  4191. const filterType = $(e.currentTarget).data('filter-type');
  4192. const elems = getElemsFromFilterType(filterType);
  4193. setSelection(elems.input);
  4194. });
  4195.  
  4196. $('#anitracker-status-button').on('keydown', (e) => {
  4197. if (e.key !== 'ArrowDown') return;
  4198. const elems = getElemsFromFilterType('status');
  4199. elems.dropdown.find('button')[0]?.focus();
  4200. });
  4201.  
  4202. $('#anitracker-status-dropdown>button').on('click', (e) => {
  4203. const elem = $(e.currentTarget);
  4204. const filter = getFilterDataFromElem(elem);
  4205. addFilter(filter);
  4206. updateApplyButton();
  4207. });
  4208.  
  4209. $('#anitracker-apply-filters').on('click', () => {
  4210. searchWithFilters(selectedFilters, false);
  4211. });
  4212.  
  4213. $('#anitracker-reset-filters').on('click keyup', (e) => {
  4214. if (e.type === 'keyup' && e.key !== "Enter") return;
  4215. window.location.replace(window.location.origin + window.location.pathname);
  4216. });
  4217.  
  4218. $('.anitracker-filter-rules').on('click', (e) => {
  4219. const elem1 = $(e.currentTarget);
  4220. const filterType = elem1.data('filter-type');
  4221.  
  4222. const disableInclude = ['type','demographic'].includes(filterType) ? 'disabled' : '';
  4223.  
  4224. $('#anitracker-modal-body').empty();
  4225.  
  4226. $(`
  4227. <p>Rules for ${filterType} filters</p>
  4228. <div class="anitracker-filter-rule-selection" ${disableInclude} data-rule-type="include" style="background-color: #485057;">
  4229. <i class="fa fa-plus" aria-hidden="true"></i>
  4230. <span>Include:</span>
  4231. <div class="btn-group"><button ${disableInclude} title="Select this rule type">and</button><button ${disableInclude} title="Select this rule type">or</button></div>
  4232. </div>
  4233. <div class="anitracker-filter-rule-selection" data-rule-type="combined" style="display: flex;justify-content: center;">
  4234. <span>-</span>
  4235. <div class="btn-group"><button title="Select this rule type">and</button><button title="Select this rule type">or</button></div>
  4236. <span>-</span>
  4237. </div>
  4238. <div class="anitracker-filter-rule-selection" data-rule-type="exclude" style="background-color: #485057;">
  4239. <i class="fa fa-minus" aria-hidden="true"></i>
  4240. <span>Exclude:</span>
  4241. <div class="btn-group"><button title="Select this rule type">and</button><button title="Select this rule type">or</button></div>
  4242. </div>
  4243. <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>
  4244. `).appendTo('#anitracker-modal-body');
  4245.  
  4246. function refreshBtnStates() {
  4247. const rules = filterRules[filterType];
  4248. for (const selec of $('.anitracker-filter-rule-selection')) {
  4249. const ruleType = $(selec).data('rule-type');
  4250. const rule = rules[ruleType];
  4251.  
  4252. const btns = $(selec).find('button').removeClass('anitracker-active');
  4253. if (rule === 'or') $(btns[1]).addClass('anitracker-active');
  4254. else $(btns[0]).addClass('anitracker-active');
  4255. }
  4256. }
  4257.  
  4258. $('.anitracker-filter-rule-selection button').on('click', (e) => {
  4259. const elem = $(e.currentTarget);
  4260. const ruleType = elem.parents().eq(1).data('rule-type');
  4261. const text = elem.text();
  4262. if (!['and','or'].includes(text)) return;
  4263.  
  4264. filterRules[filterType][ruleType] = text;
  4265.  
  4266. elem.parent().find('button').removeClass('anitracker-active');
  4267. elem.addClass('anitracker-active');
  4268. updateRuleButtons();
  4269. });
  4270.  
  4271. $('#anitracker-reset-filter-rules').on('click', () => {
  4272. filterRules[filterType] = JSON.parse(JSON.stringify(filterDefaultRules[filterType]));
  4273. refreshBtnStates();
  4274. updateRuleButtons();
  4275. });
  4276.  
  4277. refreshBtnStates();
  4278.  
  4279. openModal();
  4280. });
  4281.  
  4282. $('#anitracker-time-search-button').on('click', () => {
  4283. $('#anitracker-modal-body').empty();
  4284.  
  4285. $(`
  4286. <h5>Time interval</h5>
  4287. <div class="custom-control custom-switch">
  4288. <input type="checkbox" class="custom-control-input" id="anitracker-settings-enable-switch">
  4289. <label class="custom-control-label" for="anitracker-settings-enable-switch" title="Enable timeframe settings">Enable</label>
  4290. </div>
  4291. <div class="custom-control custom-switch">
  4292. <input type="checkbox" class="custom-control-input" id="anitracker-settings-invert-switch" disabled>
  4293. <label class="custom-control-label" for="anitracker-settings-invert-switch" title="Invert time range">Invert</label>
  4294. </div>
  4295. <br>
  4296. <div class="anitracker-season-group" id="anitracker-season-from">
  4297. <span>From:</span>
  4298. <div class="btn-group">
  4299. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-year-input" disabled placeholder="Year" type="number">
  4300. </div>
  4301. <div class="btn-group">
  4302. <button class="btn dropdown-toggle btn-secondary anitracker-season-dropdown-button" disabled data-bs-toggle="dropdown" data-toggle="dropdown" data-value="Spring">Spring</button>
  4303. <button class="btn btn-secondary" id="anitracker-season-copy-to-lower" title="Copy the 'from' season to the 'to' season">
  4304. <i class="fa fa-arrow-circle-down" aria-hidden="true"></i>
  4305. </button>
  4306. </div>
  4307. </div>
  4308. <div class="anitracker-season-group" id="anitracker-season-to">
  4309. <span>To:</span>
  4310. <div class="btn-group">
  4311. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-year-input" disabled placeholder="Year" type="number">
  4312. </div>
  4313. <div class="btn-group">
  4314. <button class="btn dropdown-toggle btn-secondary anitracker-season-dropdown-button" disabled data-bs-toggle="dropdown" data-toggle="dropdown" data-value="Spring">Spring</button>
  4315. </div>
  4316. </div>
  4317. <br>
  4318. <div>
  4319. <div class="btn-group">
  4320. <button class="btn btn-primary" id="anitracker-modal-confirm-button">Save</button>
  4321. </div>
  4322. </div>`).appendTo('#anitracker-modal-body');
  4323.  
  4324. $('.anitracker-year-input').val(new Date().getFullYear());
  4325.  
  4326. $('#anitracker-settings-enable-switch').on('change', () => {
  4327. const enabled = $('#anitracker-settings-enable-switch').is(':checked');
  4328. $('.anitracker-season-group').find('input,button').prop('disabled', !enabled);
  4329. $('#anitracker-settings-invert-switch').prop('disabled', !enabled);
  4330. }).prop('checked', timeframeSettings.enabled).change();
  4331.  
  4332. $('#anitracker-settings-invert-switch').prop('checked', timeframeSettings.inverted);
  4333.  
  4334. $('#anitracker-season-copy-to-lower').on('click', () => {
  4335. const seasonName = $('#anitracker-season-from .anitracker-season-dropdown-button').data('value');
  4336. $('#anitracker-season-to .anitracker-year-input').val($('#anitracker-season-from .anitracker-year-input').val());
  4337. $('#anitracker-season-to .anitracker-season-dropdown-button').data('value', seasonName);
  4338. $('#anitracker-season-to .anitracker-season-dropdown-button').text(seasonName);
  4339. });
  4340.  
  4341. $(`<div class="dropdown-menu anitracker-dropdown-content anitracker-season-dropdown">`).insertAfter('.anitracker-season-dropdown-button');
  4342. ['Winter','Spring','Summer','Fall'].forEach(g => { $(`<button ref="${g.toLowerCase()}">${g}</button>`).appendTo('.anitracker-season-dropdown') });
  4343.  
  4344. $('.anitracker-season-dropdown button').on('click', (e) => {
  4345. const pressed = $(e.target)
  4346. const btn = pressed.parents().eq(1).find('.anitracker-season-dropdown-button');
  4347. btn.data('value', pressed.text());
  4348. btn.text(pressed.text());
  4349. });
  4350.  
  4351. const currentSeason = getCurrentSeason();
  4352. if (timeframeSettings.from) {
  4353. $('#anitracker-season-from .anitracker-year-input').val(timeframeSettings.from.year.toString());
  4354. $('#anitracker-season-from .anitracker-season-dropdown button')[timeframeSettings.from.season].click();
  4355. }
  4356. else $('#anitracker-season-from .anitracker-season-dropdown button')[currentSeason].click();
  4357.  
  4358. if (timeframeSettings.to) {
  4359. $('#anitracker-season-to .anitracker-year-input').val(timeframeSettings.to.year.toString());
  4360. $('#anitracker-season-to .anitracker-season-dropdown button')[timeframeSettings.to.season].click();
  4361. }
  4362. else $('#anitracker-season-to .anitracker-season-dropdown button')[currentSeason].click();
  4363.  
  4364. $('#anitracker-modal-confirm-button').on('click', () => {
  4365. const enabled = $('#anitracker-settings-enable-switch').is(':checked');
  4366. const inverted = $('#anitracker-settings-invert-switch').is(':checked');
  4367. const from = {
  4368. year: +$('#anitracker-season-from .anitracker-year-input').val(),
  4369. season: getSeasonValue($('#anitracker-season-from').find('.anitracker-season-dropdown-button').data('value'))
  4370. }
  4371. const to = {
  4372. year: +$('#anitracker-season-to .anitracker-year-input').val(),
  4373. season: getSeasonValue($('#anitracker-season-to').find('.anitracker-season-dropdown-button').data('value'))
  4374. }
  4375. if (enabled) {
  4376. for (const input of $('.anitracker-year-input')) {
  4377. if (/^\d{4}$/.test($(input).val())) continue;
  4378. alert('[AnimePahe Improvements]\n\nYear values must both be 4 numbers.');
  4379. return;
  4380. }
  4381. if (to.year < from.year || (to.year === from.year && to.season < from.season)) {
  4382. alert('[AnimePahe Improvements]\n\nSeason times must be from oldest to newest.' + (to.season === 0 ? '\n(Winter is the first quarter of the year)' : ''));
  4383. return;
  4384. }
  4385. if (to.year - from.year > 100) {
  4386. alert('[AnimePahe Improvements]\n\nYear interval cannot be more than 100 years.');
  4387. return;
  4388. }
  4389. removeSeasonFilters(); // Put here so it doesn't remove existing filters if input is invalid
  4390. addFilter({
  4391. type: 'season',
  4392. value: {
  4393. from: from,
  4394. to: to
  4395. },
  4396. exclude: inverted
  4397. });
  4398. }
  4399. else {
  4400. removeSeasonFilters();
  4401. $('#anitracker-time-search-button').removeClass('anitracker-active');
  4402. }
  4403. updateApplyButton();
  4404. timeframeSettings.enabled = enabled;
  4405. timeframeSettings.inverted = inverted;
  4406. closeModal();
  4407. });
  4408.  
  4409. openModal();
  4410. });
  4411.  
  4412. $('#anitracker-random-anime').on('click', function(e) {
  4413. const elem = $(e.currentTarget);
  4414.  
  4415. elem.find('i').removeClass('fa-random').addClass('fa-refresh').css('animation', 'anitracker-spin 1s linear infinite');
  4416.  
  4417. getFilteredList(selectedFilters).then(results => {
  4418. elem.find('i').removeClass('fa-refresh').addClass('fa-random').css('animation', '');
  4419.  
  4420. const storage = getStorage();
  4421. storage.temp = results;
  4422. saveData(storage);
  4423.  
  4424. const params = new URLSearchParams('anitracker-random=1');
  4425.  
  4426. getRandomAnime(results, getSearchParamsString(params));
  4427. });
  4428. });
  4429.  
  4430. $.getScript('https://cdn.jsdelivr.net/npm/fuse.js@7.0.0', function() {
  4431. let typingTimer;
  4432. const elem = $('#anitracker-anime-list-search');
  4433. elem.prop('disabled', false).attr('placeholder', 'Search');
  4434.  
  4435. elem.on('anitracker:search', function() {
  4436. if ($(this).val() !== '') animeListSearch();
  4437. })
  4438. .on('keyup', function() {
  4439. clearTimeout(typingTimer);
  4440. typingTimer = setTimeout(animeListSearch, 150);
  4441. })
  4442. .on('keydown', function() {
  4443. clearTimeout(typingTimer);
  4444. });
  4445.  
  4446. function animeListSearch() {
  4447. const value = elem.val();
  4448. if (value === '') {
  4449. layoutAnime(JSON.parse(JSON.stringify(animeList)));
  4450. searchParams.delete('search');
  4451. }
  4452. else {
  4453. const matches = searchList(Fuse, animeList, value);
  4454.  
  4455. layoutTabless(matches);
  4456. searchParams.set('search', encodeURIComponent(value));
  4457. }
  4458. updateSearchParams();
  4459. }
  4460.  
  4461. const loadedParams = new URLSearchParams(window.location.search);
  4462. if (loadedParams.has('search')) {
  4463. elem.val(decodeURIComponent(loadedParams.get('search')));
  4464. animeListSearch();
  4465. }
  4466. }).fail(() => {
  4467. console.error("[AnimePahe Improvements] Fuse.js failed to load");
  4468. });
  4469.  
  4470. // From parameters
  4471. const paramRules = getRulesListFromParams(searchParams);
  4472. applyRulesList(paramRules);
  4473. updateRuleButtons();
  4474. const paramFilters = getFiltersFromParams(searchParams);
  4475. if (paramFilters.length === 0) return;
  4476. for (const filter of paramFilters) {
  4477. addFilter(filter);
  4478. }
  4479. searchWithFilters(selectedFilters, true);
  4480. }
  4481.  
  4482. // Search/index page
  4483. if (/^\/anime\/?$/.test(window.location.pathname)) {
  4484. loadIndexPage();
  4485. return;
  4486. }
  4487.  
  4488. function getAnimeList(page = $(document)) {
  4489. const animeList = [];
  4490.  
  4491. for (const anime of page.find('.col-12')) {
  4492. if (anime.children[0] === undefined || $(anime).hasClass('anitracker-filter-result') || $(anime).parent().attr('id') !== undefined) continue;
  4493. animeList.push({
  4494. name: $(anime.children[0]).text(),
  4495. link: anime.children[0].href,
  4496. html: $(anime).html()
  4497. });
  4498. }
  4499.  
  4500. return animeList;
  4501. }
  4502.  
  4503. function randint(min, max) { // min and max included
  4504. return Math.floor(Math.random() * (max - min + 1) + min);
  4505. }
  4506.  
  4507. function isEpisode(url = window.location.toString()) {
  4508. return url.includes('/play/');
  4509. }
  4510.  
  4511. function isAnime(url = window.location.pathname) {
  4512. return /^\/anime\/[\d\w\-]+$/.test(url);
  4513. }
  4514.  
  4515. function download(filename, text) {
  4516. const element = document.createElement('a');
  4517. element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
  4518. element.setAttribute('download', filename);
  4519.  
  4520. element.click();
  4521. }
  4522.  
  4523. function deleteEpisodesFromTracker(exclude, nameInput, id = undefined) {
  4524. const storage = getStorage();
  4525. const animeName = nameInput || getAnimeName();
  4526. const linkData = getStoredLinkData(storage);
  4527.  
  4528. storage.linkList = (() => {
  4529. if (id !== undefined) {
  4530. const found = storage.linkList.filter(g => g.type === 'episode' && g.animeId === id && g.episodeNum !== exclude);
  4531. if (found.length > 0) return storage.linkList.filter(g => !(g.type === 'episode' && g.animeId === id && g.episodeNum !== exclude));
  4532. }
  4533.  
  4534. return storage.linkList.filter(g => !(g.type === 'episode' && g.animeName === animeName && g.episodeNum !== exclude));
  4535. })();
  4536.  
  4537. storage.videoTimes = (() => {
  4538. if (id !== undefined) {
  4539. const found = storage.videoTimes.filter(g => g.animeId === id && g.episodeNum !== exclude);
  4540. if (found.length > 0) return storage.videoTimes.filter(g => !(g.animeId === id && g.episodeNum !== exclude));
  4541. }
  4542.  
  4543. return storage.videoTimes.filter(g => !(g.episodeNum !== exclude && stringSimilarity(g.animeName, animeName) > 0.81));
  4544. })();
  4545.  
  4546. if (exclude === undefined && id !== undefined) {
  4547. storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== id);
  4548. }
  4549.  
  4550. saveData(storage);
  4551. }
  4552.  
  4553. function deleteEpisodeFromTracker(animeName, episodeNum, animeId = undefined) {
  4554. const storage = getStorage();
  4555.  
  4556. storage.linkList = (() => {
  4557. if (animeId !== undefined) {
  4558. const found = storage.linkList.find(g => g.type === 'episode' && g.animeId === animeId && g.episodeNum === episodeNum);
  4559. if (found !== undefined) return storage.linkList.filter(g => !(g.type === 'episode' && g.animeId === animeId && g.episodeNum === episodeNum));
  4560. }
  4561.  
  4562. return storage.linkList.filter(g => !(g.type === 'episode' && g.animeName === animeName && g.episodeNum === episodeNum));
  4563. })();
  4564.  
  4565. storage.videoTimes = (() => {
  4566. if (animeId !== undefined) {
  4567. const found = storage.videoTimes.find(g => g.animeId === animeId && g.episodeNum === episodeNum);
  4568. if (found !== undefined) return storage.videoTimes.filter(g => !(g.animeId === animeId && g.episodeNum === episodeNum));
  4569. }
  4570.  
  4571. return storage.videoTimes.filter(g => !(g.episodeNum === episodeNum && stringSimilarity(g.animeName, animeName) > 0.81));
  4572. })();
  4573.  
  4574. if (animeId !== undefined) {
  4575. const episodesRemain = storage.videoTimes.find(g => g.animeId === animeId) !== undefined;
  4576. if (!episodesRemain) {
  4577. storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== animeId);
  4578. }
  4579. }
  4580.  
  4581. saveData(storage);
  4582. }
  4583.  
  4584. function getStoredLinkData(storage) {
  4585. if (isEpisode()) {
  4586. return storage.linkList.find(a => a.type == 'episode' && a.animeSession == animeSession && a.episodeSession == episodeSession);
  4587. }
  4588. return storage.linkList.find(a => a.type == 'anime' && a.animeSession == animeSession);
  4589. }
  4590.  
  4591. function getAnimeName() {
  4592. return isEpisode() ? /Watch (.*) - ([\d\.]+)(?:\-[\d\.]+)? Online/.exec($('.theatre-info h1').text())[1] : $($('.title-wrapper h1 span')[0]).text();
  4593. }
  4594.  
  4595. function getEpisodeNum() {
  4596. if (isEpisode()) return +(/Watch (.*) - ([\d\.]+)(?:\-[\d\.]+)? Online/.exec($('.theatre-info h1').text())[2]);
  4597. else return 0;
  4598. }
  4599.  
  4600. function sortAnimesChronologically(animeList) {
  4601. // Animes (plural)
  4602. animeList.sort((a, b) => {return getSeasonValue(a.season) > getSeasonValue(b.season) ? 1 : -1});
  4603. animeList.sort((a, b) => {return a.year > b.year ? 1 : -1});
  4604.  
  4605. return animeList;
  4606. }
  4607.  
  4608. function asyncGetResponseData(qurl) {
  4609. return new Promise((resolve, reject) => {
  4610. let req = new XMLHttpRequest();
  4611. req.open('GET', qurl, true);
  4612. req.onload = () => {
  4613. if (req.status === 200) {
  4614. resolve(JSON.parse(req.response).data);
  4615. return;
  4616. }
  4617.  
  4618. reject(undefined);
  4619. };
  4620. try {
  4621. req.send();
  4622. }
  4623. catch (err) {
  4624. console.error(err);
  4625. resolve(undefined);
  4626. }
  4627. });
  4628. }
  4629.  
  4630. function getResponseData(qurl) {
  4631. let req = new XMLHttpRequest();
  4632. req.open('GET', qurl, false);
  4633. try {
  4634. req.send();
  4635. }
  4636. catch (err) {
  4637. console.error(err);
  4638. return(undefined);
  4639. }
  4640.  
  4641. if (req.status === 200) {
  4642. return(JSON.parse(req.response).data);
  4643. }
  4644.  
  4645. return(undefined);
  4646. }
  4647.  
  4648. function getAnimeSessionFromUrl(url = window.location.toString()) {
  4649. return new RegExp('^(.*animepahe\.[a-z]+)?/(play|anime)/([^/?#]+)').exec(url)[3];
  4650. }
  4651.  
  4652. function getEpisodeSessionFromUrl(url = window.location.toString()) {
  4653. return new RegExp('^(.*animepahe\.[a-z]+)?/(play|anime)/([^/]+)/([^/?#]+)').exec(url)[4];
  4654. }
  4655.  
  4656. function makeSearchable(string) {
  4657. return encodeURIComponent(string.replace(' -',' '));
  4658. }
  4659.  
  4660. function getAnimeData(name = getAnimeName(), id = undefined, guess = false) {
  4661. const cached = (() => {
  4662. if (id !== undefined) return cachedAnimeData.find(a => a.id === id);
  4663. else return cachedAnimeData.find(a => a.title === name);
  4664. })();
  4665. if (cached !== undefined) {
  4666. return cached;
  4667. }
  4668.  
  4669. if (name.length === 0) return undefined;
  4670. const response = getResponseData('/api?m=search&q=' + makeSearchable(name));
  4671.  
  4672. if (response === undefined) return response;
  4673.  
  4674. for (const anime of response) {
  4675. if (id === undefined && anime.title === name) {
  4676. cachedAnimeData.push(anime);
  4677. return anime;
  4678. }
  4679. if (id !== undefined && anime.id === id) {
  4680. cachedAnimeData.push(anime);
  4681. return anime;
  4682. }
  4683. }
  4684.  
  4685. if (guess && response.length > 0) {
  4686. cachedAnimeData.push(response[0]);
  4687. return response[0];
  4688. }
  4689.  
  4690. return undefined;
  4691. }
  4692.  
  4693. async function asyncGetAnimeData(name = getAnimeName(), id) {
  4694. const cached = cachedAnimeData.find(a => a.id === id);
  4695. const response = cached === undefined ? await getResponseData('/api?m=search&q=' + makeSearchable(name)) : undefined;
  4696. return new Promise((resolve, reject) => {
  4697. if (cached !== undefined) {
  4698. resolve(cached);
  4699. return;
  4700. }
  4701.  
  4702. if (response === undefined) resolve(response);
  4703.  
  4704. for (const anime of response) {
  4705. if (anime.id === id) {
  4706. cachedAnimeData.push(anime);
  4707. resolve(anime);
  4708. }
  4709. }
  4710. reject(`Anime "${name}" not found`);
  4711. });
  4712. }
  4713.  
  4714. // For general animepahe pages that are not episode or anime pages
  4715. if (!url.includes('/play/') && !url.includes('/anime/') && !/anime[\/#]?[^\/]*([\?&][^=]+=[^\?^&])*$/.test(url)) {
  4716. $(`
  4717. <div id="anitracker">
  4718. </div>`).insertAfter('.notification-release');
  4719.  
  4720. addGeneralButtons();
  4721. updateSwitches();
  4722.  
  4723. return;
  4724. }
  4725.  
  4726. let animeSession = getAnimeSessionFromUrl();
  4727. let episodeSession = '';
  4728. if (isEpisode()) {
  4729. episodeSession = getEpisodeSessionFromUrl();
  4730. }
  4731.  
  4732. function getEpisodeSession(aSession, episodeNum) {
  4733. const request = new XMLHttpRequest();
  4734. request.open('GET', '/api?m=release&id=' + aSession, false);
  4735. request.send();
  4736.  
  4737. if (request.status !== 200) return undefined;
  4738.  
  4739. const response = JSON.parse(request.response);
  4740.  
  4741. return (() => {
  4742. for (let i = 1; i <= response.last_page; i++) {
  4743. const episodes = getResponseData(`/api?m=release&sort=episode_asc&page=${i}&id=${aSession}`);
  4744. if (episodes === undefined) return undefined;
  4745. const episode = episodes.find(a => a.episode === episodeNum);
  4746. if (episode === undefined) continue;
  4747. return episode.session;
  4748. }
  4749. return undefined;
  4750. })();
  4751. }
  4752.  
  4753. function refreshSession(from404 = false) {
  4754. /* Return codes:
  4755. * 0: ok!
  4756. * 1: couldn't find stored session at 404 page
  4757. * 2: couldn't get anime data
  4758. * 3: couldn't get episode session
  4759. * 4: idk
  4760. */
  4761.  
  4762. const storage = getStorage();
  4763. const bobj = getStoredLinkData(storage);
  4764.  
  4765. let name = '';
  4766. let episodeNum = 0;
  4767.  
  4768. if (bobj === undefined && from404) return 1;
  4769.  
  4770. if (bobj !== undefined) {
  4771. name = bobj.animeName;
  4772. episodeNum = bobj.episodeNum;
  4773. }
  4774. else {
  4775. name = getAnimeName();
  4776. episodeNum = getEpisodeNum();
  4777. }
  4778.  
  4779. if (isEpisode()) {
  4780. const animeData = getAnimeData(name, bobj?.animeId, true);
  4781.  
  4782. if (animeData === undefined) return 2;
  4783.  
  4784. if (bobj?.animeId === undefined && animeData.title !== name && !refreshGuessWarning(name, animeData.title)) {
  4785. return 2;
  4786. }
  4787.  
  4788. const episodeSession = getEpisodeSession(animeData.session, episodeNum);
  4789.  
  4790. if (episodeSession === undefined) return 3;
  4791.  
  4792. if (bobj !== undefined) {
  4793. storage.linkList = storage.linkList.filter(g => !(g.type === 'episode' && g.animeSession === bobj.animeSession && g.episodeSession === bobj.episodeSession));
  4794. }
  4795.  
  4796. saveData(storage);
  4797.  
  4798. window.location.replace('/play/' + animeData.session + '/' + episodeSession + window.location.search);
  4799.  
  4800. return 0;
  4801. }
  4802. else if (bobj !== undefined && bobj.animeId !== undefined) {
  4803. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === bobj.animeSession));
  4804.  
  4805. saveData(storage);
  4806.  
  4807. window.location.replace('/a/' + bobj.animeId);
  4808. return 0;
  4809. }
  4810. else {
  4811. if (bobj !== undefined) {
  4812. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === bobj.animeSession));
  4813. saveData(storage);
  4814. }
  4815.  
  4816. let animeData = getAnimeData(name, undefined, true);
  4817.  
  4818. if (animeData === undefined) return 2;
  4819.  
  4820. if (animeData.title !== name && !refreshGuessWarning(name, animeData.title)) {
  4821. return 2;
  4822. }
  4823.  
  4824. window.location.replace('/a/' + animeData.id);
  4825. return 0;
  4826. }
  4827.  
  4828. return 4;
  4829. }
  4830.  
  4831. function refreshGuessWarning(name, title) {
  4832. return confirm(`[AnimePahe Improvements]\n\nAn exact match with the anime name "${name}" couldn't be found. Go to "${title}" instead?`);
  4833. }
  4834.  
  4835. const obj = getStoredLinkData(initialStorage);
  4836.  
  4837. if (isEpisode() && !is404) {
  4838. theatreMode(initialStorage.settings.theatreMode);
  4839. $('#downloadMenu').changeElementType('button');
  4840. }
  4841.  
  4842. console.log('[AnimePahe Improvements]', obj, animeSession, episodeSession);
  4843.  
  4844. function setSessionData() {
  4845. const animeName = getAnimeName();
  4846.  
  4847. const storage = getStorage();
  4848. if (isEpisode()) {
  4849. storage.linkList.push({
  4850. animeId: getAnimeData(animeName)?.id,
  4851. animeSession: animeSession,
  4852. episodeSession: episodeSession,
  4853. type: 'episode',
  4854. animeName: animeName,
  4855. episodeNum: getEpisodeNum()
  4856. });
  4857. }
  4858. else {
  4859. storage.linkList.push({
  4860. animeId: getAnimeData(animeName)?.id,
  4861. animeSession: animeSession,
  4862. type: 'anime',
  4863. animeName: animeName
  4864. });
  4865. }
  4866. if (storage.linkList.length > 1000) {
  4867. storage.linkList.splice(0,1);
  4868. }
  4869.  
  4870. saveData(storage);
  4871. }
  4872.  
  4873. if (obj === undefined && !is404) {
  4874. if (!isRandomAnime()) setSessionData();
  4875. }
  4876. else if (obj !== undefined && is404) {
  4877. document.title = "Refreshing session... :: animepahe";
  4878. $('.text-center h1').text('Refreshing session, please wait...');
  4879. const code = refreshSession(true);
  4880. if (code === 1) {
  4881. $('.text-center h1').text('Couldn\'t refresh session: Link not found in tracker');
  4882. }
  4883. else if (code === 2) {
  4884. $('.text-center h1').text('Couldn\'t refresh session: Couldn\'t get anime data');
  4885. }
  4886. else if (code === 3) {
  4887. $('.text-center h1').text('Couldn\'t refresh session: Couldn\'t get episode data');
  4888. }
  4889. else if (code !== 0) {
  4890. $('.text-center h1').text('Couldn\'t refresh session: An unknown error occured');
  4891. }
  4892.  
  4893. if ([2,3].includes(code)) {
  4894. if (obj.episodeNum !== undefined) {
  4895. $(`<h3>
  4896. Try finding the episode using the following info:
  4897. <br>Anime name: ${obj.animeName}
  4898. <br>Episode: ${obj.episodeNum}
  4899. </h3>`).insertAfter('.text-center h1');
  4900. }
  4901. else {
  4902. $(`<h3>
  4903. Try finding the anime using the following info:
  4904. <br>Anime name: ${obj.animeName}
  4905. </h3>`).insertAfter('.text-center h1');
  4906. }
  4907. }
  4908. return;
  4909. }
  4910. else if (obj === undefined && is404) {
  4911. if (document.referrer.length > 0) {
  4912. const bobj = (() => {
  4913. if (!/\/play\/.+/.test(document.referrer) && !/\/anime\/.+/.test(document.referrer)) {
  4914. return true;
  4915. }
  4916. const session = getAnimeSessionFromUrl(document.referrer);
  4917. if (isEpisode(document.referrer)) {
  4918. return initialStorage.linkList.find(a => a.type === 'episode' && a.animeSession === session && a.episodeSession === getEpisodeSessionFromUrl(document.referrer));
  4919. }
  4920. else {
  4921. return initialStorage.linkList.find(a => a.type === 'anime' && a.animeSession === session);
  4922. }
  4923. })();
  4924. if (bobj !== undefined) {
  4925. const prevUrl = new URL(document.referrer);
  4926. const params = new URLSearchParams(prevUrl);
  4927. params.set('ref','404');
  4928. prevUrl.search = params.toString();
  4929. windowOpen(prevUrl.toString(), '_self');
  4930. return;
  4931. }
  4932. }
  4933. $('.text-center h1').text('Cannot refresh session: Link not stored in tracker');
  4934. return;
  4935. }
  4936.  
  4937. function getSubInfo(str) {
  4938. const match = /^\b([^·]+)·\s*(\d{2,4})p(.*)$/.exec(str);
  4939. return {
  4940. name: match[1],
  4941. quality: +match[2],
  4942. other: match[3]
  4943. };
  4944. }
  4945.  
  4946. // Set the quality to best automatically
  4947. function bestVideoQuality() {
  4948. if (!isEpisode()) return;
  4949.  
  4950. const currentSub = getStoredLinkData(getStorage()).subInfo || getSubInfo($('#resolutionMenu .active').text());
  4951.  
  4952. let index = -1;
  4953. for (let i = 0; i < $('#resolutionMenu').children().length; i++) {
  4954. const sub = $('#resolutionMenu').children()[i];
  4955. const subInfo = getSubInfo($(sub).text());
  4956. if (subInfo.name !== currentSub.name || subInfo.other !== currentSub.other) continue;
  4957.  
  4958. if (subInfo.quality >= currentSub.quality) index = i;
  4959. }
  4960.  
  4961. if (index === -1) {
  4962. return;
  4963. }
  4964.  
  4965. const newSub = $('#resolutionMenu').children()[index];
  4966.  
  4967.  
  4968. if (!["","Loading..."].includes($('#fansubMenu').text())) {
  4969. if ($(newSub).text() === $('#resolutionMenu .active').text()) return;
  4970. newSub.click();
  4971. return;
  4972. }
  4973.  
  4974. new MutationObserver(function(mutationList, observer) {
  4975. newSub.click();
  4976. observer.disconnect();
  4977. }).observe($('#fansubMenu')[0], { childList: true });
  4978. }
  4979.  
  4980. function setIframeUrl(url) {
  4981. $('.embed-responsive-item').remove();
  4982. $(`
  4983. <iframe class="embed-responsive-item" scrolling="no" allowfullscreen="" allowtransparency="" src="${url}"></iframe>
  4984. `).prependTo('.embed-responsive');
  4985. $('.embed-responsive-item')[0].contentWindow.focus();
  4986. }
  4987.  
  4988. // Fix the quality dropdown buttons
  4989. if (isEpisode()) {
  4990. new MutationObserver(function(mutationList, observer) {
  4991. $('.click-to-load').remove();
  4992. $('#resolutionMenu').off('click');
  4993. $('#resolutionMenu').on('click', (el) => {
  4994. const targ = $(el.target);
  4995.  
  4996. if (targ.data('src') === undefined) return;
  4997.  
  4998. setIframeUrl(targ.data('src'));
  4999.  
  5000. $('#resolutionMenu .active').removeClass('active');
  5001. targ.addClass('active');
  5002.  
  5003. $('#fansubMenu').html(targ.html());
  5004.  
  5005. const storage = getStorage();
  5006. const data = getStoredLinkData(storage);
  5007. data.subInfo = getSubInfo(targ.text());
  5008. saveData(storage);
  5009.  
  5010. $.cookie('res', targ.data('resolution'), {
  5011. expires: 365,
  5012. path: '/'
  5013. });
  5014. $.cookie('aud', targ.data('audio'), {
  5015. expires: 365,
  5016. path: '/'
  5017. });
  5018. $.cookie('av1', targ.data('av1'), {
  5019. expires: 365,
  5020. path: '/'
  5021. });
  5022. });
  5023. observer.disconnect();
  5024. }).observe($('#fansubMenu')[0], { childList: true });
  5025.  
  5026.  
  5027.  
  5028. if (initialStorage.settings.bestQuality === true) {
  5029. bestVideoQuality();
  5030. }
  5031. else if (!["","Loading..."].includes($('#fansubMenu').text())) {
  5032. $('#resolutionMenu .active').click();
  5033. } else {
  5034. new MutationObserver(function(mutationList, observer) {
  5035. $('#resolutionMenu .active').click();
  5036. observer.disconnect();
  5037. }).observe($('#fansubMenu')[0], { childList: true });
  5038. }
  5039.  
  5040. const timeArg = paramArray.find(a => a[0] === 'time');
  5041. if (timeArg !== undefined) {
  5042. applyTimeArg(timeArg);
  5043. }
  5044. }
  5045.  
  5046. function applyTimeArg(timeArg) {
  5047. const time = timeArg[1];
  5048.  
  5049. function check() {
  5050. if ($('.embed-responsive-item').attr('src') !== undefined) done();
  5051. else setTimeout(check, 100);
  5052. }
  5053. setTimeout(check, 100);
  5054.  
  5055. function done() {
  5056. setIframeUrl(stripUrl($('.embed-responsive-item').attr('src')) + '?time=' + time);
  5057.  
  5058. window.history.replaceState({}, document.title, window.location.origin + window.location.pathname);
  5059. }
  5060. }
  5061.  
  5062.  
  5063. function getTrackerDiv() {
  5064. return $(`<div id="anitracker"></div>`);
  5065. }
  5066.  
  5067. async function asyncGetAllEpisodes(session, sort = "asc") {
  5068. const episodeList = [];
  5069. const request = new XMLHttpRequest();
  5070. request.open('GET', `/api?m=release&sort=episode_${sort}&id=` + session, true);
  5071.  
  5072. return new Promise((resolve, reject) => {
  5073. request.onload = async function() {
  5074. if (request.status !== 200) {
  5075. reject("Received response code " + request.status);
  5076. return;
  5077. }
  5078.  
  5079. const response = JSON.parse(request.response);
  5080. if (response.current_page === response.last_page) {
  5081. episodeList.push(...response.data || []);
  5082. }
  5083. else for (let i = 1; i <= response.last_page; i++) {
  5084. const episodes = await asyncGetResponseData(`/api?m=release&sort=episode_${sort}&page=${i}&id=${session}`);
  5085. if (episodes === undefined || episodes.length === 0) continue;
  5086. episodeList.push(...episodes);
  5087. }
  5088. resolve(episodeList);
  5089. };
  5090. request.send();
  5091. });
  5092. }
  5093.  
  5094. async function getRelationData(session, relationType) {
  5095. const request = new XMLHttpRequest();
  5096. request.open('GET', '/anime/' + session, false);
  5097. request.send();
  5098.  
  5099. const page = request.status === 200 ? $(request.response) : {};
  5100.  
  5101. if (Object.keys(page).length === 0) return undefined;
  5102.  
  5103. const relationDiv = (() => {
  5104. for (const div of page.find('.anime-relation .col-12')) {
  5105. if ($(div).find('h4 span').text() !== relationType) continue;
  5106. return $(div);
  5107. break;
  5108. }
  5109. return undefined;
  5110. })();
  5111.  
  5112. if (relationDiv === undefined) return undefined;
  5113.  
  5114. const relationSession = new RegExp('^.*animepahe\.[a-z]+/anime/([^/]+)').exec(relationDiv.find('a')[0].href)[1];
  5115.  
  5116. return new Promise(resolve => {
  5117. const episodeList = [];
  5118. asyncGetAllEpisodes(relationSession).then((episodes) => {
  5119. episodeList.push(...episodes);
  5120.  
  5121. if (episodeList.length === 0) {
  5122. resolve(undefined);
  5123. return;
  5124. }
  5125.  
  5126. resolve({
  5127. episodes: episodeList,
  5128. name: $(relationDiv.find('h5')[0]).text(),
  5129. poster: relationDiv.find('img').attr('data-src').replace('.th',''),
  5130. session: relationSession
  5131. });
  5132. });
  5133.  
  5134. });
  5135. }
  5136.  
  5137. function hideSpinner(t, parents = 1) {
  5138. $(t).parents(`:eq(${parents})`).find('.anitracker-download-spinner').hide();
  5139. }
  5140.  
  5141. if (isEpisode()) {
  5142. getTrackerDiv().appendTo('.anime-note');
  5143.  
  5144. $('.prequel,.sequel').addClass('anitracker-thumbnail');
  5145.  
  5146. $(`
  5147. <span relationType="Prequel" class="dropdown-item anitracker-relation-link" id="anitracker-prequel-link">
  5148. Previous Anime
  5149. </span>`).prependTo('.episode-menu #scrollArea');
  5150.  
  5151. $(`
  5152. <span relationType="Sequel" class="dropdown-item anitracker-relation-link" id="anitracker-sequel-link">
  5153. Next Anime
  5154. </span>`).appendTo('.episode-menu #scrollArea');
  5155.  
  5156. $('.anitracker-relation-link').on('click', function() {
  5157. if (this.href !== undefined) {
  5158. $(this).off();
  5159. return;
  5160. }
  5161.  
  5162. $(this).parents(':eq(2)').find('.anitracker-download-spinner').show();
  5163.  
  5164. const animeData = getAnimeData();
  5165.  
  5166. if (animeData === undefined) {
  5167. hideSpinner(this, 2);
  5168. return;
  5169. }
  5170.  
  5171. const relationType = $(this).attr('relationType');
  5172. getRelationData(animeData.session, relationType).then((relationData) => {
  5173. if (relationData === undefined) {
  5174. hideSpinner(this, 2);
  5175. alert(`[AnimePahe Improvements]\n\nNo ${relationType.toLowerCase()} found for this anime.`);
  5176. $(this).remove();
  5177. return;
  5178. }
  5179.  
  5180. const episodeSession = relationType === 'Prequel' ? relationData.episodes[relationData.episodes.length-1].session : relationData.episodes[0].session;
  5181.  
  5182. windowOpen(`/play/${relationData.session}/${episodeSession}`, '_self');
  5183. hideSpinner(this, 2);
  5184. });
  5185.  
  5186. });
  5187.  
  5188. if ($('.prequel').length === 0) setPrequelPoster();
  5189. if ($('.sequel').length === 0) setSequelPoster();
  5190. } else {
  5191. getTrackerDiv().insertAfter('.anime-content');
  5192. }
  5193.  
  5194. async function setPrequelPoster() {
  5195. const relationData = await getRelationData(animeSession, 'Prequel');
  5196. if (relationData === undefined) {
  5197. $('#anitracker-prequel-link').remove();
  5198. return;
  5199. }
  5200. const relationLink = `/play/${relationData.session}/${relationData.episodes[relationData.episodes.length-1].session}`;
  5201. $(`
  5202. <div class="prequel hidden-sm-down anitracker-thumbnail">
  5203. <a href="${relationLink}" title="${toHtmlCodes("Play Last Episode of " + relationData.name)}">
  5204. <img style="filter: none;" src="${relationData.poster}" data-src="${relationData.poster}" alt="">
  5205. </a>
  5206. <i class="fa fa-chevron-left" aria-hidden="true"></i>
  5207. </div>`).appendTo('.player');
  5208.  
  5209. $('#anitracker-prequel-link').attr('href', relationLink);
  5210. $('#anitracker-prequel-link').text(relationData.name);
  5211. $('#anitracker-prequel-link').changeElementType('a');
  5212.  
  5213. // If auto-clear is on, delete this prequel episode from the tracker
  5214. if (getStorage().settings.autoDelete === true) {
  5215. deleteEpisodesFromTracker(undefined, relationData.name);
  5216. }
  5217. }
  5218.  
  5219. async function setSequelPoster() {
  5220. const relationData = await getRelationData(animeSession, 'Sequel');
  5221. if (relationData === undefined) {
  5222. $('#anitracker-sequel-link').remove();
  5223. return;
  5224. }
  5225. const relationLink = `/play/${relationData.session}/${relationData.episodes[0].session}`;
  5226. $(`
  5227. <div class="sequel hidden-sm-down anitracker-thumbnail">
  5228. <a href="${relationLink}" title="${toHtmlCodes("Play First Episode of " + relationData.name)}">
  5229. <img style="filter: none;" src="${relationData.poster}" data-src="${relationData.poster}" alt="">
  5230. </a>
  5231. <i class="fa fa-chevron-right" aria-hidden="true"></i>
  5232. </div>`).appendTo('.player');
  5233.  
  5234. $('#anitracker-sequel-link').attr('href', relationLink);
  5235. $('#anitracker-sequel-link').text(relationData.name);
  5236. $('#anitracker-sequel-link').changeElementType('a');
  5237. }
  5238.  
  5239. if (!isEpisode() && $('#anitracker') != undefined) {
  5240. $('#anitracker').attr('style', "max-width: 1100px;margin-left: auto;margin-right: auto;margin-bottom: 20px;");
  5241. }
  5242.  
  5243. if (isEpisode()) {
  5244. // Replace the download buttons with better ones
  5245. if ($('#pickDownload a').length > 0) replaceDownloadButtons();
  5246. else {
  5247. new MutationObserver(function(mutationList, observer) {
  5248. replaceDownloadButtons();
  5249. observer.disconnect();
  5250. }).observe($('#pickDownload')[0], { childList: true });
  5251. }
  5252.  
  5253.  
  5254. $(document).on('blur', () => {
  5255. $('.dropdown-menu.show').removeClass('show');
  5256. });
  5257.  
  5258. (() => {
  5259. const storage = getStorage();
  5260. const foundNotifEpisode = storage.notifications.episodes.find(a => a.session === episodeSession);
  5261. if (foundNotifEpisode !== undefined) {
  5262. foundNotifEpisode.watched = true;
  5263. saveData(storage);
  5264. }
  5265. })();
  5266. }
  5267.  
  5268. function replaceDownloadButtons() {
  5269. for (const aTag of $('#pickDownload a')) {
  5270. $(aTag).changeElementType('span');
  5271. }
  5272.  
  5273. $('#pickDownload span').on('click', function(e) {
  5274.  
  5275. let request = new XMLHttpRequest();
  5276. //request.open('GET', `https://opsalar.000webhostapp.com/animepahe.php?url=${$(this).attr('href')}`, true);
  5277. request.open('GET', $(this).attr('href'), true);
  5278. try {
  5279. request.send();
  5280. $(this).parents(':eq(1)').find('.anitracker-download-spinner').show();
  5281. }
  5282. catch (err) {
  5283. windowOpen($(this).attr('href')); // When failed, open the link normally
  5284. }
  5285.  
  5286. const dlBtn = $(this);
  5287.  
  5288. request.onload = function(e) {
  5289. hideSpinner(dlBtn);
  5290. if (request.readyState !== 4 || request.status !== 200 ) {
  5291. windowOpen(dlBtn.attr('href'));
  5292. return;
  5293. }
  5294.  
  5295. const htmlText = request.response;
  5296. const link = /https:\/\/kwik.\w+\/f\/[^"]+/.exec(htmlText);
  5297. if (link) {
  5298. dlBtn.attr('href', link[0]);
  5299. dlBtn.off();
  5300. dlBtn.changeElementType('a');
  5301. windowOpen(link[0]);
  5302. }
  5303. else windowOpen(dlBtn.attr('href'));
  5304.  
  5305. };
  5306. });
  5307. }
  5308.  
  5309. function stripUrl(url) {
  5310. if (url === undefined) {
  5311. console.error('[AnimePahe Improvements] stripUrl was used with undefined URL');
  5312. return url;
  5313. }
  5314. const loc = new URL(url);
  5315. return loc.origin + loc.pathname;
  5316. }
  5317.  
  5318. function temporaryHtmlChange(elem, delay, html, timeout = undefined) {
  5319. if (timeout !== undefined) clearTimeout(timeout);
  5320. if ($(elem).attr('og-html') === undefined) {
  5321. $(elem).attr('og-html', $(elem).html());
  5322. }
  5323. elem.html(html);
  5324. return setTimeout(() => {
  5325. $(elem).html($(elem).attr('og-html'));
  5326. }, delay);
  5327. }
  5328.  
  5329. $(`
  5330. <button class="btn btn-dark" id="anitracker-clear-from-tracker" title="Remove this page from the session tracker">
  5331. <i class="fa fa-trash" aria-hidden="true"></i>
  5332. &nbsp;Clear from Tracker
  5333. </button>`).appendTo('#anitracker');
  5334.  
  5335. $('#anitracker-clear-from-tracker').on('click', function() {
  5336. const animeName = getAnimeName();
  5337.  
  5338. if (isEpisode()) {
  5339. deleteEpisodeFromTracker(animeName, getEpisodeNum(), getAnimeData().id);
  5340.  
  5341. if ($('.embed-responsive-item').length > 0) {
  5342. const storage = getStorage();
  5343. const videoUrl = stripUrl($('.embed-responsive-item').attr('src'));
  5344. for (const videoData of storage.videoTimes) {
  5345. if (!videoData.videoUrls.includes(videoUrl)) continue;
  5346. const index = storage.videoTimes.indexOf(videoData);
  5347. storage.videoTimes.splice(index, 1);
  5348. saveData(storage);
  5349. break;
  5350. }
  5351. }
  5352. }
  5353. else {
  5354. const storage = getStorage();
  5355.  
  5356. storage.linkList = storage.linkList.filter(a => !(a.type === 'anime' && a.animeName === animeName));
  5357.  
  5358. saveData(storage);
  5359. }
  5360.  
  5361. temporaryHtmlChange($('#anitracker-clear-from-tracker'), 1500, 'Cleared!');
  5362. });
  5363.  
  5364. function setCoverBlur(img) {
  5365. const cover = $('.anime-cover');
  5366. const ratio = cover.width()/img.width;
  5367. if (ratio <= 1) return;
  5368. cover.css('filter', `blur(${(ratio*Math.max((img.height/img.width)**2, 1))*1.6}px)`);
  5369. }
  5370.  
  5371. function improvePoster() {
  5372. if ($('.anime-poster .youtube-preview').length === 0) {
  5373. $('.anime-poster .poster-image').attr('target','_blank');
  5374. return;
  5375. }
  5376. $('.anime-poster .youtube-preview').removeAttr('href');
  5377. $(`
  5378. <a style="display:block;" target="_blank" href="${$('.anime-poster img').attr('src')}">
  5379. View full poster
  5380. </a>`).appendTo('.anime-poster');
  5381. }
  5382.  
  5383. function setProgressBar(baseElem, epWatched, currentTime, duration) {
  5384. const progress = $(
  5385. `<div class="anitracker-episode-progress"></div>`
  5386. ).appendTo(baseElem);
  5387.  
  5388. if (epWatched) {
  5389. progress.css('width', '100%');
  5390. return;
  5391. }
  5392.  
  5393. progress.css('width', (currentTime / duration) * 100 + '%');
  5394. }
  5395.  
  5396. function updateEpisodesPage() {
  5397. const pageNum = (() => {
  5398. const elem = $('.pagination');
  5399. if (elem.length == 0) return 1;
  5400. return +/^(\d+)/.exec($('.pagination').find('.page-item.active span').text())[0];
  5401. })();
  5402.  
  5403. const episodeSort = $('.episode-bar .btn-group-toggle .active').text().trim();
  5404.  
  5405. const episodes = getResponseData(`/api?m=release&sort=episode_${episodeSort}&page=${pageNum}&id=${animeSession}`);
  5406. if (episodes === undefined) return undefined;
  5407. if (episodes.length === 0) return undefined;
  5408.  
  5409. const episodeElements = $('.episode-wrap');
  5410.  
  5411. const storage = getStorage();
  5412. const animeId = episodes[0].anime_id;
  5413. const watched = decodeWatched(storage.watched);
  5414. const videoTimes = storage.videoTimes.filter(a => (a.animeId === animeId || a.animeName === getAnimeName()));
  5415.  
  5416. for (let i = 0; i < episodeElements.length; i++) {
  5417. const elem = $(episodeElements[i]);
  5418.  
  5419. const date = new Date(episodes[i].created_at + " UTC");
  5420. const episode = episodes[i].episode;
  5421.  
  5422. const durParts = episodes[i].duration.split(':');
  5423. const duration = (+durParts[0] * 3600) + (+durParts[1] * 60) + (+durParts[2]);
  5424.  
  5425. elem.find('.episode-duration').text(secondsToHMS(duration));
  5426.  
  5427. if (elem.find('.anitracker-episode-time').length === 0) {
  5428. $(`
  5429. <a class="anitracker-episode-time" href="${$(elem.find('a.play')).attr('href')}" tabindex="-1" title="${date.toDateString() + " " + date.toLocaleTimeString()}">${date.toLocaleDateString()}</a>
  5430. `).appendTo(elem.find('.episode-title-wrap'));
  5431. }
  5432.  
  5433. const epWatched = isWatched(animeId, episode, watched);
  5434.  
  5435. if (elem.find('.anitracker-episode-menu-button').length === 0) {
  5436. $(`
  5437. <button class="anitracker-episode-menu-button" title="View episode options">
  5438. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512">
  5439. <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
  5440. <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>
  5441. </svg>
  5442. </button>
  5443. <div class="dropdown-menu anitracker-dropdown-content anitracker-episode-menu-dropdown" data-ep="${episode}" data-duration="${Math.floor(duration)}">
  5444. <button title="Copy a link to this episode" data-action="copy">Copy link</button>
  5445. <button title="Toggle this episode being fully watched" data-action="toggle-watched">Mark ${epWatched ? 'unwatched' : 'watched'}</button>
  5446. </div>
  5447. `).appendTo(elem.find('.episode')).data('watched', epWatched);
  5448. }
  5449. else {
  5450. elem.find('.anitracker-episode-menu-dropdown>button[data-action="toggle-watched"]').text(`Mark ${epWatched ? 'unwatched' : 'watched'}`);
  5451. elem.find('.anitracker-episode-menu-dropdown').data('watched', epWatched);
  5452. }
  5453.  
  5454. elem.find('.anitracker-episode-progress').remove();
  5455.  
  5456. const foundProgress = videoTimes.find(e => e.episodeNum === episode);
  5457. if (!epWatched && foundProgress === undefined) continue;
  5458.  
  5459. setProgressBar(elem.find('.episode-snapshot'), epWatched, foundProgress?.time, duration);
  5460. }
  5461.  
  5462. $('.anitracker-episode-menu-button').off('click').on('click', (e) => {
  5463. const elem = $(e.currentTarget);
  5464. const dropdown = elem.parent().find('.anitracker-episode-menu-dropdown');
  5465. dropdown.toggle();
  5466. if (!dropdown.is(':visible')) elem.blur();
  5467. })
  5468. .off('blur').on('blur', (e) => {
  5469. const dropdown = $(e.currentTarget).parent().find('.anitracker-episode-menu-dropdown');
  5470. const dropdownBtns = dropdown.find('button');
  5471. setTimeout(() => {
  5472. if (dropdownBtns.is(':focus')) return;
  5473. dropdown.hide();
  5474. }, 100);
  5475. });
  5476.  
  5477. $('.anitracker-episode-menu-dropdown>button').off('click').on('click', (e) => {
  5478. const elem = $(e.currentTarget);
  5479. const dropdown = elem.parent();
  5480. const episode = +dropdown.data('ep');
  5481. const action = elem.data('action');
  5482. dropdown.hide();
  5483.  
  5484. if (action === 'copy') {
  5485. const name = encodeURIComponent(getAnimeName());
  5486. navigator.clipboard.writeText(window.location.origin + '/customlink?a=' + name + '&e=' + episode);
  5487. return;
  5488. }
  5489. if (action === 'toggle-watched') {
  5490. const epWatched = dropdown.data('watched');
  5491. dropdown.data('watched', !epWatched);
  5492. const animeData = getAnimeData();
  5493. const animeId = animeData.id;
  5494. const progressContainer = dropdown.parent().find('.episode-snapshot');
  5495. const videoTime = getStoredTime(animeData.title, episode, getStorage(), animeId);
  5496. if (epWatched) {
  5497. removeWatched(animeId, episode);
  5498. progressContainer.find('.anitracker-episode-progress').remove();
  5499. }
  5500. else {
  5501. addWatched(animeId, episode);
  5502. }
  5503.  
  5504. // epWatched is the opposite of what it *will* be
  5505. if (!epWatched || videoTime !== undefined) setProgressBar(progressContainer, !epWatched, videoTime?.time, +dropdown.data('duration'));
  5506.  
  5507. elem.text('Mark ' + (epWatched ? 'watched' : 'unwatched'));
  5508. }
  5509. })
  5510. .off('blur').on('blur', (e) => {
  5511. const dropdown = $(e.currentTarget).parent();
  5512. const btn = dropdown.parent().find('.anitracker-episode-menu-button');
  5513. const dropdownBtns = dropdown.find('button');
  5514. setTimeout(() => {
  5515. if (btn.is(':focus') || dropdownBtns.is(':focus')) return;
  5516. $(e.currentTarget).parent().hide();
  5517. }, 100);
  5518. });
  5519. }
  5520.  
  5521. if (isAnime()) {
  5522. if ($('.anime-poster img').attr('src') !== undefined) {
  5523. improvePoster();
  5524. }
  5525. else $('.anime-poster img').on('load', (e) => {
  5526. improvePoster();
  5527. $(e.target).off('load');
  5528. });
  5529.  
  5530. $(`
  5531. <button class="btn btn-dark" id="anitracker-clear-episodes-from-tracker" title="Clear all episodes from this anime from the session tracker">
  5532. <i class="fa fa-trash" aria-hidden="true"></i>
  5533. <i class="fa fa-window-maximize" aria-hidden="true"></i>
  5534. &nbsp;Clear Episodes from Tracker
  5535. </button>`).appendTo('#anitracker');
  5536.  
  5537. $('#anitracker-clear-episodes-from-tracker').on('click', function() {
  5538. const animeData = getAnimeData();
  5539. deleteEpisodesFromTracker(undefined, animeData.title, animeData.id);
  5540.  
  5541. temporaryHtmlChange($('#anitracker-clear-episodes-from-tracker'), 1500, 'Cleared!');
  5542.  
  5543. updateEpisodesPage();
  5544. });
  5545.  
  5546. const storedObj = getStoredLinkData(initialStorage);
  5547.  
  5548. if (storedObj === undefined || storedObj?.coverImg === undefined) updateAnimeCover();
  5549. else
  5550. {
  5551. new MutationObserver(function(mutationList, observer) {
  5552. $('.anime-cover').css('background-image', `url("${storedObj.coverImg}")`);
  5553. $('.anime-cover').addClass('anitracker-replaced-cover');
  5554. const img = new Image();
  5555. img.src = storedObj.coverImg;
  5556. img.onload = () => {
  5557. setCoverBlur(img);
  5558. };
  5559. observer.disconnect();
  5560. }).observe($('.anime-cover')[0], { attributes: true });
  5561. }
  5562.  
  5563. if (isRandomAnime()) {
  5564. window.history.replaceState({}, document.title, "/anime/" + animeSession);
  5565.  
  5566. const storage = getStorage();
  5567. let preparedList = [];
  5568. if (storage.temp) {
  5569. preparedList = storage.temp;
  5570. delete storage.temp;
  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. const params = new URLSearchParams('anitracker-random=1');
  5587.  
  5588. if (preparedList.length > 0) {
  5589. const storage = getStorage();
  5590. storage.temp = preparedList;
  5591. saveData(storage);
  5592.  
  5593. getRandomAnime(preparedList, getSearchParamsString(params), '_self');
  5594. }
  5595. else {
  5596. getFilteredList([]).then((animeList) => {
  5597. const storage = getStorage();
  5598. storage.temp = animeList;
  5599. saveData(storage);
  5600.  
  5601. getRandomAnime(animeList, getSearchParamsString(params), '_self');
  5602. });
  5603. }
  5604.  
  5605. });
  5606.  
  5607. $('#anitracker-save-session').on('click', function() {
  5608. setSessionData();
  5609. $('#anitracker-save-session').off();
  5610. $(this).text('Saved!');
  5611.  
  5612. setTimeout(() => {
  5613. $(this).parent().remove();
  5614. }, 1500);
  5615. });
  5616. }
  5617.  
  5618. // Show episode upload time & episode progress
  5619. new MutationObserver(function(mutationList, observer) {
  5620. updateEpisodesPage();
  5621.  
  5622. observer.disconnect();
  5623. setTimeout(observer.observe($('.episode-list-wrapper')[0], { childList: true, subtree: false }), 1);
  5624. }).observe($('.episode-list-wrapper')[0], { childList: true, subtree: false });
  5625.  
  5626. // Bookmark icon
  5627. const animename = getAnimeName();
  5628. const animeid = getAnimeData(animename).id;
  5629. $('h1 .fa').remove();
  5630.  
  5631. const notifIcon = (() => {
  5632. if (initialStorage.notifications.anime.find(a => a.name === animename) !== undefined) return true;
  5633. for (const info of $('.anime-info p>strong')) {
  5634. if (!$(info).text().startsWith('Status:')) continue;
  5635. return $(info).text().includes("Not yet aired") || $(info).find('a').text() === "Currently Airing";
  5636. }
  5637. return false;
  5638. })() ?
  5639. `<i title="Add to episode feed" class="fa fa-bell anitracker-title-icon anitracker-notifications-toggle">
  5640. <i style="display: none;" class="fa fa-check anitracker-title-icon-check" aria-hidden="true"></i>
  5641. </i>` : '';
  5642.  
  5643. $(`
  5644. <i title="Bookmark this anime" class="fa fa-bookmark anitracker-title-icon anitracker-bookmark-toggle">
  5645. <i style="display: none;" class="fa fa-check anitracker-title-icon-check" aria-hidden="true"></i>
  5646. </i>${notifIcon}<a href="/a/${animeid}" title="Get Link" class="fa fa-link btn anitracker-title-icon" data-toggle="modal" data-target="#modalBookmark"></a>
  5647. `).appendTo('.title-wrapper>h1');
  5648.  
  5649. if (initialStorage.bookmarks.find(g => g.id === animeid) !== undefined) {
  5650. $('.anitracker-bookmark-toggle .anitracker-title-icon-check').show();
  5651. }
  5652.  
  5653. if (initialStorage.notifications.anime.find(g => g.id === animeid) !== undefined) {
  5654. $('.anitracker-notifications-toggle .anitracker-title-icon-check').show();
  5655. }
  5656.  
  5657. $('.anitracker-bookmark-toggle').on('click', (e) => {
  5658. const check = $(e.currentTarget).find('.anitracker-title-icon-check');
  5659.  
  5660. if (toggleBookmark(animeid, animename)) {
  5661. check.show();
  5662. return;
  5663. }
  5664. check.hide();
  5665.  
  5666. });
  5667.  
  5668. $('.anitracker-notifications-toggle').on('click', (e) => {
  5669. const check = $(e.currentTarget).find('.anitracker-title-icon-check');
  5670.  
  5671. if (toggleNotifications(animename, animeid)) {
  5672. check.show();
  5673. return;
  5674. }
  5675. check.hide();
  5676.  
  5677. });
  5678. }
  5679.  
  5680. function getRandomAnime(list, args, openType = '_blank') {
  5681. if (list.length === 0) {
  5682. alert("[AnimePahe Improvements]\n\nThere is no anime that matches the selected filters.");
  5683. return;
  5684. }
  5685. const random = randint(0, list.length-1);
  5686. windowOpen(list[random].link + args, openType);
  5687. }
  5688.  
  5689. function isRandomAnime() {
  5690. return new URLSearchParams(window.location.search).has('anitracker-random');
  5691. }
  5692.  
  5693. function getBadCovers() {
  5694. const storage = getStorage();
  5695. return ['https://s.pximg.net/www/images/pixiv_logo.png',
  5696. 'https://st.deviantart.net/minish/main/logo/card_black_large.png',
  5697. 'https://www.wcostream.com/wp-content/themes/animewp78712/images/logo.gif',
  5698. 'https://s.pinimg.com/images/default_open_graph',
  5699. 'https://share.redd.it/preview/post/',
  5700. 'https://i.redd.it/o0h58lzmax6a1.png',
  5701. 'https://ir.ebaystatic.com/cr/v/c1/ebay-logo',
  5702. 'https://i.ebayimg.com/images/g/7WgAAOSwQ7haxTU1/s-l1600.jpg',
  5703. 'https://www.rottentomatoes.com/assets/pizza-pie/head-assets/images/RT_TwitterCard',
  5704. 'https://m.media-amazon.com/images/G/01/social_share/amazon_logo',
  5705. 'https://zoro.to/images/capture.png',
  5706. 'https://cdn.myanimelist.net/img/sp/icon/twitter-card.png',
  5707. 'https://s2.bunnycdn.ru/assets/sites/animesuge/images/preview.jpg',
  5708. 'https://s2.bunnycdn.ru/assets/sites/anix/preview.jpg',
  5709. 'https://cdn.myanimelist.net/images/company_no_picture.png',
  5710. 'https://myanimeshelf.com/eva2/handlers/generateSocialImage.php',
  5711. 'https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon',
  5712. 'https://m.media-amazon.com/images/G/01/imdb/images/social',
  5713. 'https://forums.animeuknews.net/styles/default/',
  5714. 'https://honeysanime.com/wp-content/uploads/2016/12/facebook_cover_2016_851x315.jpg',
  5715. 'https://fi.somethingawful.com/images/logo.png',
  5716. 'https://static.hidive.com/misc/HIDIVE-Logo-White.png',
  5717. ...storage.badCovers];
  5718. }
  5719.  
  5720. async function updateAnimeCover() {
  5721. $(`<div id="anitracker-cover-spinner" class="anitracker-spinner">
  5722. <div class="spinner-border" role="status">
  5723. <span class="sr-only">Loading...</span>
  5724. </div>
  5725. </div>`).prependTo('.anime-cover');
  5726.  
  5727. const request = new XMLHttpRequest();
  5728. let beforeYear = 2022;
  5729. for (const info of $('.anime-info p')) {
  5730. if (!$(info).find('strong').html().startsWith('Season:')) continue;
  5731. const year = +/(\d+)$/.exec($(info).find('a').text())[0];
  5732. if (year >= beforeYear) beforeYear = year + 1;
  5733. }
  5734. 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);
  5735. request.onload = function() {
  5736. if (request.status !== 200) {
  5737. $('#anitracker-cover-spinner').remove();
  5738. return;
  5739. }
  5740. if ($('.anime-cover').css('background-image').length > 10) {
  5741. decideAnimeCover(request.response);
  5742. }
  5743. else {
  5744. new MutationObserver(function(mutationList, observer) {
  5745. if ($('.anime-cover').css('background-image').length <= 10) return;
  5746. decideAnimeCover(request.response);
  5747. observer.disconnect();
  5748. }).observe($('.anime-cover')[0], { attributes: true });
  5749. }
  5750. };
  5751. request.send();
  5752. }
  5753.  
  5754. function trimHttp(string) {
  5755. return string.replace(/^https?:\/\//,'');
  5756. }
  5757.  
  5758. async function setAnimeCover(src) {
  5759. return new Promise(resolve => {
  5760. $('.anime-cover').css('background-image', `url("${storedObj.coverImg}")`);
  5761. $('.anime-cover').addClass('anitracker-replaced-cover');
  5762. const img = new Image();
  5763. img.src = src;
  5764. img.onload = () => {
  5765. setCoverBlur(img);
  5766. }
  5767.  
  5768. $('.anime-cover').addClass('anitracker-replaced-cover');
  5769. $('.anime-cover').css('background-image', `url("${src}")`);
  5770. $('.anime-cover').attr('image', src);
  5771.  
  5772. $('#anitracker-replace-cover').remove();
  5773. $(`<button class="btn btn-dark" id="anitracker-replace-cover" title="Use another cover instead">
  5774. <i class="fa fa-refresh" aria-hidden="true"></i>
  5775. </button>`).appendTo('.anime-cover');
  5776.  
  5777. $('#anitracker-replace-cover').on('click', e => {
  5778. const storage = getStorage();
  5779. storage.badCovers.push($('.anime-cover').attr('image'));
  5780. saveData(storage);
  5781. updateAnimeCover();
  5782. $(e.target).off();
  5783. playAnimation($(e.target).find('i'), 'spin', 'infinite', 1);
  5784. });
  5785.  
  5786. setCoverBlur(image);
  5787. });
  5788. }
  5789.  
  5790. async function decideAnimeCover(response) {
  5791. const badCovers = getBadCovers();
  5792. const candidates = [];
  5793. let results = [];
  5794. try {
  5795. results = JSON.parse(response).items;
  5796. }
  5797. catch (e) {
  5798. return;
  5799. }
  5800. if (results === undefined) {
  5801. $('#anitracker-cover-spinner').remove();
  5802. return;
  5803. }
  5804. for (const result of results) {
  5805. let imgUrl = result['pagemap']?.['metatags']?.[0]?.['og:image'] ||
  5806. result['pagemap']?.['cse_image']?.[0]?.['src'] || result['pagemap']?.['webpage']?.[0]?.['image'] ||
  5807. result['pagemap']?.['metatags']?.[0]?.['twitter:image:src'];
  5808.  
  5809.  
  5810. const width = result['pagemap']?.['cse_thumbnail']?.[0]?.['width'];
  5811. const height = result['pagemap']?.['cse_thumbnail']?.[0]?.['height'];
  5812.  
  5813. if (imgUrl === undefined || height < 100 || badCovers.find(a=> trimHttp(imgUrl).startsWith(trimHttp(a))) !== undefined || imgUrl.endsWith('.gif')) continue;
  5814.  
  5815. if (imgUrl.startsWith('https://static.wikia.nocookie.net')) {
  5816. imgUrl = imgUrl.replace(/\/revision\/latest.*\?cb=\d+$/, '');
  5817. }
  5818.  
  5819. candidates.push({
  5820. src: imgUrl,
  5821. width: width,
  5822. height: height,
  5823. aspectRatio: width / height
  5824. });
  5825. }
  5826.  
  5827. if (candidates.length === 0) return;
  5828.  
  5829. candidates.sort((a, b) => {return a.aspectRatio < b.aspectRatio ? 1 : -1});
  5830.  
  5831. if (candidates[0].src.includes('"')) return;
  5832.  
  5833. const originalBg = $('.anime-cover').css('background-image');
  5834.  
  5835. function badImg() {
  5836. $('.anime-cover').css('background-image', originalBg);
  5837.  
  5838. const storage = getStorage();
  5839. for (const anime of storage.linkList) {
  5840. if (anime.type === 'anime' && anime.animeSession === animeSession) {
  5841. anime.coverImg = /^url\("?([^"]+)"?\)$/.exec(originalBg)[1];
  5842. break;
  5843. }
  5844. }
  5845. saveData(storage);
  5846.  
  5847. $('#anitracker-cover-spinner').remove();
  5848. }
  5849.  
  5850. const image = new Image();
  5851. image.onload = () => {
  5852. if (image.width >= 250) {
  5853.  
  5854. $('.anime-cover').addClass('anitracker-replaced-cover');
  5855. $('.anime-cover').css('background-image', `url("${candidates[0].src}")`);
  5856. $('.anime-cover').attr('image', candidates[0].src);
  5857. setCoverBlur(image);
  5858. const storage = getStorage();
  5859. for (const anime of storage.linkList) {
  5860. if (anime.type === 'anime' && anime.animeSession === animeSession) {
  5861. anime.coverImg = candidates[0].src;
  5862. break;
  5863. }
  5864. }
  5865. saveData(storage);
  5866.  
  5867. $('#anitracker-cover-spinner').remove();
  5868. }
  5869. else badImg();
  5870. };
  5871.  
  5872. image.addEventListener('error', function() {
  5873. badImg();
  5874. });
  5875.  
  5876. image.src = candidates[0].src;
  5877. }
  5878.  
  5879. function hideThumbnails() {
  5880. $('.main').addClass('anitracker-hide-thumbnails');
  5881. }
  5882.  
  5883. function resetPlayer() {
  5884. setIframeUrl(stripUrl($('.embed-responsive-item').attr('src')));
  5885. }
  5886.  
  5887. function addGeneralButtons() {
  5888. $(`
  5889. <button class="btn btn-dark" id="anitracker-show-data" title="View and handle stored sessions and video progress">
  5890. <i class="fa fa-floppy-o" aria-hidden="true"></i>
  5891. &nbsp;Manage Data...
  5892. </button>
  5893. <button class="btn btn-dark" id="anitracker-settings" title="Options">
  5894. <i class="fa fa-sliders" aria-hidden="true"></i>
  5895. &nbsp;Options...
  5896. </button>`).appendTo('#anitracker');
  5897.  
  5898. $('#anitracker-settings').on('click', () => {
  5899. $('#anitracker-modal-body').empty();
  5900.  
  5901. if (isAnime() || isEpisode())
  5902. $(`<div class="btn-group">
  5903. <button class="btn btn-secondary" id="anitracker-refresh-session" title="Refresh the session for the current page">
  5904. <i class="fa fa-refresh" aria-hidden="true"></i>
  5905. &nbsp;Refresh Session
  5906. </button></div>`).appendTo('#anitracker-modal-body');
  5907.  
  5908. $('<span style="display:block;margin-top:10px">Video player:</span>').appendTo('#anitracker-modal-body');
  5909.  
  5910. addOptionSwitch('autoPlayVideo', 'Auto-Play Video', 'Automatically play the video when it is loaded.');
  5911. addOptionSwitch('theatreMode', 'Theatre Mode', 'Expand the video player for a better experience on bigger screens.');
  5912. addOptionSwitch('bestQuality', 'Default to Best Quality', 'Automatically select the best resolution quality available.');
  5913. addOptionSwitch('seekThumbnails', 'Seek Thumbnails', 'Show thumbnail images while seeking through the progress bar. May cause performance issues on weak systems.');
  5914. addOptionSwitch('seekPoints', 'Seek Points', 'Show points on the progress bar.');
  5915. addOptionSwitch('skipButton', 'Skip Button', 'Show a button to skip sections of episodes.');
  5916. addOptionSwitch('copyScreenshots', 'Copy Screenshots', 'Copy screenshots to the clipboard, instead of downloading them.');
  5917.  
  5918. if (isEpisode()) {
  5919. const data = getAnimeData();
  5920. $(`
  5921. <div class="btn-group">
  5922. <button class="btn btn-secondary" id="anitracker-reset-player" title="Reset the video player">
  5923. <i class="fa fa-rotate-right" aria-hidden="true"></i>
  5924. &nbsp;Reset Player
  5925. </button>
  5926. </div><br>
  5927. <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">
  5928. <button class="btn btn-secondary anitracker-flat-button" id="anitracker-report-timestamps" title="Open a new issue for incorrect timestamps on this episode">
  5929. <i class="fa fa-external-link"></i>
  5930. &nbsp;Report Timestamp Issue
  5931. </button>
  5932. </a>`).appendTo('#anitracker-modal-body');
  5933.  
  5934. $('#anitracker-reset-player').on('click', function() {
  5935. closeModal();
  5936. resetPlayer();
  5937. });
  5938. }
  5939.  
  5940. $('<span style="display:block;margin-top:10px;">Site:</span>').appendTo('#anitracker-modal-body');
  5941. addOptionSwitch('hideThumbnails', 'Hide Thumbnails', 'Hide thumbnails and preview images.');
  5942. addOptionSwitch('autoDelete', 'Auto-Clear Links', 'Only one episode of a series is stored in the tracker at a time.');
  5943. addOptionSwitch('autoDownload', 'Automatic Download', 'Automatically download the episode when visiting a download page.');
  5944. addOptionSwitch('reduceMotion', 'Reduce Motion', 'Don\'t show animations for opening/closing modal menus.');
  5945.  
  5946. if (isAnime()) {
  5947. $(`
  5948. <span style="display:block;margin-top:10px;">This anime:</span>
  5949. <div class="btn-group">
  5950. <button class="btn btn-secondary" id="anitracker-mark-watched" title="Mark all episodes of this anime as fully watched">
  5951. <i class="fa fa-eye" aria-hidden="true"></i>
  5952. &nbsp;Mark As Watched
  5953. </button>
  5954. </div>
  5955. <div class="anitracker-mark-watched-spinner anitracker-spinner" style="display: none;vertical-align: bottom;">
  5956. <div class="spinner-border" role="status">
  5957. <span class="sr-only">Loading...</span>
  5958. </div>
  5959. </div>
  5960. <div class="btn-group" style="display:block;margin-top: 5px;">
  5961. <button class="btn btn-secondary" id="anitracker-unmark-watched" title="Unmark all fully watched episodes of this anime">
  5962. <i class="fa fa-eye-slash" aria-hidden="true"></i>
  5963. &nbsp;Unmark Watched Episodes
  5964. </button>
  5965. </div>`).appendTo('#anitracker-modal-body');
  5966.  
  5967. $('#anitracker-mark-watched').on('click', function(e) {
  5968. $(e.currentTarget).prop('disabled', true);
  5969. $('.anitracker-mark-watched-spinner').css('display','inline');
  5970. asyncGetAllEpisodes(animeSession).then((episodes) => {
  5971. $(e.currentTarget).prop('disabled', false);
  5972.  
  5973. if (episodes.length === 0) {
  5974. $('.anitracker-mark-watched-spinner').css('display','none');
  5975. return;
  5976. }
  5977.  
  5978. const converted = episodes.map(e => e.episode);
  5979.  
  5980. const storage = getStorage();
  5981. const watched = decodeWatched(storage.watched);
  5982. const animeId = getAnimeData().id;
  5983.  
  5984. const found = watched.find(a => a.animeId === animeId);
  5985. if (found !== undefined) {
  5986. found.episodes = converted;
  5987. }
  5988. else {
  5989. watched.push({
  5990. animeId: animeId,
  5991. episodes: converted
  5992. });
  5993. }
  5994.  
  5995. storage.watched = encodeWatched(watched);
  5996. saveData(storage);
  5997.  
  5998. closeModal();
  5999. updateEpisodesPage();
  6000. });
  6001. });
  6002.  
  6003. $('#anitracker-unmark-watched').on('click', function() {
  6004. closeModal();
  6005. removeWatchedAnime(getAnimeData().id);
  6006. updateEpisodesPage();
  6007. });
  6008. }
  6009.  
  6010. $('#anitracker-refresh-session').on('click', function(e) {
  6011. const elem = $('#anitracker-refresh-session');
  6012. let timeout = temporaryHtmlChange(elem, 2200, 'Waiting...');
  6013.  
  6014. const result = refreshSession();
  6015.  
  6016. if (result === 0) {
  6017. temporaryHtmlChange(elem, 2200, '<i class="fa fa-refresh" aria-hidden="true" style="animation: anitracker-spin 1s linear infinite;"></i>&nbsp;&nbsp;Refreshing...', timeout);
  6018. }
  6019. else if ([2,3].includes(result)) {
  6020. temporaryHtmlChange(elem, 2200, 'Failed: Couldn\'t find session', timeout);
  6021. }
  6022. else {
  6023. temporaryHtmlChange(elem, 2200, 'Failed.', timeout);
  6024. }
  6025. });
  6026.  
  6027. openModal();
  6028. });
  6029.  
  6030. function openShowDataModal() {
  6031. $('#anitracker-modal-body').empty();
  6032. $(`
  6033. <div class="anitracker-modal-list-container">
  6034. <div class="anitracker-storage-data" title="Expand or retract the storage entry for page sessions" tabindex="0" key="linkList">
  6035. <span>Session Data</span>
  6036. </div>
  6037. </div>
  6038. <div class="anitracker-modal-list-container">
  6039. <div class="anitracker-storage-data" title="Expand or retract the storage entry for video progress" tabindex="0" key="videoTimes">
  6040. <span>Video Progress</span>
  6041. </div>
  6042. </div>
  6043. <div class="anitracker-modal-list-container">
  6044. <div class="anitracker-storage-data" title="Expand or retract the storage entry for episodes marked as watched" tabindex="0" key="watched">
  6045. <span>Watched Episodes</span>
  6046. </div>
  6047. </div>
  6048. <div class="anitracker-modal-list-container">
  6049. <div class="anitracker-storage-data" title="Expand or retract the storage entry for anime-specific video playback speed" tabindex="0" key="videoSpeed">
  6050. <span>Video Playback Speed</span>
  6051. </div>
  6052. </div>
  6053. <div class="anitracker-modal-bottom-buttons">
  6054. <div class="btn-group">
  6055. <button class="btn btn-danger" id="anitracker-reset-data" title="Remove stored data and reset all settings">
  6056. <i class="fa fa-undo" aria-hidden="true"></i>
  6057. &nbsp;Reset Data
  6058. </button>
  6059. </div>
  6060. <div class="btn-group">
  6061. <button class="btn btn-secondary" id="anitracker-raw-data" title="View data in JSON format">
  6062. <i class="fa fa-code" aria-hidden="true"></i>
  6063. &nbsp;Raw
  6064. </button>
  6065. </div>
  6066. <div class="btn-group">
  6067. <button class="btn btn-secondary" id="anitracker-export-data" title="Export and download the JSON data">
  6068. <i class="fa fa-download" aria-hidden="true"></i>
  6069. &nbsp;Export Data
  6070. </button>
  6071. </div>
  6072. <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.">
  6073. <i class="fa fa-upload" aria-hidden="true"></i>
  6074. &nbsp;Import Data
  6075. </label>
  6076. <div class="btn-group">
  6077. <button class="btn btn-dark" id="anitracker-edit-data" title="Edit a key">
  6078. <i class="fa fa-pencil" aria-hidden="true"></i>
  6079. &nbsp;Edit...
  6080. </button>
  6081. </div>
  6082. <input type="file" id="anitracker-import-data" style="visibility: hidden; width: 0;" accept=".json">
  6083. </div>
  6084. `).appendTo('#anitracker-modal-body');
  6085.  
  6086. const expandIcon = `<i class="fa fa-plus anitracker-expand-data-icon" aria-hidden="true"></i>`;
  6087. const contractIcon = `<i class="fa fa-minus anitracker-expand-data-icon" aria-hidden="true"></i>`;
  6088.  
  6089. $(expandIcon).appendTo('.anitracker-storage-data');
  6090.  
  6091. $('.anitracker-storage-data').on('click keydown', (e) => {
  6092. if (e.type === 'keydown' && e.key !== "Enter") return;
  6093. toggleExpandData($(e.currentTarget));
  6094. });
  6095.  
  6096. function toggleExpandData(elem) {
  6097. if (elem.hasClass('anitracker-expanded')) {
  6098. contractData(elem);
  6099. }
  6100. else {
  6101. expandData(elem);
  6102. }
  6103. }
  6104.  
  6105. $('#anitracker-reset-data').on('click', function() {
  6106. if (confirm('[AnimePahe Improvements]\n\nThis will remove all saved data and reset it to its default state.\nAre you sure?') === true) {
  6107. saveData(getDefaultData());
  6108. updatePage();
  6109. openShowDataModal();
  6110. }
  6111. });
  6112.  
  6113. $('#anitracker-raw-data').on('click', function() {
  6114. const blob = new Blob([JSON.stringify(getStorage())], {type : 'application/json'});
  6115. windowOpen(URL.createObjectURL(blob));
  6116. });
  6117.  
  6118. $('#anitracker-edit-data').on('click', function() {
  6119. $('#anitracker-modal-body').empty();
  6120. $(`
  6121. <b>Warning: for developer use.<br>Back up your data before messing with this.</b>
  6122. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-edit-data-key" placeholder="Key (Path)">
  6123. <input autocomplete="off" class="form-control anitracker-text-input-bar anitracker-edit-data-value" placeholder="Value (JSON)">
  6124. <p>Leave value empty to get the existing value</p>
  6125. <div class="btn-group">
  6126. <button class="btn dropdown-toggle btn-secondary anitracker-edit-mode-dropdown-button" data-bs-toggle="dropdown" data-toggle="dropdown" data-value="replace">Replace</button>
  6127. <div class="dropdown-menu anitracker-dropdown-content anitracker-edit-mode-dropdown"></div>
  6128. </div>
  6129. <div class="btn-group">
  6130. <button class="btn btn-primary anitracker-confirm-edit-button">Confirm</button>
  6131. </div>
  6132. `).appendTo('#anitracker-modal-body');
  6133.  
  6134. [{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') });
  6135.  
  6136. $('.anitracker-edit-mode-dropdown button').on('click', (e) => {
  6137. const pressed = $(e.target)
  6138. const btn = pressed.parents().eq(1).find('.anitracker-edit-mode-dropdown-button');
  6139. btn.data('value', pressed.attr('ref'));
  6140. btn.text(pressed.text());
  6141. });
  6142.  
  6143. $('.anitracker-confirm-edit-button').on('click', () => {
  6144. const storage = getStorage();
  6145. const key = $('.anitracker-edit-data-key').val();
  6146. let keyValue = undefined;
  6147. try {
  6148. keyValue = eval("storage." + key); // lots of evals here because I'm lazy
  6149. }
  6150. catch (e) {
  6151. console.error(e);
  6152. alert("Nope didn't work");
  6153. return;
  6154. }
  6155.  
  6156. if ($('.anitracker-edit-data-value').val() === '') {
  6157. alert(JSON.stringify(keyValue));
  6158. return;
  6159. }
  6160.  
  6161. if (keyValue === undefined) {
  6162. alert("Undefined");
  6163. return;
  6164. }
  6165.  
  6166. const mode = $('.anitracker-edit-mode-dropdown-button').data('value');
  6167.  
  6168. let value = undefined;
  6169. if (mode === 'delList') {
  6170. value = $('.anitracker-edit-data-value').val();
  6171. }
  6172. else if ($('.anitracker-edit-data-value').val() !== "undefined") {
  6173. try {
  6174. value = JSON.parse($('.anitracker-edit-data-value').val());
  6175. }
  6176. catch (e) {
  6177. console.error(e);
  6178. alert("Invalid JSON");
  6179. return;
  6180. }
  6181. }
  6182.  
  6183. 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.";
  6184.  
  6185. switch (mode) {
  6186. case 'replace':
  6187. eval(`storage.${key} = value`);
  6188. break;
  6189. case 'append':
  6190. if (keyValue.constructor.name !== 'Array') {
  6191. alert("Not a list");
  6192. return;
  6193. }
  6194. eval(`storage.${key}.push(value)`);
  6195. break;
  6196. case 'delList':
  6197. if (keyValue.constructor.name !== 'Array') {
  6198. alert("Not a list");
  6199. return;
  6200. }
  6201. try {
  6202. eval(`storage.${key} = storage.${key}.filter(a => !(${value}))`);
  6203. }
  6204. catch (e) {
  6205. console.error(e);
  6206. alert(delFromListMessage);
  6207. return;
  6208. }
  6209. break;
  6210. default:
  6211. alert("This message isn't supposed to show up. Uh...");
  6212. return;
  6213. }
  6214. if (JSON.stringify(storage) === JSON.stringify(getStorage())) {
  6215. alert("Nothing changed.");
  6216. if (mode === 'delList') {
  6217. alert(delFromListMessage);
  6218. }
  6219. return;
  6220. }
  6221. else alert("Probably worked!");
  6222.  
  6223. saveData(storage);
  6224. });
  6225.  
  6226. openModal(openShowDataModal);
  6227. });
  6228.  
  6229. $('#anitracker-export-data').on('click', function() {
  6230. const storage = getStorage();
  6231.  
  6232. if (storage.temp) {
  6233. delete storage.temp;
  6234. saveData(storage);
  6235. }
  6236. download('animepahe-tracked-data-' + Date.now() + '.json', JSON.stringify(getStorage(), null, 2));
  6237. });
  6238.  
  6239. $('#anitracker-import-data-label').on('keydown', (e) => {
  6240. if (e.key === "Enter") $("#" + $(e.currentTarget).attr('for')).click();
  6241. });
  6242.  
  6243. $('#anitracker-import-data').on('change', function(event) {
  6244. const file = this.files[0];
  6245. const fileReader = new FileReader();
  6246. $(fileReader).on('load', function() {
  6247. let newData = {};
  6248. try {
  6249. newData = JSON.parse(fileReader.result);
  6250. }
  6251. catch (err) {
  6252. alert('[AnimePahe Improvements]\n\nPlease input a valid JSON file.');
  6253. return;
  6254. }
  6255.  
  6256. const storage = getStorage();
  6257. const diffBefore = importData(storage, newData, false);
  6258.  
  6259. let totalChanged = 0;
  6260. for (const [key, value] of Object.entries(diffBefore)) {
  6261. totalChanged += value;
  6262. }
  6263.  
  6264. if (totalChanged === 0) {
  6265. alert('[AnimePahe Improvements]\n\nThis file contains no changes to import.');
  6266. return;
  6267. }
  6268.  
  6269. $('#anitracker-modal-body').empty();
  6270.  
  6271. $(`
  6272. <h4>Choose what to import</h4>
  6273. <br>
  6274. <div class="form-check">
  6275. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-link-list-check" ${diffBefore.linkListAdded > 0 ? "checked" : "disabled"}>
  6276. <label class="form-check-label" for="anitracker-link-list-check">
  6277. Session entries (${diffBefore.linkListAdded})
  6278. </label>
  6279. </div>
  6280. <div class="form-check">
  6281. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-video-times-check" ${(diffBefore.videoTimesAdded + diffBefore.videoTimesUpdated) > 0 ? "checked" : "disabled"}>
  6282. <label class="form-check-label" for="anitracker-video-times-check">
  6283. Video progress times (${diffBefore.videoTimesAdded + diffBefore.videoTimesUpdated})
  6284. </label>
  6285. </div>
  6286. <div class="form-check">
  6287. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-bookmarks-check" ${diffBefore.bookmarksAdded > 0 ? "checked" : "disabled"}>
  6288. <label class="form-check-label" for="anitracker-bookmarks-check">
  6289. Bookmarks (${diffBefore.bookmarksAdded})
  6290. </label>
  6291. </div>
  6292. <div class="form-check">
  6293. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-notifications-check" ${(diffBefore.notificationsAdded + diffBefore.episodeFeedUpdated) > 0 ? "checked" : "disabled"}>
  6294. <label class="form-check-label" for="anitracker-notifications-check">
  6295. Episode feed entries (${diffBefore.notificationsAdded})
  6296. <ul style="margin-bottom:0;margin-left:-24px;"><li>Episode feed entries updated: ${diffBefore.episodeFeedUpdated}</li></ul>
  6297. </label>
  6298. </div>
  6299. <div class="form-check">
  6300. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-watched-check" ${diffBefore.watchedEpisodesAdded > 0 ? "checked" : "disabled"}>
  6301. <label class="form-check-label" for="anitracker-watched-check">
  6302. Watched episodes (${diffBefore.watchedEpisodesAdded})
  6303. </label>
  6304. </div>
  6305. <div class="form-check">
  6306. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-video-speed-check" ${diffBefore.videoSpeedUpdated > 0 ? "checked" : "disabled"}>
  6307. <label class="form-check-label" for="anitracker-video-speed-check">
  6308. Video speed entries (${diffBefore.videoSpeedUpdated})
  6309. </label>
  6310. </div>
  6311. <div class="form-check">
  6312. <input class="form-check-input anitracker-import-data-input" type="checkbox" value="" id="anitracker-settings-check" ${diffBefore.settingsUpdated > 0 ? "checked" : "disabled"}>
  6313. <label class="form-check-label" for="anitracker-settings-check">
  6314. Settings (${diffBefore.settingsUpdated})
  6315. </label>
  6316. </div>
  6317. <div class="btn-group" style="float: right;">
  6318. <button class="btn btn-primary" id="anitracker-confirm-import" title="Confirm import">
  6319. <i class="fa fa-upload" aria-hidden="true"></i>
  6320. &nbsp;Import
  6321. </button>
  6322. </div>
  6323. `).appendTo('#anitracker-modal-body');
  6324.  
  6325. $('.anitracker-import-data-input').on('change', (e) => {
  6326. let checksOn = 0;
  6327. for (const elem of $('.anitracker-import-data-input')) {
  6328. if ($(elem).prop('checked')) checksOn++;
  6329. }
  6330. if (checksOn === 0) {
  6331. $('#anitracker-confirm-import').attr('disabled', true);
  6332. }
  6333. else {
  6334. $('#anitracker-confirm-import').attr('disabled', false);
  6335. }
  6336. });
  6337.  
  6338. $('#anitracker-confirm-import').on('click', () => {
  6339. const diffAfter = importData(getStorage(), newData, true, {
  6340. linkList: !$('#anitracker-link-list-check').prop('checked'),
  6341. videoTimes: !$('#anitracker-video-times-check').prop('checked'),
  6342. bookmarks: !$('#anitracker-bookmarks-check').prop('checked'),
  6343. notifications: !$('#anitracker-notifications-check').prop('checked'),
  6344. watchedEpisodes: !$('#anitracker-watched-check').prop('checked'),
  6345. videoSpeed: !$('#anitracker-video-speed-check').prop('checked'),
  6346. settings: !$('#anitracker-settings-check').prop('checked')
  6347. });
  6348.  
  6349. if ((diffAfter.bookmarksAdded + diffAfter.notificationsAdded + diffAfter.settingsUpdated) > 0) updatePage();
  6350. if (diffAfter.watchedEpisodesAdded > 0 && isAnime()) updateEpisodesPage();
  6351. if ((diffAfter.videoTimesUpdated + diffAfter.videoTimesAdded) > 0 && isEpisode()) {
  6352. sendMessage({action:"change_time", time:getStorage().videoTimes.find(a => a.videoUrls.includes($('.embed-responsive-item')[0].src))?.time});
  6353. }
  6354. alert('[AnimePahe Improvements]\n\nImported!');
  6355. openShowDataModal();
  6356. });
  6357.  
  6358. openModal(openShowDataModal);
  6359. });
  6360. fileReader.readAsText(file);
  6361. });
  6362.  
  6363. function importData(data, importedData, save = true, ignored = {settings:{}}) {
  6364. const changed = {
  6365. linkListAdded: 0, // Session entries added
  6366. videoTimesAdded: 0, // Video progress entries added
  6367. videoTimesUpdated: 0, // Video progress times updated
  6368. bookmarksAdded: 0, // Bookmarks added
  6369. notificationsAdded: 0, // Anime added to episode feed
  6370. episodeFeedUpdated: 0, // Episodes either added to episode feed or that had their watched status updated
  6371. videoSpeedUpdated: 0, // Video speed entries added or updated
  6372. watchedEpisodesAdded: 0, // Amount of episodes marked as watched that are added
  6373. settingsUpdated: 0 // Settings updated
  6374. }
  6375.  
  6376. const defaultData = getDefaultData();
  6377.  
  6378. if (importedData.version !== defaultData.version) {
  6379. upgradeData(importedData, importedData.version);
  6380. }
  6381.  
  6382. for (const [key, value] of Object.entries(importedData)) {
  6383. if (defaultData[key] === undefined) continue;
  6384.  
  6385. if (!ignored.linkList && key === 'linkList') {
  6386. const added = [];
  6387. if (value.length === undefined) {
  6388. console.warn('[AnimePahe Improvements] Imported "linkList" has an incorrect format.');
  6389. continue;
  6390. }
  6391. value.forEach(g => {
  6392. if ((g.type === 'episode' && data.linkList.find(h => h.type === 'episode' && h.animeSession === g.animeSession && h.episodeSession === g.episodeSession) === undefined)
  6393. || (g.type === 'anime' && data.linkList.find(h => h.type === 'anime' && h.animeSession === g.animeSession) === undefined)) {
  6394. added.push(g);
  6395. changed.linkListAdded++;
  6396. }
  6397. });
  6398. data.linkList.splice(0,0,...added);
  6399. continue;
  6400. }
  6401. else if (!ignored.videoTimes && key === 'videoTimes') {
  6402. const added = [];
  6403. if (value.length === undefined) {
  6404. console.warn('[AnimePahe Improvements] Imported "videoTimes" has an incorrect format.');
  6405. continue;
  6406. }
  6407. value.forEach(g => {
  6408. const foundTime = data.videoTimes.find(h => h.videoUrls.includes(g.videoUrls[0]));
  6409. if (foundTime === undefined) {
  6410. added.push(g);
  6411. changed.videoTimesAdded++;
  6412. }
  6413. else if (foundTime.time < g.time) {
  6414. foundTime.time = g.time;
  6415. changed.videoTimesUpdated++;
  6416. }
  6417. });
  6418. data.videoTimes.splice(0,0,...added);
  6419. continue;
  6420. }
  6421. else if (!ignored.bookmarks && key === 'bookmarks') {
  6422. if (value.length === undefined) {
  6423. console.warn('[AnimePahe Improvements] Imported "bookmarks" has an incorrect format.');
  6424. continue;
  6425. }
  6426. value.forEach(g => {
  6427. if (data.bookmarks.find(h => h.id === g.id) !== undefined) return;
  6428. data.bookmarks.push(g);
  6429. changed.bookmarksAdded++;
  6430. });
  6431. continue;
  6432. }
  6433. else if (!ignored.notifications && key === 'notifications') {
  6434. if (value.anime?.length === undefined || value.episodes?.length === undefined) {
  6435. console.warn('[AnimePahe Improvements] Imported "notifications" has an incorrect format.');
  6436. continue;
  6437. }
  6438. value.anime.forEach(g => {
  6439. if (data.notifications.anime.find(h => h.id === g.id) !== undefined) return;
  6440. data.notifications.anime.push(g);
  6441. changed.notificationsAdded++;
  6442. });
  6443.  
  6444. // Checking if there exists any gap between the imported episodes and the existing ones
  6445. if (save) data.notifications.anime.forEach(g => {
  6446. const existingEpisodes = data.notifications.episodes.filter(a => (a.animeName === g.name || a.animeId === g.id));
  6447. const addedEpisodes = value.episodes.filter(a => (a.animeName === g.name || a.animeId === g.id));
  6448. if (existingEpisodes.length > 0 && (existingEpisodes[existingEpisodes.length-1].episode - addedEpisodes[0].episode > 1.5)) {
  6449. g.updateFrom = new Date(addedEpisodes[0].time + " UTC").getTime();
  6450. }
  6451. });
  6452.  
  6453. value.episodes.forEach(g => {
  6454. const anime = (() => {
  6455. if (g.animeId !== undefined) return data.notifications.anime.find(a => a.id === g.animeId);
  6456.  
  6457. const fromNew = data.notifications.anime.find(a => a.name === g.animeName);
  6458. if (fromNew !== undefined) return fromNew;
  6459. const id = value.anime.find(a => a.name === g.animeName);
  6460. return data.notifications.anime.find(a => a.id === id);
  6461. })();
  6462. if (anime === undefined) return;
  6463. if (g.animeName !== anime.name) g.animeName = anime.name;
  6464. if (g.animeId === undefined) g.animeId = anime.id;
  6465. const foundEpisode = data.notifications.episodes.find(h => h.animeId === anime.id && h.episode === g.episode);
  6466. if (foundEpisode !== undefined) {
  6467. if (g.watched === true && !foundEpisode.watched) {
  6468. foundEpisode.watched = true;
  6469. changed.episodeFeedUpdated++;
  6470. }
  6471. return;
  6472. }
  6473. data.notifications.episodes.push(g);
  6474. changed.episodeFeedUpdated++;
  6475. });
  6476. if (save) {
  6477. data.notifications.episodes.sort((a,b) => a.time < b.time ? 1 : -1);
  6478. if (value.episodes.length > 0) {
  6479. data.notifications.lastUpdated = new Date(value.episodes[0].time + " UTC").getTime();
  6480. }
  6481. }
  6482. continue;
  6483. }
  6484. else if (!ignored.watchedEpisodes && key === 'watched') {
  6485. const watched = decodeWatched(data.watched);
  6486. const watchedNew = decodeWatched(value);
  6487.  
  6488. if (value.length === undefined || (value.length > 0 && watchedNew.length === 0)) {
  6489. console.warn('[AnimePahe Improvements] Imported "watched" has an incorrect format.');
  6490. continue;
  6491. }
  6492.  
  6493. for (const anime of watchedNew) {
  6494. const found = watched.find(a => a.animeId === anime.animeId);
  6495.  
  6496. if (found === undefined) {
  6497. watched.push({
  6498. animeId: anime.animeId,
  6499. episodes: anime.episodes
  6500. });
  6501. changed.watchedEpisodesAdded += anime.episodes.length;
  6502. }
  6503. else for (const ep of anime.episodes) {
  6504. if (found.episodes.includes(ep)) continue;
  6505. found.episodes.push(ep);
  6506. changed.watchedEpisodesAdded++;
  6507. }
  6508. }
  6509.  
  6510. data.watched = encodeWatched(watched);
  6511. }
  6512. else if (!ignored.videoSpeed && key === 'videoSpeed') {
  6513. if (value.length === undefined) {
  6514. console.warn('[AnimePahe Improvements] Imported "videoSpeed" has an incorrect format.');
  6515. continue;
  6516. }
  6517.  
  6518. for (const anime of value) {
  6519. const found = data.videoSpeed.find(a => a.animeId === anime.animeId);
  6520. if (found !== undefined) {
  6521. if (found.speed === anime.speed) continue;
  6522. found.speed = anime.speed;
  6523. changed.videoSpeedUpdated++;
  6524. continue;
  6525. }
  6526.  
  6527. data.videoSpeed.push(anime);
  6528. changed.videoSpeedUpdated++;
  6529. }
  6530. }
  6531. else if (ignored.settings !== true && key === 'settings') {
  6532. for (const [key, value2] of Object.entries(value)) {
  6533. if (defaultData.settings[key] === undefined || ignored.settings[key] || ![true,false].includes(value2)) continue;
  6534. if (data.settings[key] === value2) continue;
  6535. data.settings[key] = value2;
  6536. changed.settingsUpdated++;
  6537. }
  6538. }
  6539. }
  6540.  
  6541. if (save) saveData(data);
  6542.  
  6543. return changed;
  6544. }
  6545.  
  6546. function getCleanType(type) {
  6547. if (type === 'linkList') return "Clean up older duplicate entries";
  6548. else if (type === 'videoTimes') return "Remove entries with no progress (0s)";
  6549. else return "[Message not found]";
  6550. }
  6551.  
  6552. function expandData(elem) {
  6553. const storage = getStorage();
  6554. const dataType = elem.attr('key');
  6555.  
  6556. elem.find('.anitracker-expand-data-icon').replaceWith(contractIcon);
  6557. const dataEntries = $('<div class="anitracker-modal-list"></div>').appendTo(elem.parent());
  6558.  
  6559. 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>` : '';
  6560. $(`
  6561. <div class="btn-group anitracker-storage-filter">
  6562. <input title="Search within this storage entry" autocomplete="off" class="form-control anitracker-text-input-bar anitracker-modal-search" placeholder="Search">
  6563. <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>
  6564. ${cleanButton}
  6565. </div>
  6566. `).appendTo(dataEntries);
  6567. elem.parent().find('.anitracker-modal-search').focus();
  6568.  
  6569. elem.parent().find('.anitracker-modal-search').on('input', (e) => {
  6570. setTimeout(() => {
  6571. const query = $(e.target).val();
  6572. for (const entry of elem.parent().find('.anitracker-modal-list-entry')) {
  6573. if ($($(entry).find('a,span')[0]).text().toLowerCase().includes(query)) {
  6574. $(entry).show();
  6575. continue;
  6576. }
  6577. $(entry).hide();
  6578. }
  6579. }, 10);
  6580. });
  6581.  
  6582. elem.parent().find('.anitracker-clean-data-button').on('click', () => {
  6583. if (!confirm("[AnimePahe Improvements]\n\n" + getCleanType(dataType) + '?')) return;
  6584.  
  6585. const updatedStorage = getStorage();
  6586.  
  6587. const removed = [];
  6588. if (dataType === 'linkList') {
  6589. for (let i = 0; i < updatedStorage.linkList.length; i++) {
  6590. const link = updatedStorage.linkList[i];
  6591.  
  6592. const similar = updatedStorage.linkList.filter(a => a.animeName === link.animeName && a.episodeNum === link.episodeNum);
  6593. if (similar[similar.length-1] !== link) {
  6594. removed.push(link);
  6595. }
  6596. }
  6597. updatedStorage.linkList = updatedStorage.linkList.filter(a => !removed.includes(a));
  6598. }
  6599. else if (dataType === 'videoTimes') {
  6600. for (const timeEntry of updatedStorage.videoTimes) {
  6601. if (timeEntry.time > 5) continue;
  6602. removed.push(timeEntry);
  6603. }
  6604. updatedStorage.videoTimes = updatedStorage.videoTimes.filter(a => !removed.includes(a));
  6605. }
  6606.  
  6607. alert(`[AnimePahe Improvements]\n\nCleaned up ${removed.length} ${removed.length === 1 ? "entry" : "entries"}.`);
  6608.  
  6609. saveData(updatedStorage);
  6610. dataEntries.remove();
  6611. expandData(elem);
  6612. });
  6613.  
  6614. // When clicking the reverse order button
  6615. elem.parent().find('.anitracker-reverse-order-button').on('click', (e) => {
  6616. const btn = $(e.target);
  6617. if (btn.attr('dir') === 'down') {
  6618. btn.attr('dir', 'up');
  6619. btn.addClass('anitracker-up');
  6620. }
  6621. else {
  6622. btn.attr('dir', 'down');
  6623. btn.removeClass('anitracker-up');
  6624. }
  6625.  
  6626. const entries = [];
  6627. for (const entry of elem.parent().find('.anitracker-modal-list-entry')) {
  6628. entries.push(entry.outerHTML);
  6629. }
  6630. entries.reverse();
  6631. elem.parent().find('.anitracker-modal-list-entry').remove();
  6632. for (const entry of entries) {
  6633. $(entry).appendTo(elem.parent().find('.anitracker-modal-list'));
  6634. }
  6635. applyDeleteEvents();
  6636. });
  6637.  
  6638. function applyDeleteEvents() {
  6639. $('.anitracker-modal-list-entry .anitracker-delete-session-button').on('click', function() {
  6640. const storage = getStorage();
  6641.  
  6642. const href = $(this).parent().find('a').attr('href');
  6643. const animeSession = getAnimeSessionFromUrl(href);
  6644.  
  6645. if (isEpisode(href)) {
  6646. const episodeSession = getEpisodeSessionFromUrl(href);
  6647. storage.linkList = storage.linkList.filter(g => !(g.type === 'episode' && g.animeSession === animeSession && g.episodeSession === episodeSession));
  6648. saveData(storage);
  6649. }
  6650. else {
  6651. storage.linkList = storage.linkList.filter(g => !(g.type === 'anime' && g.animeSession === animeSession));
  6652. saveData(storage);
  6653. }
  6654.  
  6655. $(this).parent().remove();
  6656. });
  6657.  
  6658. $('.anitracker-modal-list-entry .anitracker-delete-progress-button').on('click', function() {
  6659. const storage = getStorage();
  6660. storage.videoTimes = storage.videoTimes.filter(g => !g.videoUrls.includes($(this).attr('lookForUrl')));
  6661. saveData(storage);
  6662.  
  6663. $(this).parent().remove();
  6664. });
  6665.  
  6666. $('.anitracker-modal-list-entry .anitracker-delete-watched-button').on('click', function() {
  6667. const id = +$(this).parent().attr('animeid');
  6668. removeWatchedAnime(id);
  6669.  
  6670. $(this).parent().remove();
  6671. });
  6672.  
  6673. $('.anitracker-modal-list-entry .anitracker-delete-speed-entry-button').on('click', function() {
  6674. const storage = getStorage();
  6675. const idString = $(this).attr('animeid');
  6676. if (idString !== undefined) storage.videoSpeed = storage.videoSpeed.filter(g => g.animeId !== parseInt(idString));
  6677. else storage.videoSpeed = storage.videoSpeed.filter(g => g.animeName !== $(this).attr('animename'));
  6678. saveData(storage);
  6679.  
  6680. $(this).parent().remove();
  6681. });
  6682. }
  6683.  
  6684. if (dataType === 'linkList') {
  6685. [...storage.linkList].reverse().forEach(g => {
  6686. const name = g.animeName + (g.type === 'episode' ? (' - Episode ' + g.episodeNum) : '');
  6687. $(`
  6688. <div class="anitracker-modal-list-entry">
  6689. <a target="_blank" href="/${(g.type === 'episode' ? 'play/' : 'anime/') + g.animeSession + (g.type === 'episode' ? ('/' + g.episodeSession) : '')}" title="${toHtmlCodes(name)}">
  6690. ${toHtmlCodes(name)}
  6691. </a><br>
  6692. <button class="btn btn-danger anitracker-delete-session-button anitracker-flat-button" title="Delete this stored session">
  6693. <i class="fa fa-trash" aria-hidden="true"></i>
  6694. &nbsp;Delete
  6695. </button>
  6696. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6697. });
  6698.  
  6699. applyDeleteEvents();
  6700. }
  6701. else if (dataType === 'videoTimes') {
  6702. [...storage.videoTimes].reverse().forEach(g => {
  6703. $(`
  6704. <div class="anitracker-modal-list-entry">
  6705. <span>
  6706. ${g.animeId !== undefined ? `<a href="/a/${g.animeId}" target="_blank">${toHtmlCodes(g.animeName)}</a>` : toHtmlCodes(g.animeName)} - Episode ${g.episodeNum}
  6707. </span><br>
  6708. <span>
  6709. Current time: ${secondsToHMS(g.time)}
  6710. </span><br>
  6711. <button class="btn btn-danger anitracker-delete-progress-button anitracker-flat-button" lookForUrl="${g.videoUrls[0]}" title="Delete this video progress">
  6712. <i class="fa fa-trash" aria-hidden="true"></i>
  6713. &nbsp;Delete
  6714. </button>
  6715. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6716. });
  6717.  
  6718. applyDeleteEvents();
  6719. }
  6720. else if (dataType === 'watched') {
  6721. decodeWatched(storage.watched).reverse().forEach(g => {
  6722. const linkListObj = storage.linkList.find(a => a.animeId === g.animeId);
  6723. const episodes = g.episodes;
  6724. $(`
  6725. <div class="anitracker-modal-list-entry" animeid="${g.animeId}">
  6726. <span>
  6727. <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'}
  6728. </span><br>
  6729. <span class="anitracker-watched-episodes-list">
  6730. ${episodes.join()}
  6731. </span><br>
  6732. ${linkListObj === undefined ? `<button class="btn btn-secondary anitracker-get-name-button anitracker-flat-button" title="Get the name for this anime">
  6733. <i class="fa fa-search" aria-hidden="true"></i>
  6734. &nbsp;Get Name
  6735. </button>` : ''}
  6736. <button class="btn btn-danger anitracker-delete-watched-button anitracker-flat-button" title="Delete this video progress">
  6737. <i class="fa fa-trash" aria-hidden="true"></i>
  6738. &nbsp;Delete
  6739. </button>
  6740. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6741. });
  6742.  
  6743. applyDeleteEvents();
  6744.  
  6745. $('.anitracker-get-name-button').on('click', function() {
  6746. const id = +$(this).parent().attr('animeid');
  6747. const spinner = $(`
  6748. <div class="anitracker-get-name-spinner anitracker-spinner" style="display: inline;vertical-align: bottom;">
  6749. <div class="spinner-border" role="status" style="height: 24px; width: 24px;">
  6750. <span class="sr-only">Loading...</span>
  6751. </div>
  6752. </div>`).insertAfter(this);
  6753. // Get the anime name from its ID
  6754. getAnimeNameFromId(id).then(name => {
  6755. $(this).prop('disabled', true);
  6756. spinner.remove();
  6757. if (name === undefined) {
  6758. alert("[AnimePahe Improvements]\n\nCouldn't get anime name");
  6759. return;
  6760. }
  6761. $(this).parent().find('.anitracker-watched-anime-id').text(name);
  6762. });
  6763. });
  6764. }
  6765. else if (dataType === 'videoSpeed') {
  6766. [...storage.videoSpeed].reverse().forEach(g => {
  6767. const identifier = (() => {
  6768. if (g.animeId !== undefined) return `animeid="${g.animeId}"`;
  6769. else return `animename="${toHtmlCodes(g.animeName)}"`;
  6770. })();
  6771. $(`
  6772. <div class="anitracker-modal-list-entry">
  6773. <span>
  6774. ${g.animeId !== undefined ? `<a href="/a/${g.animeId}" target="_blank">${toHtmlCodes(g.animeName)}</a>` : toHtmlCodes(g.animeName)}
  6775. </span><br>
  6776. <span>
  6777. Playback speed: ${g.speed}
  6778. </span><br>
  6779. <button class="btn btn-danger anitracker-delete-speed-entry-button anitracker-flat-button" ${identifier} title="Delete this video speed entry">
  6780. <i class="fa fa-trash" aria-hidden="true"></i>
  6781. &nbsp;Delete
  6782. </button>
  6783. </div>`).appendTo(elem.parent().find('.anitracker-modal-list'));
  6784. });
  6785.  
  6786. applyDeleteEvents();
  6787. }
  6788.  
  6789. elem.addClass('anitracker-expanded');
  6790. }
  6791.  
  6792. function contractData(elem) {
  6793. elem.find('.anitracker-expand-data-icon').replaceWith(expandIcon);
  6794.  
  6795. elem.parent().find('.anitracker-modal-list').remove();
  6796.  
  6797. elem.removeClass('anitracker-expanded');
  6798. elem.blur();
  6799. }
  6800.  
  6801. openModal();
  6802. }
  6803.  
  6804. $('#anitracker-show-data').on('click', openShowDataModal);
  6805. }
  6806.  
  6807. addGeneralButtons();
  6808. if (isEpisode()) {
  6809. $(`
  6810. <span style="margin-left: 30px;"><i class="fa fa-files-o" aria-hidden="true"></i>&nbsp;Copy:</span>
  6811. <div class="btn-group">
  6812. <button class="btn btn-dark anitracker-copy-button" copy="link" data-placement="top" data-content="Copied!">Link</button>
  6813. </div>
  6814. <div class="btn-group" style="margin-right:30px;">
  6815. <button class="btn btn-dark anitracker-copy-button" copy="link-time" data-placement="top" data-content="Copied!">Link & Time</button>
  6816. </div>`).appendTo('#anitracker');
  6817. addOptionSwitch('autoPlayNext','Auto-Play Next','Automatically go to the next episode when the current one has ended.','#anitracker');
  6818.  
  6819. $('.anitracker-copy-button').on('click', (e) => {
  6820. const targ = $(e.currentTarget);
  6821. const type = targ.attr('copy');
  6822. const name = encodeURIComponent(getAnimeName());
  6823. const episode = getEpisodeNum();
  6824. if (['link','link-time'].includes(type)) {
  6825. navigator.clipboard.writeText(window.location.origin + '/customlink?a=' + name + '&e=' + episode + (type !== 'link-time' ? '' : ('&t=' + currentEpisodeTime.toString())));
  6826. }
  6827. targ.popover('show');
  6828. setTimeout(() => {
  6829. targ.popover('hide');
  6830. }, 1000);
  6831. });
  6832. }
  6833.  
  6834. if (initialStorage.settings.autoDelete === true && isEpisode() && paramArray.find(a => a[0] === 'ref' && a[1] === 'customlink') === undefined) {
  6835. const animeData = getAnimeData();
  6836. deleteEpisodesFromTracker(getEpisodeNum(), animeData.title, animeData.id);
  6837. }
  6838.  
  6839. function updateSwitches() {
  6840. const storage = getStorage();
  6841.  
  6842. for (const s of optionSwitches) {
  6843. const different = s.value !== storage.settings[s.optionId];
  6844. if (!different) continue;
  6845.  
  6846. s.value = storage.settings[s.optionId];
  6847. $(`#anitracker-${s.switchId}-switch`).prop('checked', s.value === true);
  6848.  
  6849. if (s.value === true) {
  6850. if (s.onEvent !== undefined) s.onEvent();
  6851. }
  6852. else if (s.offEvent !== undefined) {
  6853. s.offEvent();
  6854. }
  6855. }
  6856. }
  6857.  
  6858. updateSwitches();
  6859.  
  6860. function addOptionSwitch(optionId, name, desc = '', parent = '#anitracker-modal-body') {
  6861. const option = optionSwitches.find(s => s.optionId === optionId);
  6862.  
  6863. $(`
  6864. <div class="custom-control custom-switch anitracker-switch" id="anitracker-${option.switchId}" title="${desc}">
  6865. <input type="checkbox" class="custom-control-input" id="anitracker-${option.switchId}-switch">
  6866. <label class="custom-control-label" for="anitracker-${option.switchId}-switch">${name}</label>
  6867. </div>`).appendTo(parent);
  6868. const switc = $(`#anitracker-${option.switchId}-switch`);
  6869. switc.prop('checked', option.value);
  6870.  
  6871. const events = [option.onEvent, option.offEvent];
  6872.  
  6873. switc.on('change', (e) => {
  6874. const checked = $(e.currentTarget).is(':checked');
  6875. const storage = getStorage();
  6876.  
  6877. if (checked !== storage.settings[optionId]) {
  6878. storage.settings[optionId] = checked;
  6879. option.value = checked;
  6880. saveData(storage);
  6881. }
  6882.  
  6883. if (checked) {
  6884. if (events[0] !== undefined) events[0]();
  6885. }
  6886. else if (events[1] !== undefined) events[1]();
  6887. });
  6888. }
  6889.  
  6890. $(`
  6891. <div class="anitracker-download-spinner anitracker-spinner" style="display: none;">
  6892. <div class="spinner-border" role="status">
  6893. <span class="sr-only">Loading...</span>
  6894. </div>
  6895. </div>`).prependTo('#downloadMenu,#episodeMenu');
  6896. $('.prequel img,.sequel img').attr('loading','');
  6897. }