From e4a1f62538e904b6306c2711a13e595b51668c8a Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:03:08 -0400 Subject: [PATCH 1/7] feat(Anikura): add Activity --- websites/A/Anikura/metadata.json | 56 ++++++ websites/A/Anikura/presence.ts | 296 +++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 websites/A/Anikura/metadata.json create mode 100644 websites/A/Anikura/presence.ts diff --git a/websites/A/Anikura/metadata.json b/websites/A/Anikura/metadata.json new file mode 100644 index 000000000000..ea8480bb23cc --- /dev/null +++ b/websites/A/Anikura/metadata.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://schemas.premid.app/metadata/1.16", + "apiVersion": 1, + "author": { + "name": "q_wo2", + "id": "1238250053836079160" + }, + "service": "Anikura", + "description": { + "en": "Stream your presence from Anikura to your friends on Discord.", + "ja": "Anikuraからあなたの存在をDiscordの友達にストリーミングしましょう。" + }, + "url": "anikura.club", + "regExp": "^https?[:][/][/](www[.])?anikura[.]club[/]", + "version": "1.0.0", + "logo": "https://i.imgur.com/7J9jL7R.png", + "thumbnail": "https://i.imgur.com/xK5jHDy.png", + "color": "#6366f1", + "category": "anime", + "tags": [ + "anime", + "streaming" + ], + "settings": [ + { + "id": "multiLanguage", + "title": "Multi-language support", + "icon": "fas fa-language", + "value": true + }, + { + "id": "showAnimeAsTitle", + "title": "Use anime name as header", + "icon": "fas fa-heading", + "value": false + }, + { + "id": "buttons", + "title": "Show buttons", + "icon": "fas fa-link", + "value": true + }, + { + "id": "timestamps", + "title": "Show elapsed time", + "icon": "fas fa-clock", + "value": true + }, + { + "id": "showEpTitle", + "title": "Show episode title / chapter", + "icon": "fas fa-file-alt", + "value": true + } + ] +} \ No newline at end of file diff --git a/websites/A/Anikura/presence.ts b/websites/A/Anikura/presence.ts new file mode 100644 index 000000000000..9058548a7889 --- /dev/null +++ b/websites/A/Anikura/presence.ts @@ -0,0 +1,296 @@ +import { ActivityType } from 'premid' + +const presence = new Presence({ + clientId: '1373817718192734268' +}) + +const siteStartTimestamp = Math.floor(Date.now() / 1000) +enum ActivityAssets { + Logo = 'https://i.imgur.com/7J9jL7R.png' +} + +function formatAnimeSlug(slug: string | null): string | null { + if (!slug) return null + return slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} +function getAnimeTitleFromDOM(): string | null { + const titleSpan = document.querySelector('span[class*="truncate"][class*="text-snow"]') || + document.querySelector('span[style*="view-transition-name: title-"]') + if (titleSpan && titleSpan.textContent?.trim()) { + return titleSpan.textContent.trim() + } + const titleElem = document.querySelector('.anime-title, .show-title, h1[class*="text-snow"], h1') + if (titleElem && titleElem.textContent?.trim()) { + return titleElem.textContent.trim() + } + return null +} +function getChapterOrSubtitleFromDOM(): { chapter: string | null; subtitleLang: string | null } { + const chapterElem = document.querySelector('.vds-chapter-title') + const chapter = chapterElem?.textContent?.trim() || null + const trackElem = document.querySelector('track[default], track[kind="captions"][src]') + const subtitleLang = trackElem?.label || trackElem?.srclang?.toUpperCase() || null + return { chapter, subtitleLang } +} +function getEpisodeTitleFromDOM(): string | null { + const epTitleSpan = document.querySelector('.ep-title') || + document.querySelector('h1[class*="text-[clamp"]') || + document.querySelector('h1.text-snow') + if (epTitleSpan && epTitleSpan.textContent?.trim()) { + return epTitleSpan.textContent.trim() + } + const epImg = document.querySelector('img[alt*="titled"]') + if (!epImg) return null + const altText = epImg.getAttribute('alt') || '' + const match = altText.match(/titled\s+['"](.+?)['"]/i) + return match && match[1] ? match[1].trim() : null +} +function getFollowersCountFromDOM(): string | null { + const links = Array.from(document.querySelectorAll('a, button, span, div')) + const followersLink = links.find(el => el.textContent?.toLowerCase().includes('followers')) + if (followersLink) { + const span = followersLink.querySelector('.tabular-nums') || followersLink.querySelector('span') + if (span && span.textContent?.trim()) { + return span.textContent.trim() + } + } + return null +} +function getFollowingCountFromDOM(): string | null { + const links = Array.from(document.querySelectorAll('a, button, span, div')) + const followingLink = links.find(el => { + const text = el.textContent?.toLowerCase() || '' + return text.includes('following') && !text.includes('followers') + }) + if (followingLink) { + const span = followingLink.querySelector('.tabular-nums') || followingLink.querySelector('span') + if (span && span.textContent?.trim()) { + return span.textContent.trim() + } + } + return null +} +function getCoverImageFromDOM(epNum?: string, lang?: string, isProfile?: boolean): string | null { + if (isProfile) { + const avatarImg = document.querySelector('main img[src*="/avatars/"]') || + document.querySelector('img[src*="/avatars/"]') + const src = avatarImg?.src || avatarImg?.getAttribute('src') + if (src && src.startsWith('http')) return src + } + + if (epNum) { + const langQuery = lang ? `[href*="lang=${lang}"]` : '' + const activeLink = document.querySelector(`a[href*="ep=${epNum}"]${langQuery}`) || + document.querySelector(`a[href*="ep=${epNum}"]`) + + const activeImg = activeLink?.querySelector('img') + if (activeImg) { + const src = activeImg.src || activeImg.getAttribute('src') + if (src && src.startsWith('http')) return src + } + } + + const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') || + document.querySelector('img[data-nimg="fill"]') || + document.querySelector('img[src*="screencap"]') || + document.querySelector('img[src*="episode"]') || + document.querySelector('img[class*="object-cover"]') || + document.querySelector('img._infoImage_aojp4_125') || + document.querySelector('img[class*="_infoImage_"]') || + document.querySelector('img._coverImg_2wrhc_89') || + document.querySelector('img[class*="_coverImg_"]') + const src = coverImg?.src || coverImg?.getAttribute('src') + return src && src.startsWith('http') ? src : null +} +presence.on('UpdateData', async () => { + const { pathname, href, search } = document.location + const searchParams = new URLSearchParams(search) + const [useMultiLanguage, showAnimeAsTitle, showButtons, showTimestamps, showEpTitle] = await Promise.all([ + presence.getSetting('multiLanguage'), + presence.getSetting('showAnimeAsTitle'), + presence.getSetting('buttons'), + presence.getSetting('timestamps'), + presence.getSetting('showEpTitle') + ]) + const rawStrings = await presence.getStrings({ + browsing: 'general.browsing', + searching: 'general.searching', + viewHome: 'general.viewHome', + viewing: 'general.viewing' + }) + const getString = (key: keyof typeof rawStrings, fallback: string) => { + if (!useMultiLanguage) return fallback + const val = rawStrings[key] + return val && !val.startsWith('general.') ? val : fallback + } + const presenceData: PresenceData = { + type: ActivityType.Watching, + largeImageKey: ActivityAssets.Logo, + startTimestamp: siteStartTimestamp + } + switch (true) { + case pathname === '/' || pathname === '': { + presenceData.details = 'Anikura' + presenceData.state = getString('viewHome', 'On Homepage') + break + } + case pathname === '/login': { + presenceData.details = 'Anikura' + presenceData.state = 'Logging In' + break + } + case pathname === '/browse': { + presenceData.details = 'Anikura' + presenceData.state = 'Browsing Anime' + break + } + case pathname === '/genres' || pathname.startsWith('/genres/'): { + presenceData.details = 'Anikura' + if (pathname.startsWith('/genres/')) { + const genreSlug = pathname.replace('/genres/', '') + const genreName = formatAnimeSlug(genreSlug) + presenceData.state = `Browsing ${genreName} Genre` + } else { + presenceData.state = 'Browsing Genres' + } + break + } + case pathname === '/profile' || pathname.startsWith('/@'): { + presenceData.details = 'Anikura' + const isProfile = pathname.startsWith('/@') + if (isProfile) { + const username = pathname.replace('/@', '') + const view = searchParams.get('view') + const tab = searchParams.get('tab') + + let subText = '' + if (tab === 'followers') { + const count = getFollowersCountFromDOM() + subText = ` (Followers${count ? `: ${count}` : ''})` + } else if (tab === 'following') { + const count = getFollowingCountFromDOM() + subText = ` (Following${count ? `: ${count}` : ''})` + } else if (tab) { + subText = ` (${formatAnimeSlug(tab)})` + } else if (view) { + subText = ` (${formatAnimeSlug(view)} List)` + } + + presenceData.state = `Viewing ${username}'s Profile${subText}` + } else { + presenceData.state = 'Viewing Profile' + } + + const coverUrl = getCoverImageFromDOM(undefined, undefined, isProfile || pathname === '/profile') + if (coverUrl) { + presenceData.largeImageKey = coverUrl + } + break + } + case pathname === '/membership': { + presenceData.details = 'Anikura' + presenceData.state = 'Viewing Membership' + break + } + case pathname.includes('/history'): { + presenceData.details = 'Anikura' + presenceData.state = 'Viewing Watch History' + break + } + case pathname.includes('/schedule'): { + presenceData.details = 'Anikura' + presenceData.state = 'Checking Release Schedule' + break + } + case pathname.includes('/trending'): { + presenceData.details = 'Anikura' + presenceData.state = 'Browsing Trending Anime' + break + } + case pathname.includes('/search') || searchParams.has('query') || searchParams.has('q'): { + const query = searchParams.get('query') ?? searchParams.get('q') ?? searchParams.get('search') ?? '' + const searchingStr = getString('searching', 'Searching') + presenceData.details = 'Anikura' + presenceData.state = query ? `${searchingStr} "${query}"` : `${searchingStr}...` + break + } + case /\/anime\/\d+\/([^\?\/#]+)/i.test(pathname): { + const infoMatch = pathname.match(/\/anime\/\d+\/([^\?\/#]+)/i) + const animeTitle = getAnimeTitleFromDOM() || (infoMatch && infoMatch[1] ? formatAnimeSlug(infoMatch[1]) : null) + const coverUrl = getCoverImageFromDOM() + presenceData.details = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Browsing Anime Info' + presenceData.state = animeTitle ? 'Reading Details & Overview' : 'Exploring Info Catalog' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Info', + url: href + } + ] + } + break + } + case /\/watch\/\d+\/([^\?\/#]+)/i.test(pathname): { + const watchMatch = pathname.match(/\/watch\/\d+\/([^\?\/#]+)/i) + const epNum = searchParams.get('ep') ?? '1' + const lang = searchParams.get('lang') ?? '' + const showTitle = getAnimeTitleFromDOM() || (watchMatch && watchMatch[1] ? formatAnimeSlug(watchMatch[1]) : null) || 'Anime' + const epTitle = getEpisodeTitleFromDOM() + const { chapter, subtitleLang } = getChapterOrSubtitleFromDOM() + const coverUrl = getCoverImageFromDOM(epNum, lang) + let epLine = `Episode ${epNum}` + const finalEpTitle = showEpTitle ? epTitle : null + if (showEpTitle && (chapter || finalEpTitle)) { + epLine += ` - ${chapter || finalEpTitle}` + } + const finalLang = lang || subtitleLang + if (finalLang) { + epLine += ` [${finalLang.charAt(0).toUpperCase() + finalLang.slice(1)}]` + } + if (showAnimeAsTitle && showTitle) { + presenceData.name = showTitle + presenceData.details = epLine + delete presenceData.state + } else { + delete presenceData.name + presenceData.details = showTitle + presenceData.state = epLine + } + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'Watch Episode', + url: href + } + ] + } + break + } + default: { + const catalogMatch = pathname.match(/\/(?:anime|watch)\/\d+\/([^\?\/#]+)/i) + const animeTitle = getAnimeTitleFromDOM() || (catalogMatch && catalogMatch[1] ? formatAnimeSlug(catalogMatch[1]) : null) + const coverUrl = getCoverImageFromDOM() + presenceData.details = getString('browsing', 'Browsing...') + presenceData.state = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Exploring Catalog' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Page', + url: href + } + ] + } + break + } + } + if (!showTimestamps) { + delete presenceData.startTimestamp + } + presence.setActivity(presenceData) +}) From 7574bc356e914df9759208e3a994c6c1b2b19a9d Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:09:09 -0400 Subject: [PATCH 2/7] feat(Anikura): add Activity --- websites/A/Anikura/metadata.json | 100 +++++++------- websites/A/Anikura/presence.ts | 223 +++++++++++++++++++++---------- 2 files changed, 197 insertions(+), 126 deletions(-) diff --git a/websites/A/Anikura/metadata.json b/websites/A/Anikura/metadata.json index ea8480bb23cc..e3e37c55c91d 100644 --- a/websites/A/Anikura/metadata.json +++ b/websites/A/Anikura/metadata.json @@ -1,56 +1,50 @@ { - "$schema": "https://schemas.premid.app/metadata/1.16", - "apiVersion": 1, - "author": { - "name": "q_wo2", - "id": "1238250053836079160" + "$schema": "https://schemas.premid.app/metadata/1.16", + "apiVersion": 1, + "author": { + "name": "q_wo2", + "id": "1238250053836079160" + }, + "service": "Anikura", + "description": { + "en": "Stream your presence from Anikura to your friends on Discord.", + "ja": "Anikuraからあなたの存在をDiscordの友達にストリーミングしましょう。" + }, + "url": "anikura.club", + "regExp": "^https?[:][/][/](www[.])?anikura[.]club[/]", + "version": "1.0.0", + "logo": "https://i.imgur.com/7J9jL7R.png", + "thumbnail": "https://i.imgur.com/xK5jHDy.png", + "color": "#6366f1", + "category": "anime", + "tags": [ + "anime", + "streaming" + ], + "settings": [ + { + "id": "multiLanguage", + "title": "Multi-language support", + "icon": "fas fa-language", + "value": true }, - "service": "Anikura", - "description": { - "en": "Stream your presence from Anikura to your friends on Discord.", - "ja": "Anikuraからあなたの存在をDiscordの友達にストリーミングしましょう。" + { + "id": "showAnimeAsTitle", + "title": "Use anime name as header", + "icon": "fas fa-heading", + "value": false }, - "url": "anikura.club", - "regExp": "^https?[:][/][/](www[.])?anikura[.]club[/]", - "version": "1.0.0", - "logo": "https://i.imgur.com/7J9jL7R.png", - "thumbnail": "https://i.imgur.com/xK5jHDy.png", - "color": "#6366f1", - "category": "anime", - "tags": [ - "anime", - "streaming" - ], - "settings": [ - { - "id": "multiLanguage", - "title": "Multi-language support", - "icon": "fas fa-language", - "value": true - }, - { - "id": "showAnimeAsTitle", - "title": "Use anime name as header", - "icon": "fas fa-heading", - "value": false - }, - { - "id": "buttons", - "title": "Show buttons", - "icon": "fas fa-link", - "value": true - }, - { - "id": "timestamps", - "title": "Show elapsed time", - "icon": "fas fa-clock", - "value": true - }, - { - "id": "showEpTitle", - "title": "Show episode title / chapter", - "icon": "fas fa-file-alt", - "value": true - } - ] -} \ No newline at end of file + { + "id": "buttons", + "title": "Show buttons", + "icon": "fas fa-link", + "value": true + }, + { + "id": "showEpTitle", + "title": "Show episode title / chapter", + "icon": "fas fa-file-alt", + "value": true + } + ] +} diff --git a/websites/A/Anikura/presence.ts b/websites/A/Anikura/presence.ts index 9058548a7889..ee228a1d50d0 100644 --- a/websites/A/Anikura/presence.ts +++ b/websites/A/Anikura/presence.ts @@ -1,24 +1,25 @@ -import { ActivityType } from 'premid' +import { ActivityType, getTimestamps, Assets } from 'premid' const presence = new Presence({ - clientId: '1373817718192734268' + clientId: '1373817718192734268', }) const siteStartTimestamp = Math.floor(Date.now() / 1000) enum ActivityAssets { - Logo = 'https://i.imgur.com/7J9jL7R.png' + Logo = 'https://i.imgur.com/7J9jL7R.png', } function formatAnimeSlug(slug: string | null): string | null { - if (!slug) return null + if (!slug) + return null return slug .split('-') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } function getAnimeTitleFromDOM(): string | null { - const titleSpan = document.querySelector('span[class*="truncate"][class*="text-snow"]') || - document.querySelector('span[style*="view-transition-name: title-"]') + const titleSpan = document.querySelector('span[class*="truncate"][class*="text-snow"]') + || document.querySelector('span[style*="view-transition-name: title-"]') if (titleSpan && titleSpan.textContent?.trim()) { return titleSpan.textContent.trim() } @@ -28,7 +29,7 @@ function getAnimeTitleFromDOM(): string | null { } return null } -function getChapterOrSubtitleFromDOM(): { chapter: string | null; subtitleLang: string | null } { +function getChapterOrSubtitleFromDOM(): { chapter: string | null, subtitleLang: string | null } { const chapterElem = document.querySelector('.vds-chapter-title') const chapter = chapterElem?.textContent?.trim() || null const trackElem = document.querySelector('track[default], track[kind="captions"][src]') @@ -36,18 +37,35 @@ function getChapterOrSubtitleFromDOM(): { chapter: string | null; subtitleLang: return { chapter, subtitleLang } } function getEpisodeTitleFromDOM(): string | null { - const epTitleSpan = document.querySelector('.ep-title') || - document.querySelector('h1[class*="text-[clamp"]') || - document.querySelector('h1.text-snow') + const epTitleSpan = document.querySelector('.ep-title') + || document.querySelector('h1[class*="text-[clamp"]') + || document.querySelector('h1.text-snow') if (epTitleSpan && epTitleSpan.textContent?.trim()) { return epTitleSpan.textContent.trim() } const epImg = document.querySelector('img[alt*="titled"]') - if (!epImg) return null + if (!epImg) + return null const altText = epImg.getAttribute('alt') || '' const match = altText.match(/titled\s+['"](.+?)['"]/i) return match && match[1] ? match[1].trim() : null } +function parseTimeToSeconds(timeStr: string | null): number | null { + if (!timeStr) + return null + const parts = timeStr.trim().split(':').map(Number) + if (parts.some(Number.isNaN)) + return null + if (parts.length === 2) { + const [minutes = 0, seconds = 0] = parts + return minutes * 60 + seconds + } + else if (parts.length === 3) { + const [hours = 0, minutes = 0, seconds = 0] = parts + return hours * 3600 + minutes * 60 + seconds + } + return null +} function getFollowersCountFromDOM(): string | null { const links = Array.from(document.querySelectorAll('a, button, span, div')) const followersLink = links.find(el => el.textContent?.toLowerCase().includes('followers')) @@ -61,7 +79,7 @@ function getFollowersCountFromDOM(): string | null { } function getFollowingCountFromDOM(): string | null { const links = Array.from(document.querySelectorAll('a, button, span, div')) - const followingLink = links.find(el => { + const followingLink = links.find((el) => { const text = el.textContent?.toLowerCase() || '' return text.includes('following') && !text.includes('followers') }) @@ -75,61 +93,63 @@ function getFollowingCountFromDOM(): string | null { } function getCoverImageFromDOM(epNum?: string, lang?: string, isProfile?: boolean): string | null { if (isProfile) { - const avatarImg = document.querySelector('main img[src*="/avatars/"]') || - document.querySelector('img[src*="/avatars/"]') + const avatarImg = document.querySelector('main img[src*="/avatars/"]') + || document.querySelector('img[src*="/avatars/"]') const src = avatarImg?.src || avatarImg?.getAttribute('src') - if (src && src.startsWith('http')) return src + if (src && src.startsWith('http')) + return src } if (epNum) { const langQuery = lang ? `[href*="lang=${lang}"]` : '' - const activeLink = document.querySelector(`a[href*="ep=${epNum}"]${langQuery}`) || - document.querySelector(`a[href*="ep=${epNum}"]`) - + const activeLink = document.querySelector(`a[href*="ep=${epNum}"]${langQuery}`) + || document.querySelector(`a[href*="ep=${epNum}"]`) + const activeImg = activeLink?.querySelector('img') if (activeImg) { const src = activeImg.src || activeImg.getAttribute('src') - if (src && src.startsWith('http')) return src + if (src && src.startsWith('http')) + return src } } - const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') || - document.querySelector('img[data-nimg="fill"]') || - document.querySelector('img[src*="screencap"]') || - document.querySelector('img[src*="episode"]') || - document.querySelector('img[class*="object-cover"]') || - document.querySelector('img._infoImage_aojp4_125') || - document.querySelector('img[class*="_infoImage_"]') || - document.querySelector('img._coverImg_2wrhc_89') || - document.querySelector('img[class*="_coverImg_"]') + const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') + || document.querySelector('img[data-nimg="fill"]') + || document.querySelector('img[src*="screencap"]') + || document.querySelector('img[src*="episode"]') + || document.querySelector('img[class*="object-cover"]') + || document.querySelector('img._infoImage_aojp4_125') + || document.querySelector('img[class*="_infoImage_"]') + || document.querySelector('img._coverImg_2wrhc_89') + || document.querySelector('img[class*="_coverImg_"]') const src = coverImg?.src || coverImg?.getAttribute('src') return src && src.startsWith('http') ? src : null } presence.on('UpdateData', async () => { const { pathname, href, search } = document.location const searchParams = new URLSearchParams(search) - const [useMultiLanguage, showAnimeAsTitle, showButtons, showTimestamps, showEpTitle] = await Promise.all([ + const [useMultiLanguage, showAnimeAsTitle, showButtons, showEpTitle] = await Promise.all([ presence.getSetting('multiLanguage'), presence.getSetting('showAnimeAsTitle'), presence.getSetting('buttons'), - presence.getSetting('timestamps'), - presence.getSetting('showEpTitle') + presence.getSetting('showEpTitle'), ]) const rawStrings = await presence.getStrings({ browsing: 'general.browsing', searching: 'general.searching', viewHome: 'general.viewHome', - viewing: 'general.viewing' + viewing: 'general.viewing', }) const getString = (key: keyof typeof rawStrings, fallback: string) => { - if (!useMultiLanguage) return fallback + if (!useMultiLanguage) + return fallback const val = rawStrings[key] return val && !val.startsWith('general.') ? val : fallback } const presenceData: PresenceData = { type: ActivityType.Watching, largeImageKey: ActivityAssets.Logo, - startTimestamp: siteStartTimestamp + startTimestamp: siteStartTimestamp, } switch (true) { case pathname === '/' || pathname === '': { @@ -144,7 +164,43 @@ presence.on('UpdateData', async () => { } case pathname === '/browse': { presenceData.details = 'Anikura' - presenceData.state = 'Browsing Anime' + const sort = searchParams.get('sort') + const year = searchParams.get('year') + const status = searchParams.get('status') + const format = searchParams.get('format') + const audio = searchParams.get('audio') + + let base = 'Anime' + const parts: string[] = [] + if (status) + parts.push(formatAnimeSlug(status) || '') + if (year) + parts.push(year) + if (format) + parts.push(format.toUpperCase()) + + let sortText = '' + if (sort === 'score') + sortText = 'Top Rated' + else if (sort === 'year') + sortText = 'Newest' + else if (sort === 'title') + sortText = 'A-Z' + else if (sort) + sortText = `by ${formatAnimeSlug(sort)}` + + if (sortText) { + if (sortText === 'A-Z') { + base = 'Anime (A-Z)' + } + else { + parts.push(sortText) + } + } + + const filterDesc = parts.length > 0 ? `${parts.join(' ')} ${base}` : base + const audioText = audio ? ` [${formatAnimeSlug(audio)}]` : '' + presenceData.state = `Browsing ${filterDesc}${audioText}` break } case pathname === '/genres' || pathname.startsWith('/genres/'): { @@ -153,7 +209,8 @@ presence.on('UpdateData', async () => { const genreSlug = pathname.replace('/genres/', '') const genreName = formatAnimeSlug(genreSlug) presenceData.state = `Browsing ${genreName} Genre` - } else { + } + else { presenceData.state = 'Browsing Genres' } break @@ -165,25 +222,29 @@ presence.on('UpdateData', async () => { const username = pathname.replace('/@', '') const view = searchParams.get('view') const tab = searchParams.get('tab') - + let subText = '' if (tab === 'followers') { const count = getFollowersCountFromDOM() subText = ` (Followers${count ? `: ${count}` : ''})` - } else if (tab === 'following') { + } + else if (tab === 'following') { const count = getFollowingCountFromDOM() subText = ` (Following${count ? `: ${count}` : ''})` - } else if (tab) { + } + else if (tab) { subText = ` (${formatAnimeSlug(tab)})` - } else if (view) { + } + else if (view) { subText = ` (${formatAnimeSlug(view)} List)` } - + presenceData.state = `Viewing ${username}'s Profile${subText}` - } else { + } + else { presenceData.state = 'Viewing Profile' } - + const coverUrl = getCoverImageFromDOM(undefined, undefined, isProfile || pathname === '/profile') if (coverUrl) { presenceData.largeImageKey = coverUrl @@ -195,21 +256,6 @@ presence.on('UpdateData', async () => { presenceData.state = 'Viewing Membership' break } - case pathname.includes('/history'): { - presenceData.details = 'Anikura' - presenceData.state = 'Viewing Watch History' - break - } - case pathname.includes('/schedule'): { - presenceData.details = 'Anikura' - presenceData.state = 'Checking Release Schedule' - break - } - case pathname.includes('/trending'): { - presenceData.details = 'Anikura' - presenceData.state = 'Browsing Trending Anime' - break - } case pathname.includes('/search') || searchParams.has('query') || searchParams.has('q'): { const query = searchParams.get('query') ?? searchParams.get('q') ?? searchParams.get('search') ?? '' const searchingStr = getString('searching', 'Searching') @@ -217,8 +263,8 @@ presence.on('UpdateData', async () => { presenceData.state = query ? `${searchingStr} "${query}"` : `${searchingStr}...` break } - case /\/anime\/\d+\/([^\?\/#]+)/i.test(pathname): { - const infoMatch = pathname.match(/\/anime\/\d+\/([^\?\/#]+)/i) + case /\/anime\/\d+\/[^?/#]+/i.test(pathname): { + const infoMatch = pathname.match(/\/anime\/\d+\/([^?/#]+)/i) const animeTitle = getAnimeTitleFromDOM() || (infoMatch && infoMatch[1] ? formatAnimeSlug(infoMatch[1]) : null) const coverUrl = getCoverImageFromDOM() presenceData.details = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Browsing Anime Info' @@ -228,14 +274,14 @@ presence.on('UpdateData', async () => { presenceData.buttons = [ { label: 'View Info', - url: href - } + url: href, + }, ] } break } - case /\/watch\/\d+\/([^\?\/#]+)/i.test(pathname): { - const watchMatch = pathname.match(/\/watch\/\d+\/([^\?\/#]+)/i) + case /\/watch\/\d+\/[^?/#]+/i.test(pathname): { + const watchMatch = pathname.match(/\/watch\/\d+\/([^?/#]+)/i) const epNum = searchParams.get('ep') ?? '1' const lang = searchParams.get('lang') ?? '' const showTitle = getAnimeTitleFromDOM() || (watchMatch && watchMatch[1] ? formatAnimeSlug(watchMatch[1]) : null) || 'Anime' @@ -255,24 +301,58 @@ presence.on('UpdateData', async () => { presenceData.name = showTitle presenceData.details = epLine delete presenceData.state - } else { + } + else { delete presenceData.name presenceData.details = showTitle presenceData.state = epLine } presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + + const video = document.querySelector('video') + const isPaused = video ? video.paused : true + + if (!isPaused) { + delete presenceData.smallImageKey + delete presenceData.smallImageText + + const timeSpans = Array.from(document.querySelectorAll('span[class*="font-medium"][class*="tracking-wide"]')) + const currentTimeText = timeSpans[0]?.textContent || null + const durationText = timeSpans[1]?.textContent || null + + const elementTime = parseTimeToSeconds(currentTimeText) ?? video?.currentTime ?? 0 + const elementDuration = parseTimeToSeconds(durationText) ?? video?.duration ?? 0 + + if (elementDuration > 0) { + const [start, end] = getTimestamps(elementTime, elementDuration) + presenceData.startTimestamp = start + presenceData.endTimestamp = end + } + else { + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + } + else { + presenceData.smallImageKey = Assets.Play + presenceData.smallImageText = 'Paused' + + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + if (showButtons) { presenceData.buttons = [ { label: 'Watch Episode', - url: href - } + url: href, + }, ] } break } default: { - const catalogMatch = pathname.match(/\/(?:anime|watch)\/\d+\/([^\?\/#]+)/i) + const catalogMatch = pathname.match(/\/(?:anime|watch)\/\d+\/([^?/#]+)/i) const animeTitle = getAnimeTitleFromDOM() || (catalogMatch && catalogMatch[1] ? formatAnimeSlug(catalogMatch[1]) : null) const coverUrl = getCoverImageFromDOM() presenceData.details = getString('browsing', 'Browsing...') @@ -282,15 +362,12 @@ presence.on('UpdateData', async () => { presenceData.buttons = [ { label: 'View Page', - url: href - } + url: href, + }, ] } break } } - if (!showTimestamps) { - delete presenceData.startTimestamp - } presence.setActivity(presenceData) }) From bfd620a8491614c5af1376cd1091e8c3cf65426a Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:25:04 -0400 Subject: [PATCH 3/7] fix getTimestamps being before assets --- websites/A/Anikura/presence.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/websites/A/Anikura/presence.ts b/websites/A/Anikura/presence.ts index ee228a1d50d0..2b87cbcd3ac1 100644 --- a/websites/A/Anikura/presence.ts +++ b/websites/A/Anikura/presence.ts @@ -1,4 +1,4 @@ -import { ActivityType, getTimestamps, Assets } from 'premid' +import { ActivityType, Assets, getTimestamps } from 'premid' const presence = new Presence({ clientId: '1373817718192734268', From 331e36d0a6bf99b35ead84af8a1b2c3c9fc2d95e Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:46:27 -0400 Subject: [PATCH 4/7] feat(Nekowatch): add Activity --- websites/N/Nekowatch/metadata.json | 44 +++++ websites/N/Nekowatch/presence.ts | 286 +++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 websites/N/Nekowatch/metadata.json create mode 100644 websites/N/Nekowatch/presence.ts diff --git a/websites/N/Nekowatch/metadata.json b/websites/N/Nekowatch/metadata.json new file mode 100644 index 000000000000..24828a4d0bcc --- /dev/null +++ b/websites/N/Nekowatch/metadata.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schemas.premid.app/metadata/1.16", + "apiVersion": 1, + "author": { + "name": "q_wo2", + "id": "1238250053836079160" + }, + "service": "Nekowatch", + "description": { + "en": "Stream your presence from Nekowatch to your friends on Discord.", + "ja": "Nekowatchからあなたの存在をDiscordの友達にストリーミングしましょう。" + }, + "url": "nekowatch.xyz", + "regExp": "^https?[:][/][/](www[.])?nekowatch[.]xyz[/]", + "version": "1.0.0", + "logo": "https://i.imgur.com/LtP6hmP.jpeg", + "thumbnail": "https://i.imgur.com/Q7jwfg0.png", + "color": "#6366f1", + "category": "anime", + "tags": [ + "anime", + "streaming" + ], + "settings": [ + { + "id": "multiLanguage", + "title": "Multi-language support", + "icon": "fas fa-language", + "value": true + }, + { + "id": "showAnimeAsTitle", + "title": "Use anime name as header", + "icon": "fas fa-heading", + "value": false + }, + { + "id": "buttons", + "title": "Show buttons", + "icon": "fas fa-link", + "value": true + } + ] +} diff --git a/websites/N/Nekowatch/presence.ts b/websites/N/Nekowatch/presence.ts new file mode 100644 index 000000000000..8260c7f5093a --- /dev/null +++ b/websites/N/Nekowatch/presence.ts @@ -0,0 +1,286 @@ +import { ActivityType, Assets, getTimestamps } from 'premid' + +const presence = new Presence({ + clientId: '1373817718192734268', +}) + +const siteStartTimestamp = Math.floor(Date.now() / 1000) +enum ActivityAssets { + Logo = 'https://i.imgur.com/LtP6hmP.jpeg', +} + +function formatAnimeSlug(slug: string | null): string | null { + if (!slug) + return null + return slug + .split(/[-_]/) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' ') +} +function getAnimeTitleFromDOM(): string | null { + const titleElem = document.querySelector('#info-title, #aa-name') + if (titleElem && titleElem.textContent?.trim()) { + return titleElem.textContent.trim() + } + return null +} +function parseTimeToSeconds(timeStr: string | null): number | null { + if (!timeStr) + return null + const parts = timeStr.trim().split(':').map(Number) + if (parts.some(Number.isNaN)) + return null + if (parts.length === 2) { + const [minutes = 0, seconds = 0] = parts + return minutes * 60 + seconds + } + else if (parts.length === 3) { + const [hours = 0, minutes = 0, seconds = 0] = parts + return hours * 3600 + minutes * 60 + seconds + } + return null +} + +function getCoverImageFromDOM(epNum?: string, lang?: string, isProfile?: boolean): string | null { + if (isProfile) { + const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') + const src = avatarImg?.src || avatarImg?.getAttribute('src') + if (src && src.startsWith('http')) + return src + } + + const posterDiv = document.querySelector('#aa-poster') + if (posterDiv) { + const style = posterDiv.style.backgroundImage + const match = style?.match(/url\((['"]?)(.*?)\1\)/) + const src = match ? match[2] : null + if (src && src.startsWith('http')) + return src + } + + const infoPoster = document.querySelector('#info-poster-img') + if (infoPoster) { + const src = infoPoster.src || infoPoster.getAttribute('src') + if (src && src.startsWith('http')) + return src + } + + const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') + || document.querySelector('img[data-nimg="fill"]') + || document.querySelector('img[src*="screencap"]') + || document.querySelector('img[src*="episode"]') + || document.querySelector('img[class*="object-cover"]') + const src = coverImg?.src || coverImg?.getAttribute('src') + return src && src.startsWith('http') ? src : null +} +presence.on('UpdateData', async () => { + const { pathname, href, search } = document.location + const searchParams = new URLSearchParams(search) + const [useMultiLanguage, showAnimeAsTitle, showButtons] = await Promise.all([ + presence.getSetting('multiLanguage'), + presence.getSetting('showAnimeAsTitle'), + presence.getSetting('buttons'), + ]) + const rawStrings = await presence.getStrings({ + browsing: 'general.browsing', + searching: 'general.searching', + viewHome: 'general.viewHome', + viewing: 'general.viewing', + }) + const getString = (key: keyof typeof rawStrings, fallback: string) => { + if (!useMultiLanguage) + return fallback + const val = rawStrings[key] + return val && !val.startsWith('general.') ? val : fallback + } + const presenceData: PresenceData = { + type: ActivityType.Watching, + largeImageKey: ActivityAssets.Logo, + startTimestamp: siteStartTimestamp, + } + switch (true) { + case pathname === '/' || pathname === '/home': { + presenceData.details = 'Nekowatch' + presenceData.state = getString('viewHome', 'On Homepage') + break + } + case pathname === '/browse.html': { + presenceData.details = 'Nekowatch' + const q = searchParams.get('q') + const sort = searchParams.get('sort') + const season = searchParams.get('season') + const status = searchParams.get('status') + const format = searchParams.get('format') + const year = searchParams.get('year') + const audio = searchParams.get('audio') + + presenceData.details = q ? `Searching: "${q}"` : 'Browsing Catalog' + + const filters: string[] = [] + if (season) + filters.push(formatAnimeSlug(season) || '') + if (year) + filters.push(year) + if (format) + filters.push(format.toUpperCase() === 'TV' ? 'TV' : (formatAnimeSlug(format) || '')) + if (status) + filters.push(formatAnimeSlug(status) || '') + if (audio) + filters.push(formatAnimeSlug(audio) || '') + + let sortText = '' + if (sort === 'SCORE_DESC') + sortText = 'Top Rated' + else if (sort === 'POPULARITY_DESC') + sortText = 'Popular' + else if (sort === 'UPDATED_AT_DESC') + sortText = 'Recently Updated' + else if (sort === 'START_DATE_DESC') + sortText = 'Newest' + else if (sort) + sortText = `Sort: ${formatAnimeSlug(sort.replace('_DESC', ''))}` + + if (sortText) { + filters.push(sortText) + } + + presenceData.state = filters.length > 0 ? filters.join(' • ') : 'All Anime' + break + } + case pathname === '/circles': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Watching in Circles' + break + } + case pathname === '/schedule.html': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Checking Release Schedule' + break + } + case pathname === '/donate': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Donation Page' + break + } + case pathname === '/settings.html': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Settings' + break + } + case pathname === '/feedback': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Feedback Page' + break + } + case pathname === '/profile.html': { + presenceData.details = 'Nekowatch' + const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') + const altText = avatarImg?.getAttribute('alt') || '' + const match = altText.match(/(.+?)\s+avatar/i) + const username = match && match[1] ? match[1].trim() : null + presenceData.state = username ? `Viewing ${username}'s Profile` : 'Viewing Profile' + + const coverUrl = getCoverImageFromDOM(undefined, undefined, true) + if (coverUrl) { + presenceData.largeImageKey = coverUrl + } + break + } + case pathname === '/info.html': { + const animeTitle = getAnimeTitleFromDOM() + const coverUrl = getCoverImageFromDOM() + presenceData.details = 'Viewing Anime Info' + presenceData.state = animeTitle || 'Reading Details & Overview' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Info', + url: href, + }, + ] + } + break + } + case pathname === '/anime.html': { + const epNum = searchParams.get('ep') ?? '1' + const lang = searchParams.get('audio') ?? '' + const showTitle = getAnimeTitleFromDOM() || 'Anime' + const coverUrl = getCoverImageFromDOM() + let epLine = `Episode ${epNum}` + if (lang) { + epLine += ` [${lang.charAt(0).toUpperCase() + lang.slice(1)}]` + } + if (showAnimeAsTitle && showTitle) { + presenceData.name = showTitle + presenceData.details = epLine + delete presenceData.state + } + else { + delete presenceData.name + presenceData.details = showTitle + presenceData.state = epLine + } + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + + const video = document.querySelector('video') + const isPaused = video ? video.paused : true + + if (!isPaused) { + delete presenceData.smallImageKey + delete presenceData.smallImageText + + const timeSpans = Array.from(document.querySelectorAll('span[class*="font-medium"][class*="tracking-wide"]')) + const currentTimeText = timeSpans[0]?.textContent || null + const durationText = timeSpans[1]?.textContent || null + + const elementTime = parseTimeToSeconds(currentTimeText) ?? video?.currentTime ?? 0 + const elementDuration = parseTimeToSeconds(durationText) ?? video?.duration ?? 0 + + if (elementDuration > 0) { + const [start, end] = getTimestamps(elementTime, elementDuration) + presenceData.startTimestamp = start + presenceData.endTimestamp = end + } + else { + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + } + else { + presenceData.smallImageKey = Assets.Play + presenceData.smallImageText = 'Paused' + + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + + if (showButtons) { + presenceData.buttons = [ + { + label: 'Watch Episode', + url: href, + }, + ] + } + break + } + default: { + const animeTitle = getAnimeTitleFromDOM() + const coverUrl = getCoverImageFromDOM() + presenceData.details = getString('browsing', 'Browsing...') + presenceData.state = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Exploring Catalog' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Page', + url: href, + }, + ] + } + break + } + } + presence.setActivity(presenceData) +}) From 6e629cfd99259e282af12fb2aae5a158accda82b Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:53:12 -0400 Subject: [PATCH 5/7] feat(Nekowatch): Add Activity --- websites/N/Nekowatch/metadata.json | 44 +++++ websites/N/Nekowatch/presence.ts | 286 +++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 websites/N/Nekowatch/metadata.json create mode 100644 websites/N/Nekowatch/presence.ts diff --git a/websites/N/Nekowatch/metadata.json b/websites/N/Nekowatch/metadata.json new file mode 100644 index 000000000000..24828a4d0bcc --- /dev/null +++ b/websites/N/Nekowatch/metadata.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schemas.premid.app/metadata/1.16", + "apiVersion": 1, + "author": { + "name": "q_wo2", + "id": "1238250053836079160" + }, + "service": "Nekowatch", + "description": { + "en": "Stream your presence from Nekowatch to your friends on Discord.", + "ja": "Nekowatchからあなたの存在をDiscordの友達にストリーミングしましょう。" + }, + "url": "nekowatch.xyz", + "regExp": "^https?[:][/][/](www[.])?nekowatch[.]xyz[/]", + "version": "1.0.0", + "logo": "https://i.imgur.com/LtP6hmP.jpeg", + "thumbnail": "https://i.imgur.com/Q7jwfg0.png", + "color": "#6366f1", + "category": "anime", + "tags": [ + "anime", + "streaming" + ], + "settings": [ + { + "id": "multiLanguage", + "title": "Multi-language support", + "icon": "fas fa-language", + "value": true + }, + { + "id": "showAnimeAsTitle", + "title": "Use anime name as header", + "icon": "fas fa-heading", + "value": false + }, + { + "id": "buttons", + "title": "Show buttons", + "icon": "fas fa-link", + "value": true + } + ] +} diff --git a/websites/N/Nekowatch/presence.ts b/websites/N/Nekowatch/presence.ts new file mode 100644 index 000000000000..5ff556e89e7c --- /dev/null +++ b/websites/N/Nekowatch/presence.ts @@ -0,0 +1,286 @@ +import { ActivityType, Assets, getTimestamps } from 'premid' + +const presence = new Presence({ + clientId: '1373817718192734268', +}) + +const siteStartTimestamp = Math.floor(Date.now() / 1000) +enum ActivityAssets { + Logo = 'https://i.imgur.com/LtP6hmP.jpeg', +} + +function formatAnimeSlug(slug: string | null): string | null { + if (!slug) + return null + return slug + .split(/[-_]/) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' ') +} +function getAnimeTitleFromDOM(): string | null { + const titleElem = document.querySelector('#info-title, #aa-name') + if (titleElem && titleElem.textContent?.trim()) { + return titleElem.textContent.trim() + } + return null +} +function parseTimeToSeconds(timeStr: string | null): number | null { + if (!timeStr) + return null + const parts = timeStr.trim().split(':').map(Number) + if (parts.some(Number.isNaN)) + return null + if (parts.length === 2) { + const [minutes = 0, seconds = 0] = parts + return minutes * 60 + seconds + } + else if (parts.length === 3) { + const [hours = 0, minutes = 0, seconds = 0] = parts + return hours * 3600 + minutes * 60 + seconds + } + return null +} + +function getCoverImageFromDOM(epNum?: string, lang?: string, isProfile?: boolean): string | null { + if (isProfile) { + const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') + const src = avatarImg?.src || avatarImg?.getAttribute('src') + if (src && src.startsWith('http')) + return src + } + + const posterDiv = document.querySelector('#aa-poster') + if (posterDiv) { + const style = posterDiv.style.backgroundImage + const match = style?.match(/url\((['"]?)(.*?)\1\)/) + const src = match ? match[2] : null + if (src && src.startsWith('http')) + return src + } + + const infoPoster = document.querySelector('#info-poster-img') + if (infoPoster) { + const src = infoPoster.src || infoPoster.getAttribute('src') + if (src && src.startsWith('http')) + return src + } + + const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') + || document.querySelector('img[data-nimg="fill"]') + || document.querySelector('img[src*="screencap"]') + || document.querySelector('img[src*="episode"]') + || document.querySelector('img[class*="object-cover"]') + const src = coverImg?.src || coverImg?.getAttribute('src') + return src && src.startsWith('http') ? src : null +} +presence.on('UpdateData', async () => { + const { pathname, href, search } = document.location + const searchParams = new URLSearchParams(search) + const [useMultiLanguage, showAnimeAsTitle, showButtons] = await Promise.all([ + presence.getSetting('multiLanguage'), + presence.getSetting('showAnimeAsTitle'), + presence.getSetting('buttons'), + ]) + const rawStrings = await presence.getStrings({ + browsing: 'general.browsing', + searching: 'general.searching', + viewHome: 'general.viewHome', + viewing: 'general.viewing', + }) + const getString = (key: keyof typeof rawStrings, fallback: string) => { + if (!useMultiLanguage) + return fallback + const val = rawStrings[key] + return val && !val.startsWith('general.') ? val : fallback + } + const presenceData: PresenceData = { + type: ActivityType.Watching, + largeImageKey: ActivityAssets.Logo, + startTimestamp: siteStartTimestamp, + } + switch (true) { + case pathname === '/' || pathname === '/home': { + presenceData.details = 'Nekowatch' + presenceData.state = getString('viewHome', 'On Homepage') + break + } + case pathname === '/browse.html': { + presenceData.details = 'Nekowatch' + const q = searchParams.get('q') + const sort = searchParams.get('sort') + const season = searchParams.get('season') + const status = searchParams.get('status') + const format = searchParams.get('format') + const year = searchParams.get('year') + const audio = searchParams.get('audio') + + presenceData.details = q ? `Searching: "${q}"` : 'Browsing Catalog' + + const filters: string[] = [] + if (season) + filters.push(formatAnimeSlug(season) || '') + if (year) + filters.push(year) + if (format) + filters.push(format.toUpperCase() === 'TV' ? 'TV' : (formatAnimeSlug(format) || '')) + if (status) + filters.push(formatAnimeSlug(status) || '') + if (audio) + filters.push(formatAnimeSlug(audio) || '') + + let sortText = '' + if (sort === 'SCORE_DESC') + sortText = 'Top Rated' + else if (sort === 'POPULARITY_DESC') + sortText = 'Popular' + else if (sort === 'UPDATED_AT_DESC') + sortText = 'Recently Updated' + else if (sort === 'START_DATE_DESC') + sortText = 'Newest' + else if (sort) + sortText = `Sort: ${formatAnimeSlug(sort.replace('_DESC', ''))}` + + if (sortText) { + filters.push(sortText) + } + + presenceData.state = filters.length > 0 ? filters.join(' • ') : 'All Anime' + break + } + case pathname === '/circles': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Watching in Circles' + break + } + case pathname === '/schedule.html': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Checking Release Schedule' + break + } + case pathname === '/donate': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Donation Page' + break + } + case pathname === '/settings.html': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Settings' + break + } + case pathname === '/feedback': { + presenceData.details = 'Nekowatch' + presenceData.state = 'Viewing Feedback Page' + break + } + case pathname === '/profile.html': { + presenceData.details = 'Nekowatch' + const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') + const altText = avatarImg?.getAttribute('alt') || '' + const match = altText.match(/^(.+?)\s+avatar$/i) + const username = match && match[1] ? match[1].trim() : null + presenceData.state = username ? `Viewing ${username}'s Profile` : 'Viewing Profile' + + const coverUrl = getCoverImageFromDOM(undefined, undefined, true) + if (coverUrl) { + presenceData.largeImageKey = coverUrl + } + break + } + case pathname === '/info.html': { + const animeTitle = getAnimeTitleFromDOM() + const coverUrl = getCoverImageFromDOM() + presenceData.details = 'Viewing Anime Info' + presenceData.state = animeTitle || 'Reading Details & Overview' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Info', + url: href, + }, + ] + } + break + } + case pathname === '/anime.html': { + const epNum = searchParams.get('ep') ?? '1' + const lang = searchParams.get('audio') ?? '' + const showTitle = getAnimeTitleFromDOM() || 'Anime' + const coverUrl = getCoverImageFromDOM() + let epLine = `Episode ${epNum}` + if (lang) { + epLine += ` [${lang.charAt(0).toUpperCase() + lang.slice(1)}]` + } + if (showAnimeAsTitle && showTitle) { + presenceData.name = showTitle + presenceData.details = epLine + delete presenceData.state + } + else { + delete presenceData.name + presenceData.details = showTitle + presenceData.state = epLine + } + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + + const video = document.querySelector('video') + const isPaused = video ? video.paused : true + + if (!isPaused) { + delete presenceData.smallImageKey + delete presenceData.smallImageText + + const timeSpans = Array.from(document.querySelectorAll('span[class*="font-medium"][class*="tracking-wide"]')) + const currentTimeText = timeSpans[0]?.textContent || null + const durationText = timeSpans[1]?.textContent || null + + const elementTime = parseTimeToSeconds(currentTimeText) ?? video?.currentTime ?? 0 + const elementDuration = parseTimeToSeconds(durationText) ?? video?.duration ?? 0 + + if (elementDuration > 0) { + const [start, end] = getTimestamps(elementTime, elementDuration) + presenceData.startTimestamp = start + presenceData.endTimestamp = end + } + else { + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + } + else { + presenceData.smallImageKey = Assets.Play + presenceData.smallImageText = 'Paused' + + presenceData.startTimestamp = siteStartTimestamp + delete presenceData.endTimestamp + } + + if (showButtons) { + presenceData.buttons = [ + { + label: 'Watch Episode', + url: href, + }, + ] + } + break + } + default: { + const animeTitle = getAnimeTitleFromDOM() + const coverUrl = getCoverImageFromDOM() + presenceData.details = getString('browsing', 'Browsing...') + presenceData.state = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Exploring Catalog' + presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + if (showButtons) { + presenceData.buttons = [ + { + label: 'View Page', + url: href, + }, + ] + } + break + } + } + presence.setActivity(presenceData) +}) From 361db298f8c3d0a9899e007b2dd0999d4ac6d9cd Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:24:47 -0400 Subject: [PATCH 6/7] fix quantifier --- websites/N/Nekowatch/presence.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/websites/N/Nekowatch/presence.ts b/websites/N/Nekowatch/presence.ts index 5ff556e89e7c..94304fd14a66 100644 --- a/websites/N/Nekowatch/presence.ts +++ b/websites/N/Nekowatch/presence.ts @@ -176,8 +176,7 @@ presence.on('UpdateData', async () => { presenceData.details = 'Nekowatch' const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') const altText = avatarImg?.getAttribute('alt') || '' - const match = altText.match(/^(.+?)\s+avatar$/i) - const username = match && match[1] ? match[1].trim() : null + const username = altText.toLowerCase().endsWith(' avatar') ? altText.slice(0, -7).trim() : null presenceData.state = username ? `Viewing ${username}'s Profile` : 'Viewing Profile' const coverUrl = getCoverImageFromDOM(undefined, undefined, true) From 41210fa043455e950ba8dc9de44c7c2f99cda664 Mon Sep 17 00:00:00 2001 From: AkinaRulezx <201164556+AkinaRulezx@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:08:02 -0400 Subject: [PATCH 7/7] fixed some issues with the dynamic video play time not pausing or starting properly (idle timer seperate from dynamic video timer), made viewing a anime use the default logo as loading the image takes too long, fixed a issue with the profile username not displaying all the time. Nekowatch isnt optimized so everything is usally slow to load by default. --- websites/N/Nekowatch/presence.ts | 110 ++++++++++++++++++------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/websites/N/Nekowatch/presence.ts b/websites/N/Nekowatch/presence.ts index 94304fd14a66..f8ed34a27ecd 100644 --- a/websites/N/Nekowatch/presence.ts +++ b/websites/N/Nekowatch/presence.ts @@ -5,9 +5,7 @@ const presence = new Presence({ }) const siteStartTimestamp = Math.floor(Date.now() / 1000) -enum ActivityAssets { - Logo = 'https://i.imgur.com/LtP6hmP.jpeg', -} +let pauseStartTimestamp: number | null = null function formatAnimeSlug(slug: string | null): string | null { if (!slug) @@ -17,10 +15,18 @@ function formatAnimeSlug(slug: string | null): string | null { .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(' ') } -function getAnimeTitleFromDOM(): string | null { - const titleElem = document.querySelector('#info-title, #aa-name') - if (titleElem && titleElem.textContent?.trim()) { - return titleElem.textContent.trim() +function getAnimeTitleFromDOM(pathname: string): string | null { + if (pathname === '/info.html') { + const titleElem = document.querySelector('#info-title') + if (titleElem && titleElem.textContent?.trim()) { + return titleElem.textContent.trim() + } + } + if (pathname === '/anime.html') { + const titleElem = document.querySelector('#aa-name') + if (titleElem && titleElem.textContent?.trim()) { + return titleElem.textContent.trim() + } } return null } @@ -41,36 +47,44 @@ function parseTimeToSeconds(timeStr: string | null): number | null { return null } -function getCoverImageFromDOM(epNum?: string, lang?: string, isProfile?: boolean): string | null { - if (isProfile) { +function getCoverImageFromDOM(pathname: string, isProfile?: boolean): string | null { + if (isProfile || pathname === '/profile.html') { const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') const src = avatarImg?.src || avatarImg?.getAttribute('src') if (src && src.startsWith('http')) return src } - const posterDiv = document.querySelector('#aa-poster') - if (posterDiv) { - const style = posterDiv.style.backgroundImage - const match = style?.match(/url\((['"]?)(.*?)\1\)/) - const src = match ? match[2] : null - if (src && src.startsWith('http')) - return src + if (pathname === '/anime.html') { + const posterDiv = document.querySelector('#aa-poster') + if (posterDiv) { + const style = posterDiv.style.backgroundImage + const match = style?.match(/url\((['"]?)(.*?)\1\)/) + const src = match ? match[2] : null + if (src && src.startsWith('http')) + return src + } } - const infoPoster = document.querySelector('#info-poster-img') - if (infoPoster) { - const src = infoPoster.src || infoPoster.getAttribute('src') - if (src && src.startsWith('http')) - return src + if (pathname === '/info.html') { + const infoPoster = document.querySelector('#info-poster-img') + if (infoPoster) { + const src = infoPoster.getAttribute('data-nw-last-good-src') + || infoPoster.getAttribute('data-nw-motion-source') + || infoPoster.src + || infoPoster.getAttribute('src') + if (src && src.startsWith('http')) + return src + } } const coverImg = document.querySelector('img[src*="anilistcdn/media/anime/cover/"]') + || document.querySelector('img[data-nw-last-good-src*="anilistcdn"]') || document.querySelector('img[data-nimg="fill"]') || document.querySelector('img[src*="screencap"]') || document.querySelector('img[src*="episode"]') || document.querySelector('img[class*="object-cover"]') - const src = coverImg?.src || coverImg?.getAttribute('src') + const src = coverImg?.src || coverImg?.getAttribute('src') || coverImg?.getAttribute('data-nw-last-good-src') return src && src.startsWith('http') ? src : null } presence.on('UpdateData', async () => { @@ -95,7 +109,7 @@ presence.on('UpdateData', async () => { } const presenceData: PresenceData = { type: ActivityType.Watching, - largeImageKey: ActivityAssets.Logo, + largeImageKey: 'https://i.imgur.com/LtP6hmP.jpeg', startTimestamp: siteStartTimestamp, } switch (true) { @@ -174,23 +188,21 @@ presence.on('UpdateData', async () => { } case pathname === '/profile.html': { presenceData.details = 'Nekowatch' - const avatarImg = document.querySelector('img[src*="user/avatar/"], img[alt*="avatar"]') - const altText = avatarImg?.getAttribute('alt') || '' - const username = altText.toLowerCase().endsWith(' avatar') ? altText.slice(0, -7).trim() : null + const usernameElem = document.querySelector('.anilist-profile-name') + const username = usernameElem?.textContent?.trim() || null presenceData.state = username ? `Viewing ${username}'s Profile` : 'Viewing Profile' - const coverUrl = getCoverImageFromDOM(undefined, undefined, true) + const coverUrl = getCoverImageFromDOM(pathname, true) if (coverUrl) { presenceData.largeImageKey = coverUrl } break } case pathname === '/info.html': { - const animeTitle = getAnimeTitleFromDOM() - const coverUrl = getCoverImageFromDOM() + const animeTitle = getAnimeTitleFromDOM(pathname) presenceData.details = 'Viewing Anime Info' presenceData.state = animeTitle || 'Reading Details & Overview' - presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + presenceData.largeImageKey = 'https://i.imgur.com/LtP6hmP.jpeg' if (showButtons) { presenceData.buttons = [ { @@ -204,8 +216,8 @@ presence.on('UpdateData', async () => { case pathname === '/anime.html': { const epNum = searchParams.get('ep') ?? '1' const lang = searchParams.get('audio') ?? '' - const showTitle = getAnimeTitleFromDOM() || 'Anime' - const coverUrl = getCoverImageFromDOM() + const showTitle = getAnimeTitleFromDOM(pathname) || 'Anime' + const coverUrl = getCoverImageFromDOM(pathname) let epLine = `Episode ${epNum}` if (lang) { epLine += ` [${lang.charAt(0).toUpperCase() + lang.slice(1)}]` @@ -220,22 +232,27 @@ presence.on('UpdateData', async () => { presenceData.details = showTitle presenceData.state = epLine } - presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + presenceData.largeImageKey = coverUrl || 'https://i.imgur.com/LtP6hmP.jpeg' - const video = document.querySelector('video') + const videos = Array.from(document.querySelectorAll('video')) + const video = videos.find(v => v.src && !v.src.startsWith('blob:') && v.src.startsWith('http')) + || videos.find(v => v.src) + || videos[0] + || null const isPaused = video ? video.paused : true + const timeSpans = Array.from(document.querySelectorAll('span[class*="font-medium"][class*="tracking-wide"]')) + const currentTimeText = timeSpans[0]?.textContent || null + const durationText = timeSpans[1]?.textContent || null + + const elementTime = parseTimeToSeconds(currentTimeText) ?? video?.currentTime ?? 0 + const elementDuration = parseTimeToSeconds(durationText) ?? video?.duration ?? 0 + if (!isPaused) { + pauseStartTimestamp = null delete presenceData.smallImageKey delete presenceData.smallImageText - const timeSpans = Array.from(document.querySelectorAll('span[class*="font-medium"][class*="tracking-wide"]')) - const currentTimeText = timeSpans[0]?.textContent || null - const durationText = timeSpans[1]?.textContent || null - - const elementTime = parseTimeToSeconds(currentTimeText) ?? video?.currentTime ?? 0 - const elementDuration = parseTimeToSeconds(durationText) ?? video?.duration ?? 0 - if (elementDuration > 0) { const [start, end] = getTimestamps(elementTime, elementDuration) presenceData.startTimestamp = start @@ -250,7 +267,10 @@ presence.on('UpdateData', async () => { presenceData.smallImageKey = Assets.Play presenceData.smallImageText = 'Paused' - presenceData.startTimestamp = siteStartTimestamp + if (!pauseStartTimestamp) { + pauseStartTimestamp = Math.floor(Date.now() / 1000) + } + presenceData.startTimestamp = pauseStartTimestamp delete presenceData.endTimestamp } @@ -265,11 +285,11 @@ presence.on('UpdateData', async () => { break } default: { - const animeTitle = getAnimeTitleFromDOM() - const coverUrl = getCoverImageFromDOM() + const animeTitle = getAnimeTitleFromDOM(pathname) + const coverUrl = getCoverImageFromDOM(pathname) presenceData.details = getString('browsing', 'Browsing...') presenceData.state = animeTitle ? `${getString('viewing', 'Viewing')} ${animeTitle}` : 'Exploring Catalog' - presenceData.largeImageKey = coverUrl || ActivityAssets.Logo + presenceData.largeImageKey = coverUrl || 'https://i.imgur.com/LtP6hmP.jpeg' if (showButtons) { presenceData.buttons = [ {