diff --git a/src/lib/core/discord.ts b/src/lib/core/discord.ts index 8c98842..b7d2165 100644 --- a/src/lib/core/discord.ts +++ b/src/lib/core/discord.ts @@ -1,4 +1,5 @@ import { platformService } from '$lib/platform-service' +import { settingsState } from '$lib/state/settings.svelte' import type { Manga } from '$lib/types/manga' import type { Chapter } from '$lib/types/chapter' @@ -9,11 +10,8 @@ const APP_BUTTONS = [ const FALLBACK_IMAGE = 'moku_logo' -let sessionStart: number | null = null - -function isPublicUrl(url: string | null | undefined): boolean { - return typeof url === 'string' && url.startsWith('https://') -} +let sessionStart: number | null = null +let activeMangaId: number | null = null function trunc(s: string, max = 128): string { return s.length <= max ? s : `${s.slice(0, max - 1)}…` @@ -24,6 +22,31 @@ function formatChapter(chapter: Chapter): string { return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}` } +// Suwayomi always returns the proxy path (/api/v1/manga/{id}/thumbnail), never the raw CDN URL. +// The proxy URL is only useful to Discord when the server is publicly reachable over HTTPS. +// For localhost setups cover art falls back to the app logo until Suwayomi exposes rawThumbnailUrl. +function resolveCoverUrl(manga: Manga): string { + const serverBase = (settingsState.settings.serverUrl ?? '').replace(/\/$/, '') + if (!serverBase.startsWith('https://')) return FALLBACK_IMAGE + const path = manga.thumbnailUrl?.startsWith('/') ? manga.thumbnailUrl : `/api/v1/manga/${manga.id}/thumbnail` + return `${serverBase}${path}` +} + +function buildPresence(manga: Manga, chapter: Chapter, coverUrl: string) { + return { + details: trunc(manga.title), + state: `${formatChapter(chapter)} · Reading`, + timestamps: { start: sessionStart ?? Date.now() }, + assets: { + largeImage: coverUrl, + largeText: trunc(manga.title), + smallImage: FALLBACK_IMAGE, + smallText: 'Moku', + }, + buttons: APP_BUTTONS, + } +} + export async function initRpc(): Promise { if (!platformService.isSupported('discord-rpc')) return sessionStart = Date.now() @@ -36,18 +59,9 @@ export async function destroyRpc(): Promise { export async function setReading(manga: Manga, chapter: Chapter): Promise { if (!platformService.isSupported('discord-rpc')) return - await platformService.setDiscordPresence({ - details: trunc(manga.title), - state: `${formatChapter(chapter)} · Reading`, - timestamps: { start: sessionStart ?? Date.now() }, - assets: { - largeImage: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE, - largeText: trunc(manga.title), - smallImage: FALLBACK_IMAGE, - smallText: 'Moku', - }, - buttons: APP_BUTTONS, - }) + + activeMangaId = manga.id + await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga))) } export async function setIdle(): Promise { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cfa8c4e..589a6e0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -158,30 +158,33 @@ if (appState.status !== 'ready') return // capture phase so events from any component — including modals — reset the timer const onActivity = () => resetIdleTimer() - document.addEventListener('mousemove', onActivity, true) - document.addEventListener('keydown', onActivity, true) - document.addEventListener('touchstart', onActivity, true) - document.addEventListener('touchmove', onActivity, true) // sustained touch-scroll in reader - document.addEventListener('wheel', onActivity, true) // mouse-wheel / trackpad scroll in reader - document.addEventListener('click', onActivity, true) + document.addEventListener('mousemove', onActivity, true) + document.addEventListener('keydown', onActivity, true) + document.addEventListener('touchstart', onActivity, true) + // passive:true tells the browser it can render scroll frames without waiting for the handler + document.addEventListener('touchmove', onActivity, { capture: true, passive: true }) + document.addEventListener('wheel', onActivity, { capture: true, passive: true }) + document.addEventListener('click', onActivity, true) return () => { - document.removeEventListener('mousemove', onActivity, true) - document.removeEventListener('keydown', onActivity, true) - document.removeEventListener('touchstart', onActivity, true) - document.removeEventListener('touchmove', onActivity, true) - document.removeEventListener('wheel', onActivity, true) - document.removeEventListener('click', onActivity, true) + document.removeEventListener('mousemove', onActivity, true) + document.removeEventListener('keydown', onActivity, true) + document.removeEventListener('touchstart', onActivity, true) + document.removeEventListener('touchmove', onActivity, { capture: true }) + document.removeEventListener('wheel', onActivity, { capture: true }) + document.removeEventListener('click', onActivity, true) } }) function resetIdleTimer() { - if (idleTimer) clearTimeout(idleTimer) - appState.idleSplash = false + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null } + if (appState.idleSplash) appState.idleSplash = false // read the setting live so changes take effect without a restart - const timeoutMs = (settingsState.settings.idleTimeoutMin ?? 5) * 60_000 + // 0 means "Never" — skip the timer entirely + const mins = settingsState.settings.idleTimeoutMin ?? 5 + if (mins === 0) return idleTimer = setTimeout(() => { if (appState.status === 'ready') appState.idleSplash = true - }, timeoutMs) + }, mins * 60_000) } function onIdleDismiss() { appState.idleSplash = false; resetIdleTimer() }