288 lines
No EOL
13 KiB
JavaScript
288 lines
No EOL
13 KiB
JavaScript
// script.js
|
|
// Copyright (c) 2025 snfsx.xyz
|
|
// Licensed under the MIT License. See https://opensource.org/licenses/MIT
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
if ('scrollRestoration' in history) {
|
|
history.scrollRestoration = 'manual';
|
|
}
|
|
|
|
// Прокручиваем в самый верх
|
|
window.scrollTo(0, 0);
|
|
|
|
// --- Copy TON Address ---
|
|
const copyTonButton = document.getElementById('copy-ton-button');
|
|
const tonAddressElement = document.getElementById('ton-address');
|
|
|
|
// --- Helper Function for execCommand Fallback ---
|
|
function copyUsingExecCommand(textToCopy) {
|
|
let success = false;
|
|
// Create a temporary textarea element
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = textToCopy;
|
|
|
|
// Make it non-editable to avoid focus issues
|
|
textArea.setAttribute('readonly', '');
|
|
|
|
// ** IMPORTANT ** Style to make it invisible but selectable
|
|
// Needs to be in the DOM and selectable to work
|
|
textArea.style.position = 'absolute';
|
|
textArea.style.left = '-9999px'; // Move off-screen
|
|
textArea.style.top = '0';
|
|
textArea.style.opacity = '0'; // Hide visually
|
|
textArea.style.pointerEvents = 'none'; // Prevent interaction
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
// Store original selection and focus
|
|
const currentFocus = document.activeElement;
|
|
const selectedRange = document.getSelection().rangeCount > 0
|
|
? document.getSelection().getRangeAt(0)
|
|
: false;
|
|
|
|
try {
|
|
// Select text inside the textarea
|
|
textArea.select();
|
|
// textArea.setSelectionRange(0, 99999); // For mobile potentially
|
|
|
|
// Attempt to copy
|
|
success = document.execCommand('copy');
|
|
|
|
if (!success) {
|
|
console.warn('document.execCommand("copy") returned false.');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error executing document.execCommand("copy"):', err);
|
|
success = false;
|
|
} finally {
|
|
// Cleanup: remove the temporary element
|
|
document.body.removeChild(textArea);
|
|
|
|
// Restore original selection and focus
|
|
if (selectedRange) {
|
|
const selection = document.getSelection();
|
|
selection.removeAllRanges(); // Clear selection from temp textarea
|
|
selection.addRange(selectedRange); // Restore original selection
|
|
}
|
|
if (currentFocus && typeof currentFocus.focus === 'function') {
|
|
// Only focus if it's focusable
|
|
currentFocus.focus(); // Restore original focus
|
|
}
|
|
}
|
|
|
|
return success;
|
|
}
|
|
// --- End Helper Function ---
|
|
|
|
|
|
if (copyTonButton && tonAddressElement) {
|
|
copyTonButton.addEventListener('click', async () => { // Use async for await
|
|
const address = (copyTonButton.dataset.address || tonAddressElement.textContent || '').trim();
|
|
|
|
if (!address) {
|
|
console.error('Address to copy is empty.');
|
|
alert('Cannot copy: Address is empty.');
|
|
return;
|
|
}
|
|
|
|
let copied = false;
|
|
let copyMethod = ''; // To track which method worked
|
|
|
|
// --- METHOD 1: Try Modern Clipboard API (Requires HTTPS or localhost) ---
|
|
// Check if API exists AND if we are in a secure context
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
try {
|
|
await navigator.clipboard.writeText(address);
|
|
copied = true;
|
|
copyMethod = 'Clipboard API';
|
|
} catch (err) {
|
|
console.warn('navigator.clipboard.writeText failed (maybe permissions?):', err);
|
|
// If it failed, we'll try the fallback method below
|
|
}
|
|
} else {
|
|
console.warn('navigator.clipboard not available or context is not secure (HTTPS/localhost required). Trying fallback.');
|
|
}
|
|
|
|
// --- METHOD 2: Fallback to execCommand (Legacy) ---
|
|
if (!copied) {
|
|
copied = copyUsingExecCommand(address);
|
|
if (copied) {
|
|
copyMethod = 'execCommand';
|
|
}
|
|
}
|
|
|
|
// --- Provide Feedback ---
|
|
if (copied) {
|
|
console.log(`Address copied successfully using: ${copyMethod}`);
|
|
const originalText = copyTonButton.textContent;
|
|
copyTonButton.textContent = 'Copied!';
|
|
copyTonButton.disabled = true; // Disable button temporarily
|
|
setTimeout(() => {
|
|
copyTonButton.textContent = originalText;
|
|
copyTonButton.disabled = false;
|
|
}, 1500);
|
|
} else {
|
|
// If both methods failed
|
|
console.error('Failed to copy address using both Clipboard API and execCommand.');
|
|
alert('Failed to copy address. Please copy manually.');
|
|
}
|
|
});
|
|
} else {
|
|
if (!copyTonButton) console.warn("Element with ID 'copy-ton-button' not found.");
|
|
if (!tonAddressElement) console.warn("Element with ID 'ton-address' not found.");
|
|
}
|
|
// --- End Copy TON Address ---
|
|
|
|
|
|
// --- Last.fm Now Playing ---
|
|
const nowPlayingCover = document.getElementById('now-playing-cover');
|
|
const nowPlayingStatus = document.getElementById('now-playing-status');
|
|
const nowPlayingTitleLink = document.getElementById('now-playing-title-link');
|
|
const nowPlayingTitleText = document.getElementById('now-playing-title');
|
|
const nowPlayingArtist = document.getElementById('now-playing-artist');
|
|
|
|
const placeholderSvg = "data:image/svg+xml,%3Csvg width='80' height='80' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 80'%3E%3Crect width='100%25' height='100%25' fill='%231f3a3a'/%3E%3C/svg%3E";
|
|
|
|
let previousTrackId = null;
|
|
let previousStatus = null; // can be 'playing', 'not_playing', 'error', 'rate_limited'
|
|
|
|
if (!nowPlayingCover || !nowPlayingStatus || !nowPlayingTitleLink || !nowPlayingTitleText || !nowPlayingArtist) {
|
|
console.error("One or more Last.fm elements are missing from the DOM.");
|
|
// Stop execution if elements are missing
|
|
// Consider throwing an error or returning, depending on context
|
|
// For this example, we'll just return to prevent further errors.
|
|
return;
|
|
}
|
|
|
|
// Helper function to set the UI to an error/inactive state
|
|
function setErrorState(message, statusIdentifier = 'error') {
|
|
if (previousStatus !== statusIdentifier) {
|
|
nowPlayingStatus.textContent = message;
|
|
nowPlayingTitleText.textContent = '';
|
|
nowPlayingArtist.textContent = '';
|
|
if (nowPlayingCover.src !== placeholderSvg) {
|
|
nowPlayingCover.src = placeholderSvg;
|
|
}
|
|
nowPlayingCover.alt = 'Error or inactive state';
|
|
nowPlayingCover.classList.add('error'); // Add error class for visual feedback
|
|
|
|
nowPlayingTitleLink.removeAttribute('href');
|
|
nowPlayingTitleLink.classList.add('inactive-link');
|
|
nowPlayingTitleLink.setAttribute('aria-disabled', 'true');
|
|
|
|
previousStatus = statusIdentifier;
|
|
previousTrackId = null;
|
|
}
|
|
}
|
|
|
|
|
|
function updateNowPlaying() {
|
|
fetch('/api/v1/now_playing')
|
|
.then(response => {
|
|
// --- START 429 Handling ---
|
|
if (response.status === 429) {
|
|
// Attempt to parse the JSON body for the custom message
|
|
return response.json().then(errorData => {
|
|
// Use the message from the server if available
|
|
const message = errorData?.message || "Too many requests. Please wait.";
|
|
setErrorState(message, 'rate_limited');
|
|
// Throw an error to skip the rest of the .then chain and go to .catch
|
|
// This signals that we handled this specific error type already
|
|
throw new Error('Rate limit exceeded');
|
|
}).catch(jsonError => {
|
|
// If parsing the 429 JSON fails, use a default message
|
|
console.error("Could not parse 429 response body:", jsonError);
|
|
setErrorState("Too many requests. Please wait.", 'rate_limited');
|
|
throw new Error('Rate limit exceeded'); // Still go to .catch
|
|
});
|
|
}
|
|
// --- END 429 Handling ---
|
|
|
|
if (!response.ok) {
|
|
// Handle other HTTP errors (like 500, 404, etc.)
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
// If response is OK (2xx), parse the JSON
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
// This block only runs if the response was OK (2xx) and not 429
|
|
let currentStatus;
|
|
let currentTrackId = null;
|
|
|
|
nowPlayingCover.classList.remove('error'); // Remove error class if previous state was error
|
|
|
|
// We already know data is not null/undefined if we reached here
|
|
if (data.error) { // Handle application-level errors returned in JSON
|
|
console.error('API Logic Error:', data.error);
|
|
currentStatus = 'error';
|
|
setErrorState(data.error || 'Error fetching data.'); // Use helper
|
|
} else if (data.playing && data.artist && data.name) {
|
|
currentStatus = 'playing';
|
|
currentTrackId = `${data.artist}-${data.name}`;
|
|
} else if (data.playing) {
|
|
console.warn('API returned playing:true, but missing artist or name.');
|
|
currentStatus = 'error';
|
|
setErrorState('Incomplete track data received.'); // Use helper
|
|
} else {
|
|
currentStatus = 'not_playing';
|
|
}
|
|
|
|
// Update UI only if state changed or track changed
|
|
if (currentStatus !== previousStatus || (currentStatus === 'playing' && currentTrackId !== previousTrackId)) {
|
|
|
|
if (currentStatus === 'playing') {
|
|
nowPlayingStatus.textContent = ''; // Clear status text
|
|
nowPlayingTitleText.textContent = data.name;
|
|
nowPlayingArtist.textContent = `by ${data.artist}`;
|
|
|
|
const lastfmUrl = `https://www.last.fm/music/${encodeURIComponent(data.artist)}/_/${encodeURIComponent(data.name)}`;
|
|
nowPlayingTitleLink.href = lastfmUrl;
|
|
nowPlayingTitleLink.classList.remove('inactive-link');
|
|
nowPlayingTitleLink.removeAttribute('aria-disabled');
|
|
|
|
const newCoverSrc = data.image_url || placeholderSvg;
|
|
if (nowPlayingCover.src !== newCoverSrc) {
|
|
nowPlayingCover.src = newCoverSrc;
|
|
}
|
|
nowPlayingCover.alt = data.image_url ? `Album cover for ${data.name} by ${data.artist}` : 'Album cover not available';
|
|
|
|
} else if (currentStatus === 'not_playing') {
|
|
nowPlayingStatus.textContent = data.message || 'Not listening right now.';
|
|
nowPlayingTitleText.textContent = '';
|
|
nowPlayingArtist.textContent = '';
|
|
if (nowPlayingCover.src !== placeholderSvg) {
|
|
nowPlayingCover.src = placeholderSvg;
|
|
}
|
|
nowPlayingCover.alt = 'Nothing playing';
|
|
|
|
nowPlayingTitleLink.removeAttribute('href');
|
|
nowPlayingTitleLink.classList.add('inactive-link');
|
|
nowPlayingTitleLink.setAttribute('aria-disabled', 'true');
|
|
}
|
|
// Error states are handled above or in .catch using setErrorState
|
|
|
|
previousStatus = currentStatus; // Update previous status only on successful processing
|
|
previousTrackId = currentTrackId;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
// Catch network errors, HTTP errors (thrown from !response.ok),
|
|
// and the specific 'Rate limit exceeded' error we threw.
|
|
console.error('Fetch Error:', error);
|
|
|
|
// Avoid setting generic error if it was the rate limit we already handled
|
|
if (error.message !== 'Rate limit exceeded') {
|
|
// Use the helper function for generic network/HTTP errors
|
|
setErrorState('Could not connect or error occurred.');
|
|
}
|
|
// If it *was* 'Rate limit exceeded', the UI is already set by the 429 handler.
|
|
});
|
|
}
|
|
|
|
updateNowPlaying();
|
|
setInterval(updateNowPlaying, 30000);
|
|
// --- End Last.fm Now Playing ---
|
|
|
|
}); |