presence.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. // Note: Developer has been working on a new website design for ages,
  2. // maybe at some point he'll finish it and this will need updating.
  3. import { Assets, getTimestamps } from 'premid'
  4. const presence = new Presence({
  5. clientId: '629355416714739732',
  6. })
  7. async function getStrings() {
  8. return presence.getStrings(
  9. {
  10. play: 'general.playing',
  11. pause: 'general.paused',
  12. browse: 'general.browsing',
  13. page: 'general.page',
  14. episode: 'general.episode',
  15. watching: 'general.watching',
  16. watchingMovie: 'general.watchingMovie',
  17. view: 'general.view',
  18. viewGenre: 'general.viewGenre',
  19. viewCategory: 'general.viewCategory',
  20. viewPage: 'general.viewPage',
  21. viewMovie: 'general.viewMovie',
  22. watchEpisode: 'general.buttonViewEpisode',
  23. watchMovie: 'general.buttonViewMovie',
  24. latest: 'animepahe.latestRelease',
  25. season: 'animepahe.season',
  26. special: 'animepahe.special',
  27. viewOn: 'animepahe.view',
  28. timeSeason: 'animepahe.timeSeason',
  29. },
  30. )
  31. }
  32. let strings: Awaited<ReturnType<typeof getStrings>>
  33. let oldLang: string | null = null
  34. let iframeResponse = {
  35. paused: true,
  36. duration: 0,
  37. currentTime: 0,
  38. }
  39. type storeType = Record<
  40. string,
  41. { id: number, listing: [string, string], time: number }
  42. >
  43. class AnimeStorage {
  44. private list: storeType
  45. public anime(title: string, listing: [string, string] | false) {
  46. if (this.list[title] && this.list[title].listing) {
  47. return this.list[title]
  48. }
  49. else if (listing) {
  50. this.list[title] = {
  51. id: Number(
  52. document.querySelector<HTMLMetaElement>('meta[name=id]')?.content ?? '',
  53. ),
  54. listing,
  55. time: Date.now(),
  56. }
  57. // Removes the oldest stored anime if the store length has exceeded 10
  58. if (Object.keys(this.list).length === 11) {
  59. delete this.list[
  60. Object.entries(Object.assign({}, this.list)).sort(
  61. (a, b) => a[1].time - b[1].time,
  62. )[0]![0]
  63. ]
  64. }
  65. localStorage.setItem('presence_data', btoa(JSON.stringify(this.list)))
  66. }
  67. }
  68. constructor() {
  69. let storage: storeType | string | null = localStorage.getItem('presence_data')
  70. if (storage) {
  71. storage = JSON.parse(atob(storage))
  72. this.list = storage as storeType
  73. if (!Object.entries(this.list)[0]![1].listing)
  74. this.list = {}
  75. }
  76. else {
  77. this.list = {}
  78. }
  79. }
  80. }
  81. const animeStore = new AnimeStorage()
  82. function getTimes(time: number): {
  83. sec: number
  84. min: number
  85. hrs: number
  86. } {
  87. let seconds = Math.round(time)
  88. let minutes = Math.floor(seconds / 60)
  89. seconds -= minutes * 60
  90. const hours = Math.floor(minutes / 60)
  91. minutes -= hours * 60
  92. return {
  93. sec: seconds,
  94. min: minutes,
  95. hrs: hours,
  96. }
  97. }
  98. const lessTen = (d: number) => (d < 10 ? '0' : '')
  99. function getTimestamp(time: number): string {
  100. const { sec, min, hrs } = getTimes(time)
  101. return hrs > 0
  102. ? `${hrs}:${lessTen(min)}${min}:${lessTen(sec)}${sec}`
  103. : `${min}:${lessTen(sec)}${sec}`
  104. }
  105. const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
  106. const uncapitalize = (s: string) => s.charAt(0).toLowerCase() + s.slice(1)
  107. function parseInfo(dom: HTMLParagraphElement[]) {
  108. const entries: Record<string, string | HTMLAnchorElement[]> = {}
  109. for (const entry of dom) {
  110. let title = entry.children[0]?.textContent?.slice(0, -1) ?? ''
  111. const [, secondChild] = entry.childNodes
  112. if (title.includes(' ')) {
  113. title = title
  114. .split(' ')
  115. .map(e => uncapitalize(e))
  116. .join('_')
  117. }
  118. else {
  119. title = uncapitalize(title)
  120. }
  121. if (secondChild?.nodeName === '#text' && entry.childNodes.length === 2) {
  122. entries[title] = secondChild.textContent ?? ''
  123. }
  124. else {
  125. entries[title] = []
  126. for (const node of entry.childNodes) {
  127. if (node.nodeName !== 'STRONG' && node.nodeName !== '#text') {
  128. (entries[title] as HTMLAnchorElement[]).push(
  129. node as HTMLAnchorElement,
  130. )
  131. }
  132. }
  133. }
  134. }
  135. return entries
  136. }
  137. presence.on(
  138. 'iFrameData',
  139. (data: unknown) => {
  140. iframeResponse = data as typeof iframeResponse
  141. },
  142. )
  143. enum ActivityAssets {
  144. Logo = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/logo.png',
  145. BrowsingHome = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/0.png',
  146. BrowsingAll = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/1.png',
  147. BrowsingGenre = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/2.png',
  148. BrowsingTime = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/3.png',
  149. BrowsingSeason = 'https://cdn.rcd.gg/PreMiD/websites/A/animepahe/assets/4.png',
  150. }
  151. presence.on('UpdateData', async () => {
  152. const path = document.location.pathname.split('/').slice(1)
  153. const presenceData: PresenceData = {
  154. largeImageKey: ActivityAssets.Logo,
  155. details: 'loading',
  156. startTimestamp: Math.floor(Date.now() / 1000),
  157. }
  158. const newLang = await presence.getSetting<string>('lang').catch(() => 'en')
  159. if (oldLang !== newLang || !strings) {
  160. oldLang = newLang
  161. strings = await getStrings()
  162. }
  163. const viewing = strings.view.slice(0, -1)
  164. switch (path[0]) {
  165. // homepage / browsing new releases
  166. case '':
  167. {
  168. presenceData.details = strings.latest
  169. let page = new URLSearchParams(document.location.href)
  170. .values()
  171. .next()
  172. .value
  173. if (page === '')
  174. page = '1'
  175. presenceData.state = `${strings.page} ${page}`
  176. presenceData.smallImageKey = ActivityAssets.BrowsingHome
  177. presenceData.smallImageText = strings.browse
  178. }
  179. break
  180. case 'anime': {
  181. // browsing a-z all
  182. if (path.length === 1) {
  183. presenceData.details = `${viewing} A-Z:`
  184. presenceData.state = document.querySelector('a.nav-link.active')?.textContent
  185. presenceData.smallImageKey = ActivityAssets.BrowsingAll
  186. presenceData.smallImageText = strings.browse
  187. }
  188. else {
  189. switch (path[1]) {
  190. case 'genre':
  191. {
  192. // viewing genre
  193. presenceData.details = strings.viewGenre
  194. presenceData.state = capitalize(path[2]!)
  195. presenceData.smallImageKey = ActivityAssets.BrowsingGenre
  196. presenceData.smallImageText = strings.browse
  197. break
  198. }
  199. case 'season':
  200. {
  201. // viewing anime/time season
  202. presenceData.details = `${viewing} Anime ${strings.timeSeason}:`
  203. presenceData.state = document.querySelectorAll('h1')[0]?.textContent
  204. presenceData.smallImageKey = ActivityAssets.BrowsingTime
  205. presenceData.smallImageText = strings.browse
  206. break
  207. }
  208. default: {
  209. if (
  210. !path[1]?.match(
  211. /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
  212. )
  213. ) {
  214. // viewing a misc. category (eg. Airing, TV, etc)
  215. presenceData.details = strings.viewCategory
  216. const heading = document.querySelectorAll('h1')[0]?.textContent
  217. presenceData.state = heading?.includes(' ')
  218. ? heading
  219. .split(' ')
  220. .map(s => capitalize(s))
  221. .join(' ')
  222. : capitalize(heading ?? '')
  223. presenceData.smallImageKey = ActivityAssets.BrowsingAll
  224. presenceData.smallImageText = strings.browse
  225. }
  226. else {
  227. // viewing specific
  228. const info = parseInfo(
  229. document.querySelectorAll('.anime-info')[0]!
  230. .children as unknown as HTMLParagraphElement[],
  231. )
  232. const title = document.querySelectorAll('.title-wrapper')[0]?.children[1]
  233. ?.textContent
  234. const listing = (() => {
  235. const links = info.external_links as HTMLAnchorElement[]
  236. if (links[0]?.textContent === 'AniList')
  237. return ['AniList', links[0].href]
  238. for (const link of links) {
  239. if (link.textContent === 'MyAnimeList')
  240. return ['MAL', link.href]
  241. }
  242. })() as [string, string]
  243. presenceData.details = (() => {
  244. switch ((info.type?.[0] as HTMLAnchorElement)?.textContent) {
  245. case 'Movie':
  246. return strings.viewMovie
  247. case 'TV':
  248. return `${viewing} ${strings.season}:`
  249. case 'Special':
  250. return `${viewing} ${strings.special}:`
  251. default:
  252. return `${viewing} ${
  253. (info.type?.[0] as HTMLAnchorElement)?.textContent
  254. }:`
  255. }
  256. })()
  257. presenceData.state = title
  258. presenceData.largeImageKey = document.querySelector<HTMLAnchorElement>(
  259. '.youtube-preview',
  260. )?.href ?? ''
  261. presenceData.smallImageKey = ActivityAssets.BrowsingSeason
  262. presenceData.smallImageText = strings.browse
  263. presenceData.buttons = [
  264. {
  265. label: strings.viewOn.replace('{0}', 'Pahe'),
  266. url: `https://pahe.win/a/${
  267. animeStore.anime(title ?? '', listing)?.id ?? ''
  268. }`,
  269. },
  270. {
  271. label: strings.viewOn.replace('{0}', listing[0]),
  272. url: listing[1],
  273. },
  274. ]
  275. }
  276. }
  277. }
  278. }
  279. break
  280. }
  281. // playback
  282. case 'play':
  283. {
  284. const movie: boolean = document.querySelectorAll('.anime-status')[0]?.firstElementChild
  285. ?.textContent === 'Movie'
  286. const title = document.querySelectorAll('.theatre-info')[0]?.children[1]
  287. ?.children[1]
  288. ?.textContent
  289. const episode = Number.parseInt(
  290. document
  291. .querySelector('#episodeMenu')
  292. ?.textContent
  293. ?.split('Episode ')[1]
  294. ?.replace(/^\s+|\s+$/g, '') ?? '',
  295. )
  296. if (!movie) {
  297. presenceData.details = `${strings.watching.slice(0, -1)} ${
  298. strings.episode
  299. } ${episode}`
  300. }
  301. else {
  302. presenceData.details = strings.watchingMovie
  303. }
  304. presenceData.state = title
  305. presenceData.largeImageKey = document
  306. .querySelector<HTMLImageElement>('.anime-poster')
  307. ?.src
  308. ?.replace('.th', '') ?? ''
  309. presenceData.smallImageKey = iframeResponse.paused
  310. ? Assets.Pause
  311. : Assets.Play
  312. presenceData.smallImageText = iframeResponse.paused
  313. ? strings.pause
  314. : strings.play
  315. if (!iframeResponse.paused) {
  316. [presenceData.startTimestamp, presenceData.endTimestamp] = getTimestamps(
  317. Math.floor(iframeResponse.currentTime),
  318. Math.floor(iframeResponse.duration),
  319. )
  320. }
  321. else {
  322. presenceData.smallImageText += ` - ${getTimestamp(
  323. iframeResponse.currentTime,
  324. )}`
  325. }
  326. const anime = animeStore.anime(title ?? '', false)
  327. if (anime) {
  328. presenceData.buttons = [
  329. {
  330. label: movie ? strings.watchMovie : strings.watchEpisode,
  331. url: `https://pahe.win/a/${anime.id}/${episode}`,
  332. },
  333. {
  334. label: strings.viewOn.replace('{0}', anime.listing[0]),
  335. url: anime.listing[1],
  336. },
  337. ]
  338. }
  339. }
  340. break
  341. default: {
  342. presenceData.details = strings.viewPage
  343. presenceData.state = document.querySelectorAll('h1')[0]?.textContent
  344. }
  345. }
  346. presence.setActivity(presenceData)
  347. })