mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
1334 lines
47 KiB
JavaScript
1334 lines
47 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
let ws = null;
|
|
let reconnectAttempts = 0;
|
|
const maxReconnectAttempts = 10;
|
|
const baseReconnectDelay = 1000;
|
|
let isConnected = false;
|
|
let shouldReconnect = true;
|
|
let reconnectTimeout = null;
|
|
let connectionRetryCount = 0;
|
|
let clientCountRequestTimeout = null;
|
|
let imageLoadQueue = new Map();
|
|
let wsInitialized = false;
|
|
|
|
function connect() {
|
|
if (wsInitialized && ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
|
return;
|
|
}
|
|
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
console.log('Max reconnection attempts reached');
|
|
updateConnectionStatus('failed');
|
|
return;
|
|
}
|
|
|
|
wsInitialized = true;
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsURL = protocol + '//' + window.location.host + '/ws';
|
|
|
|
try {
|
|
updateConnectionStatus('connecting');
|
|
|
|
if (ws) {
|
|
ws.onopen = null;
|
|
ws.onmessage = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
ws.close();
|
|
ws = null;
|
|
}
|
|
|
|
ws = new WebSocket(wsURL);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
const connectionTimeout = setTimeout(() => {
|
|
if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
console.log('Connection timeout, closing WebSocket');
|
|
ws.close();
|
|
}
|
|
}, 10000);
|
|
|
|
ws.onopen = function() {
|
|
console.log('WebSocket connected successfully');
|
|
clearTimeout(connectionTimeout);
|
|
reconnectAttempts = 0;
|
|
connectionRetryCount = 0;
|
|
isConnected = true;
|
|
shouldReconnect = true;
|
|
updateConnectionStatus('connected');
|
|
|
|
startHeartbeat();
|
|
startLastFMCheck();
|
|
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
|
|
sendMessage({ type: 'register', data: { page: window.location.pathname } });
|
|
|
|
if (clientCountRequestTimeout) {
|
|
clearTimeout(clientCountRequestTimeout);
|
|
}
|
|
clientCountRequestTimeout = setTimeout(() => {
|
|
sendMessage({ type: 'get_client_count' });
|
|
clientCountRequestTimeout = null;
|
|
}, 100);
|
|
|
|
setTimeout(() => {
|
|
sendMessage({type: 'check_lastfm'});
|
|
}, 500);
|
|
};
|
|
|
|
ws.onmessage = function(event) {
|
|
try {
|
|
let messageData = event.data;
|
|
|
|
if (messageData instanceof ArrayBuffer) {
|
|
messageData = new TextDecoder().decode(messageData);
|
|
}
|
|
|
|
const messages = messageData.toString().split('\n');
|
|
messages.forEach(messageStr => {
|
|
messageStr = messageStr.trim();
|
|
if (messageStr) {
|
|
try {
|
|
const message = JSON.parse(messageStr);
|
|
handleMessage(message);
|
|
} catch (parseErr) {
|
|
console.debug('Failed to parse message:', messageStr, parseErr);
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Error processing WebSocket message:', e);
|
|
}
|
|
};
|
|
|
|
ws.onclose = function(event) {
|
|
clearTimeout(connectionTimeout);
|
|
console.log('WebSocket disconnected, code:', event.code, 'reason:', event.reason || 'none', 'clean:', event.wasClean);
|
|
isConnected = false;
|
|
stopHeartbeat();
|
|
stopLastFMCheck();
|
|
|
|
if (clientCountRequestTimeout) {
|
|
clearTimeout(clientCountRequestTimeout);
|
|
clientCountRequestTimeout = null;
|
|
}
|
|
|
|
if (event.code === 1000 || event.code === 1001) {
|
|
shouldReconnect = false;
|
|
updateConnectionStatus('disconnected');
|
|
return;
|
|
}
|
|
|
|
if (shouldReconnect) {
|
|
updateConnectionStatus('disconnected');
|
|
attemptReconnect();
|
|
} else {
|
|
updateConnectionStatus('disconnected');
|
|
}
|
|
};
|
|
|
|
ws.onerror = function(error) {
|
|
clearTimeout(connectionTimeout);
|
|
console.error('WebSocket error:', error);
|
|
updateConnectionStatus('error');
|
|
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
};
|
|
|
|
} catch (e) {
|
|
console.error('Failed to create WebSocket connection:', e);
|
|
updateConnectionStatus('error');
|
|
attemptReconnect();
|
|
}
|
|
}
|
|
|
|
function sendMessage(message) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(JSON.stringify(message));
|
|
} catch (e) {
|
|
console.error('Failed to send message:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function attemptReconnect() {
|
|
if (!shouldReconnect || reconnectTimeout) return;
|
|
|
|
reconnectAttempts++;
|
|
connectionRetryCount++;
|
|
|
|
const delay = Math.min(baseReconnectDelay * Math.pow(2, Math.min(reconnectAttempts - 1, 5)), 30000);
|
|
const jitter = Math.random() * 1000;
|
|
const finalDelay = delay + jitter;
|
|
|
|
console.log(`Reconnecting in ${Math.round(finalDelay)}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
|
|
updateConnectionStatus('connecting');
|
|
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectTimeout = null;
|
|
if (shouldReconnect) {
|
|
connect();
|
|
}
|
|
}, finalDelay);
|
|
}
|
|
|
|
let heartbeatInterval = null;
|
|
let heartbeatTimeout = null;
|
|
let missedHeartbeats = 0;
|
|
const MAX_MISSED_HEARTBEATS = 3;
|
|
|
|
function startHeartbeat() {
|
|
stopHeartbeat();
|
|
missedHeartbeats = 0;
|
|
|
|
heartbeatInterval = setInterval(() => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
const pingMessage = JSON.stringify({ type: 'ping' });
|
|
ws.send(pingMessage);
|
|
|
|
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
|
|
|
heartbeatTimeout = setTimeout(() => {
|
|
missedHeartbeats++;
|
|
console.warn(`Missed heartbeat ${missedHeartbeats}/${MAX_MISSED_HEARTBEATS}`);
|
|
|
|
if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
|
|
console.error('Too many missed heartbeats, reconnecting');
|
|
stopHeartbeat();
|
|
if (ws) ws.close();
|
|
}
|
|
}, 10000);
|
|
} catch (e) {
|
|
console.error('Failed to send ping:', e);
|
|
}
|
|
} else {
|
|
stopHeartbeat();
|
|
}
|
|
}, 25000);
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (heartbeatInterval) {
|
|
clearInterval(heartbeatInterval);
|
|
heartbeatInterval = null;
|
|
}
|
|
if (heartbeatTimeout) {
|
|
clearTimeout(heartbeatTimeout);
|
|
heartbeatTimeout = null;
|
|
}
|
|
missedHeartbeats = 0;
|
|
}
|
|
|
|
function onHeartbeatResponse() {
|
|
missedHeartbeats = 0;
|
|
if (heartbeatTimeout) {
|
|
clearTimeout(heartbeatTimeout);
|
|
heartbeatTimeout = null;
|
|
}
|
|
}
|
|
|
|
let lastFMCheckInterval = null;
|
|
let lastFMCheckTimeout = null;
|
|
let lastFMLastResponse = 0;
|
|
|
|
function startLastFMCheck() {
|
|
stopLastFMCheck();
|
|
|
|
lastFMCheckInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
|
|
if (lastFMLastResponse > 0 && (now - lastFMLastResponse) > 120000) {
|
|
console.warn('LastFM updates stale, reconnecting WebSocket');
|
|
if (shouldReconnect) {
|
|
ws?.close();
|
|
setTimeout(connect, 1000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN && getClientCount() > 0) {
|
|
try {
|
|
sendMessage({ type: 'check_lastfm' });
|
|
|
|
if (lastFMCheckTimeout) clearTimeout(lastFMCheckTimeout);
|
|
|
|
lastFMCheckTimeout = setTimeout(() => {
|
|
console.warn('LastFM check timeout, no response in 30s');
|
|
}, 30000);
|
|
} catch (e) {
|
|
console.error('Failed to send LastFM check:', e);
|
|
}
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
function stopLastFMCheck() {
|
|
if (lastFMCheckInterval) {
|
|
clearInterval(lastFMCheckInterval);
|
|
lastFMCheckInterval = null;
|
|
}
|
|
if (lastFMCheckTimeout) {
|
|
clearTimeout(lastFMCheckTimeout);
|
|
lastFMCheckTimeout = null;
|
|
}
|
|
}
|
|
|
|
function onLastFMResponse() {
|
|
lastFMLastResponse = Date.now();
|
|
if (lastFMCheckTimeout) {
|
|
clearTimeout(lastFMCheckTimeout);
|
|
lastFMCheckTimeout = null;
|
|
}
|
|
}
|
|
|
|
function getClientCount() {
|
|
const clientsEl = document.getElementById('connected-clients');
|
|
if (clientsEl) {
|
|
return parseInt(clientsEl.textContent) || 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function handleMessage(message) {
|
|
try {
|
|
switch (message.type) {
|
|
case 'pong':
|
|
onHeartbeatResponse();
|
|
break;
|
|
case 'client_count_update':
|
|
updateClientCount(message.data);
|
|
break;
|
|
case 'lastfm_update':
|
|
onLastFMResponse();
|
|
updateLastFM(message.data);
|
|
break;
|
|
case 'lastfm_realtime':
|
|
onLastFMResponse();
|
|
updateLastFM(message.data);
|
|
break;
|
|
case 'beatleader_update':
|
|
updateBeatLeader(message.data);
|
|
break;
|
|
case 'steam_update':
|
|
case 'steam_games_update':
|
|
updateSteam(message.data);
|
|
break;
|
|
case 'steam_status_update':
|
|
updateSteamStatus(message.data);
|
|
break;
|
|
case 'visitors_update':
|
|
updateVisitors(message.data);
|
|
break;
|
|
case 'webring_update':
|
|
updateWebring(message.data);
|
|
break;
|
|
case 'services_summary_update':
|
|
updateServicesStatus(message.data);
|
|
break;
|
|
case 'service_status_update':
|
|
updateSingleServiceStatus(message.data);
|
|
break;
|
|
case 'music_play':
|
|
if (window.musicPlayer) {
|
|
window.musicPlayer.handleMusicUpdate(message);
|
|
}
|
|
break;
|
|
case 'plugin_update':
|
|
handlePluginUpdate(message.data);
|
|
break;
|
|
case 'plugins_updated':
|
|
setTimeout(() => {
|
|
if (window.location.pathname !== '/admin') {
|
|
window.location.reload();
|
|
}
|
|
}, 1000);
|
|
break;
|
|
case 'meme_update':
|
|
updateMemeGlobal(message.data);
|
|
break;
|
|
case 'system_update':
|
|
updateSystemInfo(message.data);
|
|
break;
|
|
case 'heartbeat':
|
|
onHeartbeatResponse();
|
|
break;
|
|
default:
|
|
console.debug('Unknown message type:', message.type);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error handling message:', message, e);
|
|
}
|
|
}
|
|
|
|
function updateMemeGlobal(data) {
|
|
const section = document.querySelector('.meme-section');
|
|
if (!section || !data.meme) return;
|
|
|
|
const memeContent = section.querySelector('.meme-content');
|
|
if (!memeContent) return;
|
|
|
|
const meme = data.meme;
|
|
|
|
memeContent.style.opacity = '0';
|
|
memeContent.style.transition = 'opacity 0.2s ease';
|
|
|
|
setTimeout(() => {
|
|
let newContent = '';
|
|
|
|
if (meme.type === 'image' || meme.type === 'gif') {
|
|
newContent = `
|
|
<div class="meme-${meme.type}">
|
|
<img src="${meme.image}" alt="${meme.text}" loading="lazy">
|
|
${meme.text ? `<p class="meme-caption">${meme.text}</p>` : ''}
|
|
</div>
|
|
`;
|
|
} else {
|
|
newContent = `
|
|
<div class="meme-text">
|
|
<p class="meme-quote">${meme.text}</p>
|
|
${meme.source ? `<p class="meme-source">— ${meme.source}</p>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
memeContent.innerHTML = newContent;
|
|
|
|
setTimeout(() => {
|
|
memeContent.style.opacity = '1';
|
|
}, 50);
|
|
}, 200);
|
|
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateClientCount(data) {
|
|
const clientsEl = document.getElementById('connected-clients');
|
|
if (clientsEl && data && typeof data.count === 'number') {
|
|
const oldCount = parseInt(clientsEl.textContent) || 0;
|
|
const newCount = data.count;
|
|
|
|
if (oldCount !== newCount) {
|
|
animateNumber(clientsEl, oldCount, newCount);
|
|
}
|
|
}
|
|
|
|
const allClientEls = document.querySelectorAll('[data-client-count], .client-count');
|
|
allClientEls.forEach(el => {
|
|
if (data && typeof data.count === 'number') {
|
|
const oldCount = parseInt(el.textContent) || 0;
|
|
if (oldCount !== data.count) {
|
|
animateNumber(el, oldCount, data.count);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateConnectionStatus(status) {
|
|
const statusIndicator = document.getElementById('connection-status');
|
|
const statusText = document.getElementById('status-text');
|
|
|
|
if (statusIndicator) {
|
|
statusIndicator.className = 'status-indicator';
|
|
switch (status) {
|
|
case 'connected':
|
|
statusIndicator.classList.add('status-online');
|
|
break;
|
|
case 'connecting':
|
|
statusIndicator.classList.add('status-loading');
|
|
break;
|
|
case 'goodbye':
|
|
statusIndicator.classList.add('status-offline');
|
|
break;
|
|
case 'disconnected':
|
|
case 'error':
|
|
case 'failed':
|
|
statusIndicator.classList.add('status-offline');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (statusText) {
|
|
switch (status) {
|
|
case 'connected':
|
|
statusText.textContent = 'Connected';
|
|
break;
|
|
case 'connecting':
|
|
statusText.textContent = connectionRetryCount > 0 ? `Reconnecting... (${connectionRetryCount})` : 'Connecting...';
|
|
break;
|
|
case 'goodbye':
|
|
statusText.textContent = 'Goodbye!';
|
|
break;
|
|
case 'disconnected':
|
|
statusText.textContent = 'Disconnected';
|
|
break;
|
|
case 'error':
|
|
statusText.textContent = 'Connection Error';
|
|
break;
|
|
case 'failed':
|
|
statusText.textContent = 'Connection Failed';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function preloadImage(src, onLoad, onError) {
|
|
if (!src || imageLoadQueue.has(src)) return;
|
|
|
|
const img = new Image();
|
|
imageLoadQueue.set(src, img);
|
|
|
|
img.onload = () => {
|
|
imageLoadQueue.delete(src);
|
|
if (onLoad) onLoad(img);
|
|
};
|
|
|
|
img.onerror = () => {
|
|
imageLoadQueue.delete(src);
|
|
console.warn('Failed to preload image:', src);
|
|
if (onError) onError();
|
|
};
|
|
|
|
img.src = src;
|
|
}
|
|
|
|
function updateLastFM(data) {
|
|
const section = document.querySelector('.lastfm-section');
|
|
if (!section) return;
|
|
|
|
const trackName = section.querySelector('.track-title, .track-name');
|
|
const trackArtist = section.querySelector('.track-artist');
|
|
const trackAlbum = section.querySelector('.track-album');
|
|
const statusText = section.querySelector('.status-text');
|
|
const coverImg = section.querySelector('.track-cover-large img, .track-cover img');
|
|
|
|
if (trackName) trackName.textContent = data.name || 'Unknown Track';
|
|
if (trackArtist) trackArtist.textContent = data.artist ? `by ${data.artist}` : 'Unknown Artist';
|
|
if (trackAlbum && data.album) trackAlbum.textContent = `from ${data.album}`;
|
|
|
|
if (statusText) {
|
|
const isNowPlaying = data.isPlaying === true || data.isPlaying === 'true';
|
|
statusText.textContent = isNowPlaying ? 'Now Playing' : 'Last played';
|
|
|
|
const statusContainer = statusText.closest('.track-status');
|
|
if (statusContainer) {
|
|
if (isNowPlaying) {
|
|
statusContainer.classList.add('now-playing');
|
|
} else {
|
|
statusContainer.classList.remove('now-playing');
|
|
}
|
|
}
|
|
|
|
const statusIndicator = section.querySelector('.status-indicator');
|
|
if (statusIndicator) {
|
|
statusIndicator.className = 'status-indicator';
|
|
if (isNowPlaying) {
|
|
statusIndicator.classList.add('status-online');
|
|
} else {
|
|
statusIndicator.classList.add('status-offline');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (coverImg && data.image) {
|
|
loadImageSmoothly(coverImg, data.image);
|
|
} else if (coverImg && !data.image) {
|
|
coverImg.style.opacity = '0.3';
|
|
coverImg.src = '/static/images/default-album.png';
|
|
setTimeout(() => { coverImg.style.opacity = '1'; }, 150);
|
|
}
|
|
|
|
if (data.recentTracks && Array.isArray(data.recentTracks) && data.recentTracks.length > 0) {
|
|
updateRecentTracks(section, data.recentTracks);
|
|
}
|
|
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateRecentTracks(section, recentTracks) {
|
|
const recentContainer = section.querySelector('.recent-tracks-list');
|
|
if (!recentContainer) return;
|
|
|
|
const existingTracks = Array.from(recentContainer.querySelectorAll('.recent-track-item')).map(item => {
|
|
const nameEl = item.querySelector('.recent-track-name');
|
|
const artistEl = item.querySelector('.recent-track-artist');
|
|
return {
|
|
name: nameEl ? nameEl.textContent.replace(' 🎵', '').trim() : '',
|
|
artist: artistEl ? artistEl.textContent.trim() : ''
|
|
};
|
|
});
|
|
|
|
const newTracks = recentTracks.slice(0, 5).map(track => ({
|
|
name: track.name || 'Unknown Track',
|
|
artist: track.artist || 'Unknown Artist'
|
|
}));
|
|
|
|
const hasChanges = JSON.stringify(existingTracks) !== JSON.stringify(newTracks);
|
|
|
|
if (!hasChanges) return;
|
|
|
|
recentContainer.innerHTML = '';
|
|
|
|
const maxTracks = 5;
|
|
const tracksToShow = recentTracks.slice(0, maxTracks);
|
|
|
|
tracksToShow.forEach((track) => {
|
|
const trackElement = document.createElement('div');
|
|
trackElement.className = 'recent-track-item';
|
|
|
|
const isCurrentlyPlaying = track.isPlaying === true || track.isPlaying === 'true';
|
|
|
|
if (isCurrentlyPlaying) {
|
|
trackElement.classList.add('now-playing');
|
|
}
|
|
|
|
const trackName = track.name || 'Unknown Track';
|
|
const trackArtist = track.artist || 'Unknown Artist';
|
|
const trackImage = track.image || '';
|
|
const relativeTime = track.relativeTime || '';
|
|
|
|
trackElement.innerHTML = `
|
|
${trackImage ? `<div class="recent-track-cover"><img src="${trackImage}" alt="${trackName}" loading="lazy"></div>` : ''}
|
|
<div class="recent-track-info">
|
|
<div class="recent-track-name">${trackName}${isCurrentlyPlaying ? ' 🎵' : ''}</div>
|
|
<div class="recent-track-artist">${trackArtist}</div>
|
|
</div>
|
|
<div class="recent-track-time">${relativeTime}</div>
|
|
`;
|
|
|
|
if (!isCurrentlyPlaying && window.playTrack) {
|
|
trackElement.style.cursor = 'pointer';
|
|
|
|
trackElement.addEventListener('click', function () {
|
|
const searchQuery = `${trackArtist} ${trackName}`;
|
|
window.playTrack(searchQuery);
|
|
});
|
|
|
|
trackElement.addEventListener('mouseenter', () => {
|
|
trackElement.style.background = 'rgba(255,255,255,.024)';
|
|
});
|
|
|
|
trackElement.addEventListener('mouseleave', () => {
|
|
trackElement.style.background = '';
|
|
});
|
|
}
|
|
|
|
recentContainer.appendChild(trackElement);
|
|
});
|
|
}
|
|
|
|
function loadImageSmoothly(imgElement, newSrc) {
|
|
if (!imgElement || !newSrc) return;
|
|
|
|
if (imgElement.src === newSrc) return;
|
|
|
|
const fadeOut = () => {
|
|
imgElement.style.transition = 'opacity 0.2s ease, filter 0.2s ease';
|
|
imgElement.style.opacity = '0.4';
|
|
imgElement.style.filter = 'blur(2px)';
|
|
};
|
|
|
|
const fadeIn = () => {
|
|
imgElement.style.opacity = '1';
|
|
imgElement.style.filter = 'blur(0px)';
|
|
setTimeout(() => {
|
|
imgElement.style.transition = '';
|
|
}, 200);
|
|
};
|
|
|
|
preloadImage(newSrc,
|
|
(loadedImg) => {
|
|
imgElement.src = newSrc;
|
|
fadeIn();
|
|
},
|
|
() => {
|
|
imgElement.src = '/static/images/default-album.png';
|
|
fadeIn();
|
|
}
|
|
);
|
|
|
|
fadeOut();
|
|
}
|
|
|
|
function updateBeatLeader(data) {
|
|
const section = document.querySelector('.beatleader-section');
|
|
if (!section) return;
|
|
|
|
const statItems = section.querySelectorAll('.stat-item');
|
|
if (statItems.length >= 4) {
|
|
const rankStat = statItems[0].querySelector('.stat-value');
|
|
const countryRankStat = statItems[1].querySelector('.stat-value');
|
|
const ppStat = statItems[2].querySelector('.stat-value');
|
|
const accuracyStat = statItems[3].querySelector('.stat-value');
|
|
|
|
if (rankStat) {
|
|
rankStat.textContent = '#' + data.rank;
|
|
rankStat.dataset.rawValue = String(data.rank);
|
|
}
|
|
if (countryRankStat) {
|
|
countryRankStat.textContent = '#' + data.countryRank;
|
|
countryRankStat.dataset.rawValue = String(data.countryRank);
|
|
}
|
|
if (ppStat) {
|
|
ppStat.textContent = Math.round(data.pp) + 'pp';
|
|
ppStat.dataset.rawValue = String(Math.round(data.pp));
|
|
}
|
|
if (accuracyStat) {
|
|
accuracyStat.textContent = data.accuracy.toFixed(1) + '%';
|
|
accuracyStat.dataset.rawValue = String(data.accuracy);
|
|
}
|
|
}
|
|
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateSteam(data) {
|
|
const section = document.querySelector('.steam-section');
|
|
if (!section) return;
|
|
|
|
console.debug('Steam games updated:', data.games);
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateSteamStatus(data) {
|
|
const section = $('.steam-section');
|
|
if (!section) return;
|
|
|
|
const currentGameDiv = section.querySelector('.current-game');
|
|
const playerStatusDiv = section.querySelector('.player-status');
|
|
|
|
if (data.isPlaying && data.currentGame) {
|
|
if (!currentGameDiv) {
|
|
const headerElement = section.querySelector('.plugin-header');
|
|
if (headerElement) {
|
|
const gameHTML = `
|
|
<div class="current-game">
|
|
<div class="current-game-header">
|
|
<span class="status-indicator status-online"></span>
|
|
<span class="current-game-status">Currently Playing</span>
|
|
</div>
|
|
${data.gameImage ? `
|
|
<div class="current-game-cover">
|
|
<img src="${data.gameImage}" alt="${data.currentGame}" loading="lazy" class="game-cover-image">
|
|
</div>
|
|
` : ''}
|
|
<div class="current-game-info">
|
|
<div class="current-game-name">${data.currentGame}</div>
|
|
<div class="current-game-actions">
|
|
<a href="https://store.steampowered.com/${data.gameId ? 'app/' + data.gameId : 'search/?term=' + encodeURIComponent(data.currentGame)}" target="_blank" rel="noopener" class="btn btn-sm">
|
|
<svg viewBox="0 0 24 24" width="14" height="14">
|
|
<path fill="currentColor" d="M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h14c1.11 0 2-.89 2-2v-7h-2v7Z"/>
|
|
</svg>
|
|
View on Steam
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
headerElement.insertAdjacentHTML('afterend', gameHTML);
|
|
}
|
|
} else {
|
|
const gameName = currentGameDiv.querySelector('.current-game-name');
|
|
const gameLink = currentGameDiv.querySelector('.current-game-actions a');
|
|
const gameCover = currentGameDiv.querySelector('.current-game-cover');
|
|
|
|
if (gameName) gameName.textContent = data.currentGame;
|
|
if (gameLink) {
|
|
gameLink.href = data.gameId
|
|
? `https://store.steampowered.com/app/${data.gameId}`
|
|
: `https://store.steampowered.com/search/?term=${encodeURIComponent(data.currentGame)}`;
|
|
}
|
|
|
|
if (data.gameImage && !gameCover) {
|
|
const infoDiv = currentGameDiv.querySelector('.current-game-info');
|
|
if (infoDiv) {
|
|
const coverHTML = `
|
|
<div class="current-game-cover">
|
|
<img src="${data.gameImage}" alt="${data.currentGame}" loading="lazy" class="game-cover-image">
|
|
</div>
|
|
`;
|
|
infoDiv.insertAdjacentHTML('beforebegin', coverHTML);
|
|
}
|
|
} else if (data.gameImage && gameCover) {
|
|
const img = gameCover.querySelector('img');
|
|
if (img) loadImageSmoothly(img, data.gameImage);
|
|
}
|
|
}
|
|
|
|
if (playerStatusDiv) playerStatusDiv.style.display = 'none';
|
|
} else {
|
|
if (currentGameDiv) currentGameDiv.remove();
|
|
|
|
if (!playerStatusDiv) {
|
|
const headerElement = section.querySelector('.plugin-header');
|
|
if (headerElement) {
|
|
const statusHTML = `
|
|
<div class="player-status">
|
|
<div class="status-info">
|
|
<span class="status-indicator status-${getPersonaStateClass(data.personaState)}"></span>
|
|
<span class="status-text">${getPersonaStateText(data.personaState)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
headerElement.insertAdjacentHTML('afterend', statusHTML);
|
|
}
|
|
} else {
|
|
const statusIndicator = playerStatusDiv.querySelector('.status-indicator');
|
|
const statusText = playerStatusDiv.querySelector('.status-text');
|
|
if (statusIndicator) {
|
|
statusIndicator.className = `status-indicator status-${getPersonaStateClass(data.personaState)}`;
|
|
}
|
|
if (statusText) {
|
|
statusText.textContent = getPersonaStateText(data.personaState);
|
|
}
|
|
playerStatusDiv.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function getPersonaStateClass(state) {
|
|
switch (state) {
|
|
case 1: return 'online';
|
|
case 2: case 3: case 4: case 5: case 6: return 'loading';
|
|
default: return 'offline';
|
|
}
|
|
}
|
|
|
|
function getPersonaStateText(state) {
|
|
switch (state) {
|
|
case 0: return 'Offline';
|
|
case 1: return 'Online';
|
|
case 2: return 'Busy';
|
|
case 3: return 'Away';
|
|
case 4: return 'Snooze';
|
|
case 5: return 'Looking to trade';
|
|
case 6: return 'Looking to play';
|
|
default: return 'Unknown';
|
|
}
|
|
}
|
|
|
|
function updateWebring(data) {
|
|
const section = document.querySelector('.webring-section');
|
|
if (!section) return;
|
|
|
|
const prevLink = section.querySelector('.webring-prev');
|
|
const nextLink = section.querySelector('.webring-next');
|
|
|
|
let updated = false;
|
|
|
|
if (prevLink && data.prev) {
|
|
const currentHref = prevLink.href;
|
|
if (currentHref !== data.prev.url) {
|
|
prevLink.href = data.prev.url;
|
|
updated = true;
|
|
}
|
|
|
|
const prevImg = prevLink.querySelector('img');
|
|
const prevText = prevLink.querySelector('.webring-text');
|
|
|
|
if (prevImg && data.prev.favicon && prevImg.src !== data.prev.favicon) {
|
|
prevImg.src = data.prev.favicon;
|
|
updated = true;
|
|
}
|
|
|
|
if (prevText) {
|
|
const newText = `← ${data.prev.name}`;
|
|
if (prevText.textContent !== newText) {
|
|
prevText.textContent = newText;
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nextLink && data.next) {
|
|
const currentHref = nextLink.href;
|
|
if (currentHref !== data.next.url) {
|
|
nextLink.href = data.next.url;
|
|
updated = true;
|
|
}
|
|
|
|
const nextImg = nextLink.querySelector('img');
|
|
const nextText = nextLink.querySelector('.webring-text');
|
|
|
|
if (nextImg && data.next.favicon && nextImg.src !== data.next.favicon) {
|
|
nextImg.src = data.next.favicon;
|
|
updated = true;
|
|
}
|
|
|
|
if (nextText) {
|
|
const newText = `${data.next.name} →`;
|
|
if (nextText.textContent !== newText) {
|
|
nextText.textContent = newText;
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated) {
|
|
animateUpdate(section);
|
|
console.debug('Webring updated via WebSocket');
|
|
}
|
|
}
|
|
|
|
function updateVisitors(data) {
|
|
let updated = false;
|
|
|
|
if (data.total !== undefined) {
|
|
const totalElements = document.querySelectorAll('.total-visits, [data-stat="total"]');
|
|
totalElements.forEach(el => {
|
|
const newValue = data.total;
|
|
el.dataset.rawValue = String(newValue);
|
|
el.textContent = formatNumber(newValue);
|
|
updated = true;
|
|
});
|
|
}
|
|
|
|
if (data.today !== undefined) {
|
|
const todayElements = document.querySelectorAll('.today-visits, [data-stat="today"]');
|
|
todayElements.forEach(el => {
|
|
const newValue = data.today;
|
|
el.dataset.rawValue = String(newValue);
|
|
el.textContent = formatNumber(newValue);
|
|
updated = true;
|
|
});
|
|
}
|
|
|
|
if (updated) {
|
|
const visitorsSection = document.querySelector('.visitors-section');
|
|
if (visitorsSection) {
|
|
animateUpdate(visitorsSection);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateServicesStatus(data) {
|
|
const section = document.querySelector('.services-section');
|
|
if (!section) return;
|
|
|
|
const onlineCount = section.querySelector('.online-count');
|
|
const offlineCount = section.querySelector('.offline-count');
|
|
const totalCount = section.querySelector('.total-count');
|
|
|
|
if (onlineCount) onlineCount.textContent = data.online_count;
|
|
if (offlineCount) offlineCount.textContent = data.offline_count;
|
|
if (totalCount) totalCount.textContent = data.total_count;
|
|
|
|
if (data.services) {
|
|
Object.entries(data.services).forEach(([serviceName, serviceData]) => {
|
|
const serviceItems = section.querySelectorAll('.service-item');
|
|
serviceItems.forEach(item => {
|
|
const itemName = item.querySelector('.service-link, .service-title')?.textContent;
|
|
if (itemName === serviceName) {
|
|
item.setAttribute('data-status', serviceData.status);
|
|
|
|
const statusIndicator = item.querySelector('.status-indicator');
|
|
if (statusIndicator) {
|
|
statusIndicator.className = `status-indicator status-${serviceData.status}`;
|
|
}
|
|
|
|
const responseTime = item.querySelector('.service-response-time');
|
|
if (responseTime && serviceData.response_time) {
|
|
responseTime.textContent = `${serviceData.response_time}ms`;
|
|
}
|
|
|
|
const statusCode = item.querySelector('.service-status-code');
|
|
if (statusCode && serviceData.status_code) {
|
|
statusCode.textContent = serviceData.status_code;
|
|
statusCode.setAttribute('data-code', serviceData.status_code);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateSingleServiceStatus(data) {
|
|
const section = document.querySelector('.services-section');
|
|
if (!section) return;
|
|
|
|
const serviceItems = section.querySelectorAll('.service-item');
|
|
serviceItems.forEach(item => {
|
|
const itemName = item.querySelector('.service-link, .service-title')?.textContent;
|
|
if (itemName === data.name) {
|
|
item.setAttribute('data-status', data.status);
|
|
|
|
const statusIndicator = item.querySelector('.status-indicator');
|
|
if (statusIndicator) {
|
|
statusIndicator.className = `status-indicator status-${data.status}`;
|
|
statusIndicator.title = `${data.status} (${data.response_time}ms)`;
|
|
}
|
|
|
|
const responseTime = item.querySelector('.service-response-time');
|
|
if (responseTime && data.response_time) {
|
|
responseTime.textContent = `${data.response_time}ms`;
|
|
}
|
|
|
|
const statusCode = item.querySelector('.service-status-code');
|
|
if (statusCode && data.status_code) {
|
|
statusCode.textContent = data.status_code;
|
|
statusCode.setAttribute('data-code', data.status_code);
|
|
}
|
|
|
|
animateUpdate(item);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMeme(data) {
|
|
const section = document.querySelector('.meme-section');
|
|
if (!section || !data.meme) return;
|
|
|
|
const memeContent = section.querySelector('.meme-content');
|
|
if (!memeContent) return;
|
|
|
|
const meme = data.meme;
|
|
let newContent = '';
|
|
|
|
if (meme.type === 'image' || meme.type === 'gif') {
|
|
newContent = `
|
|
<div class="meme-${meme.type}">
|
|
<img src="${meme.image}" alt="${meme.text}" loading="lazy">
|
|
${meme.text ? `<p class="meme-caption">${meme.text}</p>` : ''}
|
|
</div>
|
|
`;
|
|
} else {
|
|
newContent = `
|
|
<div class="meme-text">
|
|
<p class="meme-quote">${meme.text}</p>
|
|
${meme.source ? `<p class="meme-source">— ${meme.source}</p>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
memeContent.innerHTML = newContent;
|
|
animateUpdate(section);
|
|
}
|
|
|
|
function updateSystemInfo(data) {
|
|
if (data.uptime_text) {
|
|
const uptimeElements = document.querySelectorAll('[data-uptime]:not([data-uptime="client"])');
|
|
uptimeElements.forEach(el => {
|
|
if (el.textContent !== data.uptime_text) {
|
|
el.textContent = data.uptime_text;
|
|
animateUpdate(el);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (data.last_updated) {
|
|
const lastUpdatedElements = document.querySelectorAll('#last-updated');
|
|
lastUpdatedElements.forEach(el => {
|
|
if (el.textContent !== data.last_updated) {
|
|
el.textContent = data.last_updated;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (data.connected_clients !== undefined) {
|
|
updateClientCount({ count: data.connected_clients });
|
|
}
|
|
}
|
|
|
|
function handlePluginUpdate(data) {
|
|
if (data.action === 'settings_changed') {
|
|
console.debug('Plugin settings updated:', data.plugin);
|
|
|
|
if (window.location.pathname !== '/admin') {
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 2000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function animateUpdate(element) {
|
|
if (!element) return;
|
|
|
|
element.style.transform = 'scale(1.01)';
|
|
element.style.transition = 'transform 0.15s ease';
|
|
|
|
setTimeout(() => {
|
|
element.style.transform = '';
|
|
}, 150);
|
|
}
|
|
|
|
function animateNumber(element, oldValue, newValue) {
|
|
if (!element) return;
|
|
|
|
element.style.color = 'var(--accent)';
|
|
element.style.transition = 'color 0.3s ease';
|
|
element.textContent = newValue;
|
|
|
|
setTimeout(() => {
|
|
element.style.color = '';
|
|
}, 300);
|
|
}
|
|
|
|
function animateCounterUpdate(element, oldValue, newValue) {
|
|
if (!element) return;
|
|
|
|
element.style.transform = 'scale(1.1)';
|
|
element.style.color = 'var(--accent)';
|
|
element.style.transition = 'transform 0.2s ease, color 0.3s ease';
|
|
|
|
const formatted = formatNumber(newValue);
|
|
element.textContent = formatted;
|
|
element.dataset.rawValue = String(newValue);
|
|
element.dataset.animated = 'true';
|
|
|
|
setTimeout(() => {
|
|
element.style.transform = '';
|
|
element.style.color = '';
|
|
}, 200);
|
|
}
|
|
|
|
function formatNumber(n) {
|
|
if (n < 1000) {
|
|
return n.toString();
|
|
} else if (n < 1000000) {
|
|
return (n / 1000).toFixed(1) + 'K';
|
|
} else {
|
|
return (n / 1000000).toFixed(1) + 'M';
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
shouldReconnect = false;
|
|
stopHeartbeat();
|
|
stopLastFMCheck();
|
|
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
|
|
if (clientCountRequestTimeout) {
|
|
clearTimeout(clientCountRequestTimeout);
|
|
clientCountRequestTimeout = null;
|
|
}
|
|
|
|
updateConnectionStatus('goodbye');
|
|
|
|
if (ws) {
|
|
ws.close(1000, 'Client disconnect');
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', connect);
|
|
} else {
|
|
connect();
|
|
}
|
|
|
|
window.addEventListener('beforeunload', disconnect);
|
|
window.addEventListener('pagehide', disconnect);
|
|
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden) {
|
|
stopHeartbeat();
|
|
stopLastFMCheck();
|
|
} else {
|
|
if (isConnected) {
|
|
startHeartbeat();
|
|
startLastFMCheck();
|
|
|
|
if (clientCountRequestTimeout) {
|
|
clearTimeout(clientCountRequestTimeout);
|
|
}
|
|
clientCountRequestTimeout = setTimeout(() => {
|
|
sendMessage({ type: 'get_client_count' });
|
|
}, 100);
|
|
} else if (shouldReconnect) {
|
|
connect();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.wsReconnect = connect;
|
|
window.wsDisconnect = disconnect;
|
|
window.wsStatus = () => isConnected;
|
|
window.wsSend = sendMessage;
|
|
let webringUpdateInterval = null;
|
|
let webringInitialized = false;
|
|
|
|
function initWebringUpdater() {
|
|
if (webringInitialized) return;
|
|
|
|
const webringSection = document.querySelector('.webring-section');
|
|
if (!webringSection) return;
|
|
|
|
const baseUrl = webringSection.dataset.baseUrl;
|
|
const username = webringSection.dataset.username;
|
|
|
|
if (!baseUrl || !username) return;
|
|
|
|
webringInitialized = true;
|
|
let isUpdating = false;
|
|
let lastUpdateTime = 0;
|
|
|
|
function updateWebringFromAPI() {
|
|
const now = Date.now();
|
|
|
|
if (isUpdating || (now - lastUpdateTime) < 5000) return;
|
|
|
|
isUpdating = true;
|
|
|
|
fetch(`${baseUrl}/${username}/data`)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Network response was not ok');
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (!data.prev || !data.next) return;
|
|
|
|
const prevLink = webringSection.querySelector('.webring-prev');
|
|
const nextLink = webringSection.querySelector('.webring-next');
|
|
|
|
let updated = false;
|
|
|
|
if (prevLink && data.prev) {
|
|
const currentHref = prevLink.href;
|
|
const newHref = data.prev.url;
|
|
|
|
if (currentHref !== newHref) {
|
|
prevLink.href = newHref;
|
|
updated = true;
|
|
}
|
|
|
|
const prevImg = prevLink.querySelector('img');
|
|
const prevText = prevLink.querySelector('.webring-text');
|
|
|
|
if (prevImg && data.prev.favicon) {
|
|
const newSrc = `${baseUrl}/media/${data.prev.favicon}`;
|
|
if (prevImg.src !== newSrc) {
|
|
prevImg.src = newSrc;
|
|
updated = true;
|
|
}
|
|
}
|
|
|
|
if (prevText) {
|
|
const newText = `← ${data.prev.name}`;
|
|
if (prevText.textContent !== newText) {
|
|
prevText.textContent = newText;
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nextLink && data.next) {
|
|
const currentHref = nextLink.href;
|
|
const newHref = data.next.url;
|
|
|
|
if (currentHref !== newHref) {
|
|
nextLink.href = newHref;
|
|
updated = true;
|
|
}
|
|
|
|
const nextImg = nextLink.querySelector('img');
|
|
const nextText = nextLink.querySelector('.webring-text');
|
|
|
|
if (nextImg && data.next.favicon) {
|
|
const newSrc = `${baseUrl}/media/${data.next.favicon}`;
|
|
if (nextImg.src !== newSrc) {
|
|
nextImg.src = newSrc;
|
|
updated = true;
|
|
}
|
|
}
|
|
|
|
if (nextText) {
|
|
const newText = `${data.next.name} →`;
|
|
if (nextText.textContent !== newText) {
|
|
nextText.textContent = newText;
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated) {
|
|
console.debug('Webring updated from API');
|
|
animateUpdate(webringSection);
|
|
}
|
|
|
|
lastUpdateTime = now;
|
|
})
|
|
.catch(error => {
|
|
console.debug('Webring API update failed:', error);
|
|
})
|
|
.finally(() => {
|
|
isUpdating = false;
|
|
});
|
|
}
|
|
|
|
setTimeout(updateWebringFromAPI, 2000);
|
|
|
|
if (webringUpdateInterval) {
|
|
clearInterval(webringUpdateInterval);
|
|
}
|
|
webringUpdateInterval = setInterval(updateWebringFromAPI, 60 * 60 * 1000);
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
setTimeout(updateWebringFromAPI, 1000);
|
|
}
|
|
}, { once: false, passive: true });
|
|
}
|
|
let mainInitialized = false;
|
|
|
|
function initialize() {
|
|
if (mainInitialized) return;
|
|
mainInitialized = true;
|
|
|
|
connect();
|
|
initWebringUpdater();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initialize, { once: true });
|
|
} else {
|
|
initialize();
|
|
}
|
|
|
|
window.addEventListener('beforeunload', disconnect, { once: true });
|
|
window.addEventListener('pagehide', disconnect, { once: true });
|
|
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden) {
|
|
stopHeartbeat();
|
|
stopLastFMCheck();
|
|
} else {
|
|
if (isConnected) {
|
|
startHeartbeat();
|
|
startLastFMCheck();
|
|
|
|
if (clientCountRequestTimeout) {
|
|
clearTimeout(clientCountRequestTimeout);
|
|
}
|
|
clientCountRequestTimeout = setTimeout(() => {
|
|
sendMessage({ type: 'get_client_count' });
|
|
clientCountRequestTimeout = null;
|
|
}, 100);
|
|
} else if (shouldReconnect) {
|
|
connect();
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
window.wsReconnect = connect;
|
|
window.wsDisconnect = disconnect;
|
|
window.wsStatus = () => isConnected;
|
|
window.wsSend = sendMessage;
|
|
|
|
})(); |