presence.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { ActivityType } from 'premid'
  2. import { SimpleLRU } from './utilities/lru.js'
  3. /**
  4. * Cache store for cuties' faces, will clear on reset game.
  5. * Keys stand for characters' faces urls start with "blob",
  6. * and the values are their faces but large-scaled in Data URLs
  7. *
  8. * In case of something glitches on re-sampling...
  9. * Switching to other effects for 5 times or taking any animated actions
  10. * to let the oldest cache is re-generated.
  11. */
  12. const characterFacesCache = new SimpleLRU<string>(5)
  13. /**
  14. * Badges are not frequently swithed normally.
  15. * Keys stand for url segments and values are entire urls from computed styles.
  16. */
  17. const badgesCache = new SimpleLRU<string>(2)
  18. const { Name, Logo } = {
  19. Name: 'YNOProject',
  20. Logo: 'https://cdn.rcd.gg/PreMiD/websites/Y/YNOProject/assets/logo.png',
  21. }
  22. const presence = new Presence({ clientId: '1304833580291063848' })
  23. presence.on('UpdateData', async () => {
  24. const gameName = await fetchGameName()
  25. const gameLocation = await fetchGameLocation()
  26. if (GameState.game !== gameName || !GameState.startedAt)
  27. GameState.resetWith(gameName)
  28. const presenceData: PresenceData = {
  29. name: Name,
  30. type: ActivityType.Playing,
  31. startTimestamp: GameState.startedAt,
  32. largeImageText: gameName,
  33. largeImageKey: await fetchCharacterFace().then(url => url || Logo),
  34. smallImageKey: await fetchBadge(),
  35. details: gameName || 'Choosing a game...',
  36. state: gameName ? gameLocation || 'Disconnected' : null,
  37. buttons: gameName
  38. ? [{ label: `Play ${gameName}`, url: document.location.href }]
  39. : null,
  40. } as unknown as PresenceData
  41. presence.setActivity(presenceData)
  42. })
  43. /**
  44. * Read live favicon of character face in game.
  45. * Size is scaled up from 16 to 40 to be sharper
  46. * and encoded in Data URL (~800 bytes).
  47. *
  48. * @returns Data URL or nothing at the portal
  49. */
  50. async function fetchCharacterFace(): Promise<string | undefined> {
  51. const url = document.querySelector<HTMLLinkElement>('#favicon')?.href
  52. if (url && characterFacesCache.has(url)) {
  53. return characterFacesCache.get(url)
  54. }
  55. else if (url) {
  56. return await SingleTaskExecutor.shared.postIfAbsent(url, async () => {
  57. const blob = await fetchWithResizePixelatedImage(url, 40, 40)
  58. if (blob) {
  59. return blob2dataurl(blob).then((optimizedImage) => {
  60. characterFacesCache.set(url, optimizedImage)
  61. return optimizedImage
  62. })
  63. }
  64. })
  65. }
  66. }
  67. /**
  68. * Read equipped badge of current sign-in player, size is squared 37.
  69. * (Surprisingly Discord supports GIFs in small image so it's no need to resample)
  70. *
  71. * @example 'url("star-transparent.gif")'.match(it)?.[2] // star-transparent.gif
  72. * @example "url('https://image_url')".match(it)?.[2] // image_url
  73. * @returns Entire URL or nothing for guest player
  74. */
  75. async function fetchBadge(): Promise<string | undefined> {
  76. if (!document.querySelector('#content')?.classList?.contains('loggedIn'))
  77. return
  78. const badgeEl = document.querySelector<HTMLElement>('#badgeButton .badge')
  79. const url = badgeEl?.style?.backgroundImage // Gives path as segmented url only
  80. if (url && badgesCache.has(url)) {
  81. return badgesCache.get(url)
  82. }
  83. else if (url) {
  84. return await SingleTaskExecutor.shared.postIfAbsent(url, async () => {
  85. const fullUrl = window
  86. .getComputedStyle(badgeEl)
  87. .backgroundImage
  88. .match(
  89. // eslint-disable-next-line no-control-regex
  90. /url\(("|')([^\x01\s]+)\1\)/,
  91. )?.[2]
  92. if (fullUrl)
  93. badgesCache.set(url, fullUrl)
  94. return fullUrl
  95. })
  96. }
  97. }
  98. /**
  99. * The name of playing game, or nothing at the portal
  100. * @example "Yume 2kki Online - YNOproject".match(it)?.[0] // Yume 2kki
  101. */
  102. async function fetchGameName(): Promise<string | undefined> {
  103. return document
  104. .querySelector('title')
  105. ?.textContent
  106. ?.match(/^.+(?= Online -)/)?.[0]
  107. }
  108. /**
  109. * Read current location within the game.
  110. */
  111. async function fetchGameLocation(): Promise<string | undefined> {
  112. return document.querySelector('#locationText')?.textContent ?? undefined
  113. }
  114. class GameState {
  115. static game: string | undefined
  116. static startedAt = 0
  117. static resetWith(game: string | undefined) {
  118. this.game = game
  119. this.startedAt = Math.floor(Date.now() / 1000)
  120. characterFacesCache.clear()
  121. badgesCache.clear()
  122. }
  123. }
  124. /**
  125. * Resize pixelated image. Beware of high perf cost.
  126. * @param href url
  127. * @param dw destination width
  128. * @param dh destination height
  129. */
  130. async function fetchWithResizePixelatedImage(
  131. href: string,
  132. dw: number,
  133. dh: number,
  134. ) {
  135. const img = document.createElement('img')
  136. const canvas = document.createElement('canvas')
  137. return new Promise((resolve, reject) => {
  138. img.style.imageRendering = 'pixelated'
  139. img.onload = resolve
  140. img.onerror = reject
  141. img.src = href
  142. }).then(() => {
  143. canvas.width = dw
  144. canvas.height = dh
  145. const g = canvas.getContext('2d')!
  146. g.imageSmoothingEnabled = false
  147. g.drawImage(img, 0, 0, img.width, img.height, 0, 0, dw, dh)
  148. return new Promise<Blob>(resolve => canvas.toBlob(blob => resolve(blob!), 'image/png'))
  149. })
  150. }
  151. /** We still need this function for inspecting what format the image is in */
  152. async function blob2dataurl(blob: Blob) {
  153. return new Promise<string>((resolve, reject) => {
  154. const reader = new FileReader()
  155. reader.addEventListener('load', () => resolve(String(reader.result)))
  156. reader.addEventListener('error', reject)
  157. reader.readAsDataURL(blob)
  158. })
  159. }
  160. class SingleTaskExecutor {
  161. static shared = new SingleTaskExecutor()
  162. protected map = new Map<string, Promise<unknown>>()
  163. postIfAbsent<T>(key: string, beginHeavyJob: () => Promise<T>) {
  164. // Force cast, don't result different types on the same key
  165. let runningJob = this.map.get(key) as Promise<T>
  166. if (runningJob)
  167. return runningJob
  168. this.map.set(
  169. key,
  170. (runningJob = beginHeavyJob().finally(() => this.map.delete(key))),
  171. )
  172. return runningJob
  173. }
  174. }