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.