PageSourceSearch

https://pupwife.com/projects/chatroom/scripts/chatroomLanyard.js

js pupwife.com collected 2026-09-30 15:07:21 UTC 24,060 bytes, 685 lines download raw bytes

1/**
2 * Per-user Lanyard (Discord presence) for chatroom status + activity section.
3 * Users opt in via Settings with their Discord user ID (Lanyard Discord server required).
4 */
5window.ChatroomLanyard = (function () {
6    'use strict';
7
8    const API_BASE = 'https://api.lanyard.rest/v1/users/';
9    const WS_URL = 'wss://api.lanyard.rest/socket';
10    const CACHE_MS = 8000;
11    const POLL_MS = 90000;
12    const FETCH_GAP_MS = 1200;
13    const REFRESH_DEBOUNCE_MS = 80;
14    const SOCKET_RECONNECT_MS = 5000;
15
16    const cache = new Map();
17    let pollTimer = null;
18    let fetchQueue = [];
19    let fetching = false;
20    let booted = false;
21    let refreshTimer = null;
22    let activityGeneration = 0;
23    let ws = null;
24    let heartbeatTimer = null;
25    let socketReady = false;
26    let pendingSubscribeIds = null;
27    let socketReconnectTimer = null;
28
29    const ACTIVITY_LABELS = {
30        0: 'Playing',
31        1: 'Streaming',
32        2: 'Listening to',
33        3: 'Watching',
34        5: 'Competing in'
35    };
36
37    /** Known music players (Discord RPC name). WINAMP often reports as type 0 Playing. */
38    const MUSIC_PLAYER_NAMES = [
39        'spotify', 'winamp', 'foobar2000', 'foobar', 'vlc', 'musicbee', 'itunes',
40        'apple music', 'tidal', 'deezer', 'youtube music', 'audacious', 'rhythmbox',
41        'clementine', 'aimp', 'media monkey', 'groove', 'plexamp', 'jetaudio',
42        'strawberry', 'amarok', 'qmmp', 'deadbeef', 'xmplay', 'potplayer',
43        'mpc-hc', 'mpc-be', 'kodi', 'jellyfin', 'nuclear', 'tauon', 'quod libet'
44    ];
45
46    function escapeHtml(text) {
47        const div = document.createElement('div');
48        div.textContent = text == null ? '' : String(text);
49        return div.innerHTML;
50    }
51
52    function getCustomStatus(activities) {
53        const custom = activities && activities.find(function (a) { return a.type === 4; });
54        if (!custom) return null;
55        const parts = [];
56        if (custom.emoji && custom.emoji.name) parts.push(custom.emoji.name);
57        if (custom.state) parts.push(custom.state);
58        return parts.length ? parts.join(' ') : null;
59    }
60
61    function normalizePlayerName(name) {
62        return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
63    }
64
65    function isMusicPlayerName(name) {
66        const normalized = normalizePlayerName(name);
67        if (!normalized) return false;
68        return MUSIC_PLAYER_NAMES.some(function (player) {
69            const key = player.replace(/[^a-z0-9]+/g, ' ').trim();
70            return normalized === key || normalized.indexOf(key) !== -1 || key.indexOf(normalized) !== -1;
71        });
72    }
73
74    /** Discord activity that represents music (Spotify, WINAMP, type 2 Listening, etc.). */
75    function isMusicActivity(activity) {
76        if (!activity || activity.type === 4) return false;
77        if (activity.type === 2) return true;
78        if (isMusicPlayerName(activity.name)) return true;
79        const assets = activity.assets || {};
80        const largeImage = assets.large_image || assets.largeImage || '';
81        if (String(largeImage).indexOf('spotify:') === 0) return true;
82        return false;
83    }
84
85    function getMusicActivities(activities) {
86        if (!activities || !activities.length) return [];
87        return activities.filter(isMusicActivity);
88    }
89
90    function getGameActivity(activities) {
91        if (!activities || !activities.length) return null;
92        return activities.find(function (a) {
93            if (!a || a.type === 4 || isMusicActivity(a)) return false;
94            return a.type === 0 || a.type === 1 || a.type === 3 || a.type === 5;
95        }) || null;
96    }
97
98    function formatGameLine(activity) {
99        if (!activity) return null;
100        const label = ACTIVITY_LABELS[activity.type] || 'Playing';
101        const name = activity.name || 'Something';
102        const detail = [activity.details, activity.state].filter(Boolean).join(' · ');
103        return detail ? (label + ' ' + name + ': ' + detail) : (label + ' ' + name);
104    }
105
106    function formatSpotifyLine(data) {
107        if (!data || !data.listening_to_spotify || !data.spotify) return null;
108        const song = data.spotify.song || data.spotify.track_name || 'a track';
109        const artistRaw = data.spotify.artist || data.spotify.artist_name || '';
110        const artist = typeof artistRaw === 'string' ? artistRaw.replace(/;/g, ', ') : '';
111        return artist ? ('Listening to ' + song + ' · ' + artist) : ('Listening to ' + song);
112    }
113
114    /** Non-Spotify music RPC (WINAMP, VLC, type 2 Listening, etc.). */
115    function formatRichMusicLine(activity) {
116        if (!activity) return null;
117        const details = (activity.details || '').trim();
118        const state = (activity.state || '').trim();
119        const name = (activity.name || '').trim();
120
121        if (details && state) {
122            return 'Listening to ' + details + ' · ' + state;
123        }
124        if (details) {
125            return 'Listening to ' + details;
126        }
127        if (state) {
128            return 'Listening to ' + state;
129        }
130        if (activity.type === 2 && name) {
131            return 'Listening to ' + name;
132        }
133        if (isMusicPlayerName(name)) {
134            return 'Listening on ' + name;
135        }
136        return name ? ('Listening to ' + name) : null;
137    }
138
139    function collectMusicLines(data) {
140        const lines = [];
141        const seen = new Set();
142
143        function add(line) {
144            if (!line || seen.has(line)) return;
145            seen.add(line);
146            lines.push(line);
147        }
148
149        add(formatSpotifyLine(data));
150
151        getMusicActivities(data.activities).forEach(function (activity) {
152            if (activity.name === 'Spotify' && data.listening_to_spotify) return;
153            add(formatRichMusicLine(activity));
154        });
155
156        return lines;
157    }
158
159    /** Activity panel: separate game + music bubbles (Spotify, WINAMP, other players). */
160    function getActivityBubbles(data) {
161        if (!data) return [];
162        const bubbles = [];
163        const game = getGameActivity(data.activities);
164        const gameLine = formatGameLine(game);
165        if (gameLine) {
166            bubbles.push({ kind: 'game', line: gameLine });
167        }
168        collectMusicLines(data).forEach(function (line) {
169            bubbles.push({ kind: 'music', line: line });
170        });
171        return bubbles;
172    }
173
174    function getPrimaryActivity(activities) {
175        return getGameActivity(activities);
176    }
177
178    function formatActivityLine(data) {
179        if (!data) return null;
180        const custom = getCustomStatus(data.activities);
181        if (custom) return custom;
182
183        const musicLines = collectMusicLines(data);
184        if (musicLines.length) return musicLines[0];
185
186        const gameLine = formatGameLine(getGameActivity(data.activities));
187        if (gameLine) return gameLine;
188
189        if (data.discord_status === 'dnd') return 'Do not disturb';
190        if (data.discord_status === 'idle') return 'Idle on Discord';
191        if (data.discord_status === 'online') return 'Online on Discord';
192        return null;
193    }
194
195    function getDiscordLink(username) {
196        if (!window.ChatroomProfilePicture || !window.ChatroomProfilePicture.getDiscordLink) return null;
197        return window.ChatroomProfilePicture.getDiscordLink(username);
198    }
199
200    function storePresence(discordId, data) {
201        if (!discordId) return;
202        cache.set(String(discordId), { data: data, fetchedAt: Date.now() });
203    }
204
205    function getStoredPresence(discordId, maxAge) {
206        const cached = cache.get(String(discordId));
207        if (!cached) return undefined;
208        if (maxAge != null && (Date.now() - cached.fetchedAt) > maxAge) return undefined;
209        return cached.data;
210    }
211
212    function collectMonitoredDiscordIds() {
213        const ids = [];
214        const online = window.ChatroomPresence && window.ChatroomPresence.getOnlineUsers
215            ? window.ChatroomPresence.getOnlineUsers()
216            : [];
217
218        online.forEach(function (username) {
219            const link = getDiscordLink(username);
220            if (link && link.useLanyardStatus && link.discordUserId) {
221                ids.push(String(link.discordUserId));
222            }
223        });
224
225        const me = window.__sharedUsername ? window.__sharedUsername.get() : '';
226        if (me) {
227            const myLink = getDiscordLink(me);
228            if (myLink && myLink.useLanyardStatus && myLink.discordUserId) {
229                ids.push(String(myLink.discordUserId));
230            }
231        }
232
233        return Array.from(new Set(ids));
234    }
235
236    function applyInitState(payload) {
237        if (!payload) return;
238        if (payload.discord_user && payload.discord_user.id) {
239            storePresence(payload.discord_user.id, payload);
240            return;
241        }
242        if (typeof payload === 'object') {
243            Object.keys(payload).forEach(function (id) {
244                storePresence(id, payload[id]);
245            });
246        }
247    }
248
249    function disconnectSocket() {
250        if (socketReconnectTimer) {
251            clearTimeout(socketReconnectTimer);
252            socketReconnectTimer = null;
253        }
254        if (heartbeatTimer) {
255            clearInterval(heartbeatTimer);
256            heartbeatTimer = null;
257        }
258        if (ws) {
259            ws.onopen = null;
260            ws.onclose = null;
261            ws.onmessage = null;
262            ws.onerror = null;
263            try { ws.close(); } catch (e) { /* ignore */ }
264            ws = null;
265        }
266        socketReady = false;
267    }
268
269    function sendSocketSubscribe(ids) {
270        if (!ws || ws.readyState !== WebSocket.OPEN) {
271            pendingSubscribeIds = ids;
272            return;
273        }
274        const unique = Array.from(new Set((ids || []).map(String).filter(Boolean)));
275        if (!unique.length) return;
276        ws.send(JSON.stringify({
277            op: 2,
278            d: { subscribe_to_ids: unique }
279        }));
280        pendingSubscribeIds = null;
281    }
282
283    function scheduleSocketReconnect() {
284        if (!booted || socketReconnectTimer) return;
285        socketReconnectTimer = setTimeout(function () {
286            socketReconnectTimer = null;
287            if (booted) connectSocket();
288        }, SOCKET_RECONNECT_MS);
289    }
290
291    function connectSocket() {
292        if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
293            return;
294        }
295        disconnectSocket();
296        try {
297            ws = new WebSocket(WS_URL);
298        } catch (err) {
299            console.warn('[ChatroomLanyard] WebSocket unavailable', err);
300            scheduleSocketReconnect();
301            return;
302        }
303
304        ws.onmessage = function (event) {
305            let msg;
306            try { msg = JSON.parse(event.data); } catch (e) { return; }
307
308            if (msg.op === 1) {
309                const interval = (msg.d && msg.d.heartbeat_interval) || 30000;
310                if (heartbeatTimer) clearInterval(heartbeatTimer);
311                heartbeatTimer = setInterval(function () {
312                    if (ws && ws.readyState === WebSocket.OPEN) {
313                        ws.send(JSON.stringify({ op: 3 }));
314                    }
315                }, interval);
316                socketReady = true;
317                sendSocketSubscribe(pendingSubscribeIds || collectMonitoredDiscordIds());
318                return;
319            }
320
321            if (msg.op !== 0) return;
322
323            if (msg.t === 'INIT_STATE') {
324                applyInitState(msg.d);
325                scheduleRefresh({ syncSocket: false });
326                return;
327            }
328
329            if (msg.t === 'PRESENCE_UPDATE' && msg.d) {
330                const id = msg.d.user_id || (msg.d.discord_user && msg.d.discord_user.id);
331                if (id) {
332                    storePresence(id, msg.d);
333                    scheduleRefresh({ syncSocket: false });
334                }
335            }
336        };
337
338        ws.onclose = function () {
339            socketReady = false;
340            if (heartbeatTimer) {
341                clearInterval(heartbeatTimer);
342                heartbeatTimer = null;
343            }
344            scheduleSocketReconnect();
345        };
346
347        ws.onerror = function () {
348            /* onclose handles reconnect */
349        };
350    }
351
352    function syncSocketSubscriptions() {
353        const ids = collectMonitoredDiscordIds();
354        if (!ids.length) {
355            pendingSubscribeIds = [];
356            disconnectSocket();
357            return;
358        }
359        pendingSubscribeIds = ids;
360        if (ws && ws.readyState === WebSocket.OPEN) {
361            sendSocketSubscribe(ids);
362            return;
363        }
364        connectSocket();
365    }
366
367    function fetchPresence(discordId, force) {
368        if (!discordId) return Promise.resolve(null);
369        const key = String(discordId);
370        if (!force) {
371            const cached = getStoredPresence(key, CACHE_MS);
372            if (cached !== undefined) return Promise.resolve(cached);
373        }
374        return fetch(API_BASE + key)
375            .then(function (r) { return r.json(); })
376            .then(function (res) {
377                const data = res && res.success ? res.data : null;
378                storePresence(key, data);
379                return data;
380            })
381            .catch(function (err) {
382                console.warn('[ChatroomLanyard] fetch failed for', key, err);
383                return null;
384            });
385    }
386
387    function queueFetch(discordId, force) {
388        return new Promise(function (resolve) {
389            if (!force) {
390                const cached = getStoredPresence(discordId, CACHE_MS);
391                if (cached !== undefined) {
392                    resolve(cached);
393                    return;
394                }
395            }
396            fetchQueue.push({ discordId: discordId, force: !!force, resolve: resolve });
397            drainQueue();
398        });
399    }
400
401    function drainQueue() {
402        if (fetching || !fetchQueue.length) return;
403        fetching = true;
404        const job = fetchQueue.shift();
405        fetchPresence(job.discordId, job.force).then(function (data) {
406            job.resolve(data);
407            fetching = false;
408            setTimeout(drainQueue, FETCH_GAP_MS);
409        });
410    }
411
412    function updateUserPanelStatus(opts) {
413        const statusEl = document.getElementById('user-status-text');
414        if (!statusEl) return;
415        const me = window.__sharedUsername ? window.__sharedUsername.get() : '';
416        if (!me) return;
417
418        const link = getDiscordLink(me);
419        if (link && link.useLanyardStatus && link.discordUserId) {
420            const cached = getStoredPresence(link.discordUserId);
421            if (cached !== undefined && !(opts && opts.force)) {
422                const cachedLine = formatActivityLine(cached);
423                if (cachedLine) {
424                    statusEl.textContent = cachedLine.substring(0, 120);
425                    statusEl.classList.add('user-status-lanyard');
426                    return;
427                }
428                applyFallbackStatus(statusEl, me);
429                return;
430            }
431            fetchPresence(link.discordUserId, !!(opts && opts.force)).then(function (data) {
432                const line = formatActivityLine(data);
433                if (line) {
434                    statusEl.textContent = line.substring(0, 120);
435                    statusEl.classList.add('user-status-lanyard');
436                    return;
437                }
438                applyFallbackStatus(statusEl, me);
439            });
440            return;
441        }
442        applyFallbackStatus(statusEl, me);
443    }
444
445    function applyFallbackStatus(statusEl, username) {
446        statusEl.classList.remove('user-status-lanyard');
447        const profileStatus = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getStatus
448            ? window.ChatroomProfilePicture.getStatus(username)
449            : '';
450        if (profileStatus) {
451            statusEl.textContent = profileStatus.substring(0, 80);
452            return;
453        }
454        const state = window.ChatroomPresence && window.ChatroomPresence.getPresenceState
455            ? window.ChatroomPresence.getPresenceState(username)
456            : 'online';
457        statusEl.textContent = state === 'idle' ? 'idle' : 'online';
458    }
459
460    function hideLegacyActivitySection() {
461        const section = document.getElementById('activity-section');
462        if (section) section.style.display = 'none';
463    }
464
465    function clearMemberActivities() {
466        const membersList = document.getElementById('members-list');
467        if (!membersList) return;
468        membersList.querySelectorAll('.member-activities').forEach(function (el) {
469            el.remove();
470        });
471    }
472
473    function bubblesSignature(bubbles) {
474        return bubbles.map(function (b) { return b.kind + ':' + b.line; }).join('|');
475    }
476
477    function buildActivityLinesHtml(bubbles) {
478        return bubbles.map(function (bubble) {
479            const icon = bubble.kind === 'music' ? 'fa-compact-disc' : 'fa-gamepad';
480            return '<div class="member-activity member-activity--' + bubble.kind + '">' +
481                '<i class="fa-solid ' + icon + ' fa-width-auto" aria-hidden="true"></i>' +
482                '<span class="member-activity__text">' + escapeHtml(bubble.line) + '</span>' +
483                '</div>';
484        }).join('');
485    }
486
487    function findMemberItem(username) {
488        const membersList = document.getElementById('members-list');
489        if (!membersList || !username) return null;
490        const items = membersList.querySelectorAll('.member-item[data-username]');
491        for (let i = 0; i < items.length; i++) {
492            if (items[i].dataset.username === username) return items[i];
493        }
494        return null;
495    }
496
497    function renderMemberActivities(rows) {
498        hideLegacyActivitySection();
499        const membersList = document.getElementById('members-list');
500        if (!membersList) return;
501
502        const painted = new Set();
503
504        rows.forEach(function (row) {
505            if (!row.data) return;
506            const bubbles = getActivityBubbles(row.data);
507            if (!bubbles.length) return;
508
509            const memberItem = findMemberItem(row.username);
510            if (!memberItem) return;
511            const memberInfo = memberItem.querySelector('.member-info');
512            if (!memberInfo) return;
513
514            const signature = bubblesSignature(bubbles);
515            let wrap = memberInfo.querySelector('.member-activities');
516            if (!wrap) {
517                wrap = document.createElement('div');
518                wrap.className = 'member-activities';
519                wrap.dataset.username = row.username;
520                memberInfo.appendChild(wrap);
521            }
522            wrap.dataset.username = row.username;
523
524            if (wrap.dataset.signature !== signature) {
525                wrap.dataset.signature = signature;
526                wrap.innerHTML = buildActivityLinesHtml(bubbles);
527            }
528
529            painted.add(row.username);
530        });
531
532        membersList.querySelectorAll('.member-activities').forEach(function (el) {
533            if (!painted.has(el.dataset.username)) {
534                el.remove();
535            }
536        });
537    }
538
539    function refreshActivitySection(opts) {
540        const membersList = document.getElementById('members-list');
541        if (!membersList) return;
542
543        const gen = ++activityGeneration;
544        const force = !!(opts && opts.force);
545
546        const online = window.ChatroomPresence && window.ChatroomPresence.getOnlineUsers
547            ? window.ChatroomPresence.getOnlineUsers()
548            : [];
549
550        const linked = online.map(function (username) {
551            const link = getDiscordLink(username);
552            if (link && link.useLanyardStatus && link.discordUserId) {
553                return { username: username, discordUserId: link.discordUserId };
554            }
555            return null;
556        }
556).filter(Boolean);
557
558        if (!linked.length) {
559            clearMemberActivities();
560            hideLegacyActivitySection();
561            return;
562        }
563
564        const allCached = !force && linked.every(function (entry) {
565            return getStoredPresence(entry.discordUserId) !== undefined;
566        });
567
568        if (allCached) {
569            if (gen !== activityGeneration) return;
570            const rows = linked.map(function (entry) {
571                return {
572                    username: entry.username,
573                    data: getStoredPresence(entry.discordUserId)
574                };
575            });
576            renderMemberActivities(rows);
577            return;
578        }
579
580        Promise.all(linked.map(function (entry) {
581            return queueFetch(entry.discordUserId, force).then(function (data) {
582                return { username: entry.username, data: data };
583            });
584        })).then(function (rows) {
585            if (gen !== activityGeneration) return;
586            renderMemberActivities(rows);
587        });
588    }
589
590    function refreshAll(opts) {
591        opts = opts || {};
592        if (opts.syncSocket !== false) syncSocketSubscriptions();
593        updateUserPanelStatus(opts);
594        if (!opts.userPanelOnly) {
595            refreshActivitySection(opts);
596        }
597    }
598
599    function repaintMemberActivities(opts) {
600        opts = opts || {};
601        if (opts.syncSocket !== false) syncSocketSubscriptions();
602        refreshActivitySection({ force: false });
603    }
604
605    function scheduleRefresh(opts) {
606        if (refreshTimer) clearTimeout(refreshTimer);
607        refreshTimer = setTimeout(function () {
608            refreshTimer = null;
609            refreshAll(opts || {});
610        }, REFRESH_DEBOUNCE_MS);
611    }
612
613    function onMembersListRendered() {
614        syncSocketSubscriptions();
615        repaintMemberActivities({ syncSocket: false });
616    }
617
618    function onUserProfilesUpdated() {
619        scheduleRefresh({ syncSocket: false, userPanelOnly: true });
620    }
621
622    function startPolling() {
623        if (pollTimer) clearInterval(pollTimer);
624        refreshAll({ force: true });
625        pollTimer = setInterval(function () {
626            refreshAll({ force: true });
627        }, POLL_MS);
628    }
629
630    function stopPolling() {
631        if (pollTimer) {
632            clearInterval(pollTimer);
633            pollTimer = null;
634        }
635        if (refreshTimer) {
636            clearTimeout(refreshTimer);
637            refreshTimer = null;
638        }
639    }
640
641    function init() {
642        if (!document.getElementById('user-status-text')) return false;
643        if (booted) {
644            refreshAll({ force: true });
645            return true;
646        }
647        booted = true;
648        startPolling();
649        window.addEventListener('userProfilesUpdated', onUserProfilesUpdated);
650        document.addEventListener('chatroom-members-rendered', onMembersListRendered);
651        return true;
652    }
653
654    function destroy() {
655        if (!booted) return;
656        booted = false;
657        stopPolling();
658        disconnectSocket();
659        window.removeEventListener('userProfilesUpdated', onUserProfilesUpdated);
660        document.removeEventListener('chatroom-members-rendered', onMembersListRendered);
661    }
662
663    function previewDiscordId(discordId) {
664        return fetchPresence(discordId, true).then(function (data) {
665            if (!data) {
666                return { ok: false, message: 'No data. Join the Lanyard Discord server and set a visible Discord status, then try again.' };
667            }
668            const line = formatActivityLine(data);
669            if (!line) {
670                return { ok: true, message: 'Connected! No custom status or activity showing on Discord right now.' };
671            }
672            return { ok: true, message: 'Connected: ' + line };
673        });
674    }
675
676    return {
677        init: init,
678        destroy: destroy,
679        refreshAll: refreshAll,
680        repaintMemberActivities: repaintMemberActivities,
681        updateUserPanelStatus: updateUserPanelStatus,
682        previewDiscordId: previewDiscordId,
683        formatActivityLine: formatActivityLine
684    };
685})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.