"use client"; import Image from "next/image"; import { useEffect, useState } from "react"; import { GameProfileModal } from "@/components/game-profile-modal"; import type { Dictionary } from "@/lib/i18n/get-dictionary"; import { activityImageUrl, DISCORD_ID, discordAvatarUrl, discordGameUrl, formatSpotifyList, LANYARD_WS, lanyardUrl, spotifyTrackUrl, type DiscordAppInfo, type DiscordStatus, type LanyardActivity, type LanyardData, } from "@/lib/lanyard"; const STATUS_DOT: Record = { online: "bg-emerald-400", idle: "bg-amber-400", dnd: "bg-rose-500", offline: "bg-zinc-500", }; const WS_OPEN_TIMEOUT_MS = 10_000; const RECONNECT_BASE_MS = 2_000; const RECONNECT_MAX_MS = 30_000; const SAFETY_POLL_MS = 60_000; function formatTrackTime(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } function SpotifyProgress({ start, end }: { start: number; end: number }) { const [now, setNow] = useState(start); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1_000); return () => window.clearInterval(timer); }, []); const total = Math.max(1, end - start); const ratio = Math.min(1, Math.max(0, (now - start) / total)); const percent = Math.round(ratio * 100); return (
{formatTrackTime(now - start)}
{formatTrackTime(total)}
); } function formatElapsed(start: number, lang: string): string { const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000)); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (lang === "tr") { if (hours > 0) { return `${hours}s ${minutes}dk`; } if (minutes > 0) { return `${minutes}dk`; } return "<1dk"; } if (lang === "de") { if (hours > 0) { return `${hours} Std. ${minutes} Min.`; } if (minutes > 0) { return `${minutes} Min.`; } return "<1 Min."; } if (hours > 0) { return `${hours}h ${minutes}m`; } if (minutes > 0) { return `${minutes}m`; } return "<1m"; } const discordAppCache = new Map(); function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null { const [info, setInfo] = useState(() => applicationId ? (discordAppCache.get(applicationId) ?? null) : null, ); useEffect(() => { if (!applicationId || discordAppCache.has(applicationId)) { return; } const id = applicationId; let cancelled = false; async function load(): Promise { try { const response = await fetch(`/api/discord-app/${id}`); const json = (await response.json()) as DiscordAppInfo | { error: string }; const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null; discordAppCache.set(id, resolved); if (!cancelled) { setInfo(resolved); } } catch { discordAppCache.set(id, null); } } void load(); return () => { cancelled = true; }; }, [applicationId]); return info; } function ActivityRow({ activity, dict, locale, }: { activity: LanyardActivity; dict: Dictionary["discord"]; locale: string; }) { const appInfo = useDiscordApp(activity.application_id); const [profileOpen, setProfileOpen] = useState(false); const [heroSeed, setHeroSeed] = useState(0); const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null; const small = activityImageUrl(activity, true); const gameUrl = activity.application_id ? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id)) : null; return (
{large ? (
{activity.assets?.large_text {small ? ( {activity.assets?.small_text ) : null}
) : (
{activity.name.charAt(0).toUpperCase()}
)}

{activity.name}

{activity.details ? (

{activity.details}

) : null} {activity.state ? (

{activity.state}

) : null}
{activity.timestamps?.start ? ( {formatElapsed(activity.timestamps.start, locale)} ) : null} {gameUrl ? ( ) : null}
{profileOpen && activity.application_id ? ( setProfileOpen(false)} /> ) : null}
); } export function DiscordCard({ initial, dict, locale, }: { initial: LanyardData | null; dict: Dictionary["discord"]; locale: string; }) { const [data, setData] = useState(initial); useEffect(() => { let disposed = false; let socket: WebSocket | null = null; let heartbeatTimer: number | undefined; let reconnectTimer: number | undefined; let reconnectDelay = RECONNECT_BASE_MS; async function pollOnce(): Promise { try { const response = await fetch(lanyardUrl(), { cache: "no-store" }); if (!response.ok) { return; } const json = (await response.json()) as { success: boolean; data: LanyardData; }; if (json.success && !disposed) { setData(json.data); } } catch { return; } } function scheduleReconnect(): void { if (disposed) { return; } window.clearTimeout(reconnectTimer); reconnectTimer = window.setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); connect(); }, reconnectDelay); } function dropSocket(current: WebSocket): void { if (socket === current) { socket = null; } try { current.close(); } catch { socket = null; } } function connect(): void { if (disposed) { return; } let current: WebSocket; try { current = new WebSocket(LANYARD_WS); } catch { scheduleReconnect(); return; } socket = current; let settled = false; const openTimeout = window.setTimeout(() => { if (!settled) { settled = true; dropSocket(current); if (!disposed) { void pollOnce(); scheduleReconnect(); } } }, WS_OPEN_TIMEOUT_MS); const handleDrop = (): void => { if (settled && socket !== current) { return; } settled = true; window.clearTimeout(openTimeout); window.clearInterval(heartbeatTimer); dropSocket(current); if (!disposed) { void pollOnce(); scheduleReconnect(); } }; current.onmessage = (event: MessageEvent) => { let message: { op: number; t?: string; d?: unknown }; try { message = JSON.parse(event.data as string) as { op: number; t?: string; d?: unknown; }; } catch { return; } if (message.op === 1) { settled = true; window.clearTimeout(openTimeout); reconnectDelay = RECONNECT_BASE_MS; const interval = (message.d as { heartbeat_interval?: number } | undefined) ?.heartbeat_interval ?? 30_000; window.clearInterval(heartbeatTimer); heartbeatTimer = window.setInterval(() => { try { current.send(JSON.stringify({ op: 3 })); } catch { handleDrop(); } }, interval); current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } })); } else if ( message.op === 0 && (message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE") ) { if (!disposed) { setData(message.d as LanyardData); } } }; current.onerror = handleDrop; current.onclose = handleDrop; } connect(); const safetyTimer = window.setInterval(() => { if (!socket || socket.readyState !== WebSocket.OPEN) { void pollOnce(); } }, SAFETY_POLL_MS); return () => { disposed = true; window.clearTimeout(reconnectTimer); window.clearInterval(safetyTimer); window.clearInterval(heartbeatTimer); if (socket) { const current = socket; socket = null; current.onclose = null; current.onerror = null; current.onmessage = null; try { current.close(); } catch { socket = null; } } }; }, []); if (!data) { return (
{dict.offline}
); } const user = data.discord_user; const displayName = user.display_name ?? user.global_name ?? user.username; const customStatus = data.activities.find((a) => a.type === 4); const games = data.activities.filter((a) => a.type === 0); return (
{displayName}

{displayName}

@{user.username}

{dict[data.discord_status]} · {dict.live}

{customStatus?.state ? (

{dict.customStatus} {customStatus.emoji?.name ? ( {customStatus.emoji.name} ) : null} {customStatus.state}

) : null} {games.length > 0 ? (

{dict.playing}

{games.map((game) => ( ))}
) : null} {data.listening_to_spotify && data.spotify ? (
{data.spotify.album_art_url ? ( {formatSpotifyList(data.spotify.song)} ) : null}

{dict.listening} Spotify

{formatSpotifyList(data.spotify.song)}

{formatSpotifyList(data.spotify.artist)}

{data.spotify.timestamps ? ( ) : null} {dict.openOnSpotify} ↗
) : null} {games.length === 0 && !data.listening_to_spotify && !customStatus?.state ? (

{dict.noActivity}

) : null}
); }