// 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 --- });