Spaces:
Running
Running
Download index.html from Lineage11/test-space: direct link, hf CLI and curl.
- Browser
- Download file 32.4 kB
-
https://huggingface.co/spaces/Lineage11/test-space/resolve/main/index.html
- Command line
-
hf download hf://spaces/Lineage11/test-space/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Lineage11/test-space/resolve/main/index.html
32.4 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Live Stream Countdown</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Orbitron:wght@700&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| // Tailwind CSS configuration for custom colors and fonts | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| magenta: '#7e0039', | |
| gold: '#f88d0d', | |
| grey: '#666666', | |
| charcoal: '#333333', | |
| teal: '#00a3a5', | |
| warmgrey: '#8a7b71', | |
| green: '#3d7061', | |
| orange: '#bb3900', | |
| eucalypt: '#909a8a', | |
| sandstone: '#ab8a5c' | |
| }, | |
| fontFamily: { | |
| sans: ['Inter', 'sans-serif'], // Default sans-serif | |
| display: ['Orbitron', 'sans-serif'], // For countdown digits and titles | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| /* Prevent scrollbars caused by absolute/fixed positioned particles */ | |
| body { | |
| overflow: hidden; | |
| /* Dynamic Gradient Background */ | |
| background: linear-gradient(135deg, #7e0039, #333333, #00a3a5); /* Initial gradient */ | |
| background-size: 400% 400%; /* Make gradient larger than viewport */ | |
| /* Changed animation duration to 60s for slower change */ | |
| animation: gradientAnimation 60s ease infinite; /* Animate gradient */ | |
| } | |
| @keyframes gradientAnimation { | |
| 0% { background-position: 0% 50%; } | |
| 50% { background-position: 100% 50%; } | |
| 100% { background-position: 0% 50%; } | |
| } | |
| /* Text glow effect */ | |
| .glow-text { | |
| text-shadow: 0 0 10px rgba(248, 141, 13, 0.7); | |
| } | |
| /* Pulse animation for the join button */ | |
| .pulse { | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| 100% { transform: scale(1); } | |
| } | |
| /* Modal styles for showing/hiding with transitions */ | |
| .modal { | |
| transition: all 0.3s ease; | |
| background-color: rgba(0,0,0,0); /* Start transparent overlay */ | |
| opacity: 0; | |
| pointer-events: none; /* Don't block clicks when hidden */ | |
| } | |
| .modal.active { | |
| opacity: 1; | |
| background-color: rgba(0,0,0,0.8); /* Dark overlay when active */ | |
| pointer-events: auto; /* Allow clicks when active */ | |
| } | |
| .modal-content { | |
| transform: translateY(-20px); /* Start slightly up */ | |
| opacity: 0; | |
| transition: all 0.3s ease; | |
| } | |
| .modal.active .modal-content { | |
| transform: translateY(0); /* Slide into place */ | |
| opacity: 1; | |
| } | |
| /* Ripple Effect Styles */ | |
| /* Apply to the clickable elements within the countdown */ | |
| .countdown-digit { | |
| position: relative; /* Needed for ripple positioning */ | |
| overflow: hidden; /* Hide ripple outside the element */ | |
| cursor: pointer; /* Indicate interactivity */ | |
| transform: translateZ(0); /* Improve performance */ | |
| } | |
| .ripple { | |
| position: absolute; | |
| border-radius: 50%; | |
| transform: scale(0); | |
| animation: ripple 0.6s linear; | |
| background-color: rgba(255, 255, 255, 0.5); /* White ripple with transparency */ | |
| pointer-events: none; /* Don't interfere with clicks */ | |
| } | |
| @keyframes ripple { | |
| to { | |
| transform: scale(4); | |
| opacity: 0; | |
| } | |
| } | |
| /* Particle Styles */ | |
| /* Container to hold particles and position them */ | |
| .particles-container { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; /* Don't block interaction with content */ | |
| z-index: -1; /* Place behind content */ | |
| } | |
| /* Individual particle style */ | |
| .particle { | |
| position: absolute; | |
| width: 5px; | |
| height: 5px; | |
| background-color: rgba(248, 141, 13, 0.6); /* Gold color with transparency */ | |
| border-radius: 50%; | |
| opacity: 0.8; | |
| /* Initial animation (will be overridden by JS for random movement) */ | |
| animation: float 15s ease-in-out infinite alternate; | |
| } | |
| /* Add variations for different particles */ | |
| .particle:nth-child(2n) { | |
| width: 3px; | |
| height: 3px; | |
| background-color: rgba(0, 163, 165, 0.6); /* Teal color */ | |
| animation-duration: 20s; | |
| } | |
| .particle:nth-child(3n) { | |
| width: 7px; | |
| height: 7px; | |
| background-color: rgba(138, 123, 113, 0.6); /* Warmgrey color */ | |
| animation-duration: 25s; | |
| } | |
| .particle:nth-child(4n) { | |
| width: 4px; | |
| height: 4px; | |
| background-color: rgba(171, 138, 92, 0.6); /* Sandstone color */ | |
| animation-duration: 18s; | |
| } | |
| /* Basic float keyframes (used as a fallback/starting point) */ | |
| @keyframes float { | |
| 0% { transform: translate(0, 0); } | |
| 100% { transform: translate(50px, 50px); } /* Dummy values, JS will set random ones */ | |
| } | |
| /* Progress Bar Styles */ | |
| .progress-bar-container { | |
| width: 80%; /* Adjust width as needed */ | |
| max-width: 600px; | |
| height: 15px; | |
| background-color: rgba(102, 102, 102, 0.3); /* Grey with transparency */ | |
| border-radius: 8px; | |
| margin: 20px auto; /* Center the bar */ | |
| overflow: hidden; /* Hide overflowing progress */ | |
| } | |
| .progress-bar { | |
| height: 100%; | |
| width: 100%; /* Starts full */ | |
| background: linear-gradient(to right, #00a3a5, #f88d0d); /* Teal to Gold gradient */ | |
| border-radius: 8px; | |
| transition: width 1s linear; /* Smooth transition for the bar */ | |
| } | |
| /* Countdown Digit Animation (Subtle Scale) */ | |
| .countdown-digit > div { /* Target the inner div with the number */ | |
| transition: transform 0.3s ease-out; /* Smooth transition */ | |
| } | |
| /* Optional: Add a class in JS to trigger animation on update */ | |
| .countdown-digit > div.animate-scale { | |
| transform: scale(1.1); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-charcoal text-white min-h-screen flex flex-col font-sans"> | |
| <div class="particles-container"> | |
| <div class="particle" style="top: 10%; left: 20%;"></div> | |
| <div class="particle" style="top: 50%; left: 80%;"></div> | |
| <div class="particle" style="top: 80%; left: 40%;"></div> | |
| <div class="particle" style="top: 30%; left: 60%;"></div> | |
| <div class="particle" style="top: 70%; left: 10%;"></div> | |
| <div class="particle" style="top: 20%; left: 90%;"></div> | |
| <div class="particle" style="top: 60%; left: 30%;"></div> | |
| <div class="particle" style="top: 40%; left: 70%;"></div> | |
| <div class="particle" style="top: 90%; left: 50%;"></div> | |
| <div class="particle" style="top: 5%; left: 5%;"></div> | |
| <div class="particle" style="top: 95%; left: 95%;"></div> | |
| <div class="particle" style="top: 15%; left: 75%;"></div> | |
| <div class="particle" style="top: 85%; left: 25%;"></div> | |
| </div> | |
| <button id="adminBtn" class="fixed top-4 right-4 bg-magenta hover:bg-opacity-80 text-white p-2 rounded-full shadow-lg z-50 transition duration-300 ease-in-out transform hover:scale-110" title="Admin Settings"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| </svg> | |
| </button> | |
| <main class="flex-grow flex flex-col items-center justify-center px-4 text-center relative z-10"> | |
| <div class="max-w-4xl w-full"> | |
| <h1 class="text-4xl md:text-6xl font-display mb-8 glow-text text-gold">Live Stream Starting Soon</h1> | |
| <div class="bg-opacity-20 bg-magenta backdrop-filter backdrop-blur-lg rounded-xl p-8 mb-12 border border-teal border-opacity-30"> | |
| <h2 class="text-2xl md:text-3xl font-display font-semibold mb-6 text-eucalypt">Join us for an exciting event</h2> | |
| <p class="text-lg text-warmgrey mb-8">The stream will begin automatically when the countdown reaches zero.</p> | |
| <p id="preStreamMessage" class="text-xl font-bold text-orange mb-8 hidden">Stream starting in less than 5 minutes!</p> | |
| <div class="flex justify-center mb-8"> | |
| <div id="countdown" class="grid grid-cols-4 gap-4 w-full max-w-2xl font-display"> | |
| <div class="bg-charcoal bg-opacity-70 rounded-lg p-4 border border-gold border-opacity-30 countdown-digit"> | |
| <div id="days" class="text-5xl md:text-7xl font-bold text-gold">00</div> | |
| <div class="text-sm uppercase text-sandstone">Days</div> | |
| </div> | |
| <div class="bg-charcoal bg-opacity-70 rounded-lg p-4 border border-gold border-opacity-30 countdown-digit"> | |
| <div id="hours" class="text-5xl md:text-7xl font-bold text-gold">00</div> | |
| <div class="text-sm uppercase text-sandstone">Hours</div> | |
| </div> | |
| <div class="bg-charcoal bg-opacity-70 rounded-lg p-4 border border-gold border-opacity-30 countdown-digit"> | |
| <div id="minutes" class="text-5xl md:text-7xl font-bold text-gold">00</div> | |
| <div class="text-sm uppercase text-sandstone">Minutes</div> | |
| </div> | |
| <div class="bg-charcoal bg-opacity-70 rounded-lg p-4 border border-gold border-opacity-30 countdown-digit"> | |
| <div id="seconds" class="text-5xl md:text-7xl font-bold text-gold">00</div> | |
| <div class="text-sm uppercase text-sandstone">Seconds</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="progress-bar-container"> | |
| <div id="progressBar" class="progress-bar"></div> | |
| </div> | |
| <div class="mt-8"> | |
| <a href="recent-streams.html" class="inline-block bg-teal hover:bg-opacity-80 text-white font-bold py-3 px-6 rounded-full text-lg transition-all transform hover:scale-105"> | |
| View Recent Streams | |
| </a> | |
| </div> | |
| <div id="streamReady" class="hidden pulse"> | |
| <a id="streamLink" href="#" target="_blank" class="inline-block bg-orange hover:bg-opacity-90 text-white font-bold py-4 px-8 rounded-full text-xl transition-all transform hover:scale-105"> | |
| Join Live Stream Now | |
| </a> | |
| </div> | |
| </div> | |
| <div class="text-grey"> | |
| <p class="mt-2 text-sm">Stream will begin on: <span id="streamTimeDisplay" class="font-medium text-teal">Loading...</span> <span id="timezoneDisplay" class="font-medium text-eucalypt text-xs">(Your Local Time)</span></p> | |
| <p id="errorDisplay" class="mt-2 text-sm text-orange font-semibold"></p> </div> | |
| </div> | |
| </main> | |
| <footer class="py-6 text-center text-warmgrey text-sm relative z-10"> | |
| <p>© <span id="currentYear"></span> Live Stream Countdown. All rights reserved. | <a href="recent-streams.html" class="text-teal hover:text-eucalypt transition duration-300">Recent Streams</a></p> | |
| </footer> | |
| <div id="adminModal" class="modal fixed inset-0 flex items-center justify-center p-4 z-50"> | |
| <div class="modal-content bg-charcoal rounded-xl max-w-md w-full p-6 border border-magenta border-opacity-50 text-center"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-bold text-gold">Admin Settings</h3> | |
| <button id="closeModal" class="text-grey hover:text-white"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <p class="mb-4 text-warmgrey text-left"> | |
| This countdown is controlled by a Google Sheet. To update the stream date, time, or URL, you need to edit the sheet directly. | |
| </p> | |
| <p class="mb-4 text-orange text-sm text-left font-semibold"> | |
| Remember: After editing the sheet, you MUST re-publish it to the web (as CSV) for changes to appear here on the page. | |
| </p> | |
| <a id="editSheetLink" href="https://docs.google.com/spreadsheets/d/1XQ8ZDLZvU9lz4kNu9Zn3SXBIuIwniuLG4pVEfk_5NkU/edit?gid=1249364277#gid=1249364277" target="_blank" class="inline-block w-full bg-teal hover:bg-opacity-80 text-white py-3 rounded font-bold transition duration-300 ease-in-out transform hover:scale-105"> | |
| Edit Google Sheet | |
| </a> | |
| <div id="messageArea" class="mt-4 text-center text-sm"></div> | |
| </div> | |
| </div> | |
| <audio id="countdownEndSound" src="https://github.com/selli106/avlink/raw/refs/heads/main/ding-36029.mp3" preload="auto"></audio> | |
| <script> | |
| // --- Configuration --- | |
| // Your Google Sheet Published CSV URL | |
| // (File > Share > Publish to web > Choose sheet & CSV > Publish) | |
| // Make sure the sheet contains rows with 'key,value' like 'countdown_date,YYYY-MM-DDTHH:mm:ssZ' and 'stream_url,your_url_here' | |
| const sheetCsvUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vSy-8e3C5trnUBXhMnpHxOmWEqB61875RPY0nCIIGCpw3UnU9SK31dt8iqqYzgeUziBkAWdo5qQ8wnd/pub?output=csv"; | |
| // Your Google Sheet Edit URL | |
| // (The standard URL from your browser's address bar when editing) | |
| const googleSheetEditUrl = "https://docs.google.com/spreadsheets/d/1XQ8ZDLZvU9lz4kNu9Zn3SXBIuIwniuLG4pVEfk_5NkU/edit?gid=1249364277#gid=1249364277"; | |
| // How often to fetch settings from the sheet (in milliseconds) | |
| const fetchInterval = 60000; // 1 minute | |
| // Threshold for pre-stream message (in milliseconds) | |
| const preStreamThreshold = 5 * 60 * 1000; // 5 minutes | |
| // --- DOM Elements --- | |
| const adminBtn = document.getElementById('adminBtn'); | |
| const adminModal = document.getElementById('adminModal'); | |
| const closeModal = document.getElementById('closeModal'); | |
| const editSheetLink = document.getElementById('editSheetLink'); | |
| const messageArea = document.getElementById('messageArea'); // Used in modal for fetch status | |
| const errorDisplay = document.getElementById('errorDisplay'); // Used below countdown for fetch errors | |
| const countdownDisplay = document.getElementById('countdown'); | |
| const streamReady = document.getElementById('streamReady'); | |
| const streamLink = document.getElementById('streamLink'); | |
| const streamTimeDisplay = document.getElementById('streamTimeDisplay'); | |
| const timezoneDisplay = document.getElementById('timezoneDisplay'); | |
| const currentYear = document.getElementById('currentYear'); | |
| const daysElement = document.getElementById('days'); | |
| const hoursElement = document.getElementById('hours'); | |
| const minutesElement = document.getElementById('minutes'); | |
| const secondsElement = document.getElementById('seconds'); | |
| // Elements for new features | |
| const preStreamMessageElement = document.getElementById('preStreamMessage'); | |
| const countdownEndSound = document.getElementById('countdownEndSound'); | |
| const progressBar = document.getElementById('progressBar'); | |
| // Elements for effects | |
| const countdownDigits = document.querySelectorAll('.countdown-digit'); | |
| const particles = document.querySelectorAll('.particle'); | |
| // --- State Variables --- | |
| let countdownInterval; | |
| let currentStreamTargetUrl = '#'; // Default URL | |
| let currentCountdownDateString = null; // Store the date string from the sheet | |
| let initialCountdownDuration = 0; // Store the total duration for the progress bar | |
| // --- Functions --- | |
| // Fetches settings from the Google Sheet CSV | |
| async function fetchSettings(csvUrl) { | |
| try { | |
| // Clear previous errors | |
| errorDisplay.textContent = ''; | |
| const response = await fetch(csvUrl); | |
| if (!response.ok) { | |
| // Attempt to read body for more specific error if possible, otherwise use status | |
| const errorBody = await response.text().catch(() => `Status: ${response.status}`); | |
| throw new Error(`HTTP error fetching sheet: ${response.status} - ${errorBody.substring(0, 100)}...`); // Limit error body length | |
| } | |
| const data = await response.text(); | |
| const rows = data.split("\n").map(row => row.split(",").map(cell => cell.trim())); // Split rows and cells, trim whitespace | |
| let dateString = null; | |
| let urlString = null; | |
| // Parse rows to find settings | |
| for (const row of rows) { | |
| if (row.length >= 2) { // Ensure row has at least key and value columns | |
| const key = row[0]; | |
| const value = row[1]; | |
| if (key === "countdown_date") { | |
| dateString = value; | |
| } else if (key === "stream_url") { | |
| urlString = value; | |
| } | |
| } | |
| } | |
| if (dateString && urlString) { | |
| // Only update if both values are found and are different from current settings | |
| if (dateString !== currentCountdownDateString || urlString !== currentStreamTargetUrl) { | |
| currentCountdownDateString = dateString; | |
| currentStreamTargetUrl = urlString; | |
| console.log("Settings fetched successfully:", { date: currentCountdownDateString, url: currentStreamTargetUrl }); | |
| // Restart countdown with new settings | |
| startCountdown(currentCountdownDateString, currentStreamTargetUrl); | |
| } else { | |
| console.log("Settings fetched, no changes detected."); | |
| // Continue the existing countdown if no changes | |
| } | |
| } else { | |
| errorDisplay.textContent = 'Error: Settings (countdown_date or stream_url) not found in Google Sheet.'; | |
| console.error("Missing required settings (countdown_date or stream_url) in Google Sheet CSV data."); | |
| // Indicate error to user and stop countdown if essential data is missing | |
| countdownDisplay.textContent = "Error loading settings."; | |
| clearInterval(countdownInterval); | |
| streamReady.classList.add('hidden'); | |
| preStreamMessageElement.classList.add('hidden'); // Hide pre-stream message | |
| progressBar.style.width = '0%'; // Reset progress bar | |
| } | |
| } catch (error) { | |
| errorDisplay.textContent = 'Error fetching settings. Check sheet URL and publish status.'; | |
| console.error("Failed to fetch settings from Google Sheet:", error); | |
| // Indicate error to user and stop countdown on fetch error | |
| countdownDisplay.textContent = "Error fetching settings."; | |
| clearInterval(countdownInterval); | |
| streamReady.classList.add('hidden'); | |
| preStreamMessageElement.classList.add('hidden'); // Hide pre-stream message | |
| progressBar.style.width = '0%'; // Reset progress bar | |
| } | |
| } | |
| // Starts or updates the countdown timer | |
| function startCountdown(dateString, streamTargetUrl) { | |
| // Clear any existing interval before starting a new one | |
| clearInterval(countdownInterval); | |
| // --- Debugging Log --- | |
| console.log("Parsing date string:", dateString); | |
| const targetDate = new Date(dateString); | |
| console.log("Parsed Date object:", targetDate); | |
| const targetTimestamp = targetDate.getTime(); | |
| console.log("Target Timestamp (ms):", targetTimestamp); | |
| // --- End Debugging Log --- | |
| // Check if the parsed date is valid | |
| if (isNaN(targetTimestamp)) { | |
| errorDisplay.textContent = `Error: Invalid date format from sheet: ${dateString}`; | |
| console.error("Invalid date string received from Google Sheet:", dateString); | |
| countdownDisplay.textContent = "Error loading settings."; | |
| clearInterval(countdownInterval); | |
| streamReady.classList.add('hidden'); | |
| preStreamMessageElement.classList.add('hidden'); // Hide pre-stream message | |
| progressBar.style.width = '0%'; // Reset progress bar | |
| streamTimeDisplay.textContent = "Invalid Date"; | |
| timezoneDisplay.textContent = ""; | |
| return; // Stop execution if date is invalid | |
| } | |
| // Calculate the initial duration for the progress bar | |
| const now = new Date().getTime(); | |
| initialCountdownDuration = targetTimestamp - now; | |
| // Prevent negative duration if date is in the past | |
| if (initialCountdownDuration < 0) { | |
| initialCountdownDuration = 0; | |
| } | |
| // Update display immediately | |
| updateCountdown(targetTimestamp, streamTargetUrl); | |
| // Set interval to update every second | |
| countdownInterval = setInterval(() => { | |
| updateCountdown(targetTimestamp, streamTargetUrl); | |
| }, 1000); | |
| // Update the display below the countdown with the target time | |
| const options = { | |
| weekday: 'long', | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| timeZoneName: 'short' // Show the user's local timezone abbreviation | |
| }; | |
| // Use the Date object created from the string for consistent display | |
| // Note: This displays the target moment converted to the *user's* local time zone. | |
| try { | |
| streamTimeDisplay.textContent = targetDate.toLocaleString(undefined, options); // Use the parsed Date object | |
| timezoneDisplay.textContent = "(Your Local Time)"; // Indicate this is local time | |
| } catch (e) { | |
| // Handle potential invalid date string parsing errors (less likely now with isNaN check) | |
| streamTimeDisplay.textContent = `Error formatting date: ${dateString}`; | |
| timezoneDisplay.textContent = ""; | |
| console.error("Error formatting date string:", dateString, e); | |
| } | |
| // Ensure the countdown display is visible if the date is in the future | |
| if (targetTimestamp > new Date().getTime()) { | |
| countdownDisplay.classList.remove('hidden'); // Make sure grid is visible | |
| // Ensure individual elements are visible if the parent was hidden | |
| if(daysElement) daysElement.parentElement.classList.remove('hidden'); | |
| if(hoursElement) hoursElement.parentElement.classList.remove('hidden'); | |
| if(minutesElement) minutesElement.parentElement.classList.remove('hidden'); | |
| if(secondsElement) secondsElement.parentElement.classList.remove('hidden'); | |
| streamReady.classList.add('hidden'); | |
| } | |
| } | |
| // Updates the countdown display and handles pre-stream message/progress bar | |
| function updateCountdown(targetTimestamp, streamTargetUrl) { // Accepts timestamp now | |
| const now = new Date().getTime(); | |
| const distance = targetTimestamp - now; | |
| // --- Progress Bar Update --- | |
| let progressPercentage = 100; // Start full | |
| if (initialCountdownDuration > 0) { | |
| progressPercentage = Math.max(0, (distance / initialCountdownDuration) * 100); | |
| } | |
| progressBar.style.width = `${progressPercentage}%`; | |
| if (distance < 0) { | |
| // Stream time has passed | |
| clearInterval(countdownInterval); | |
| countdownDisplay.classList.add('hidden'); // Hide the grid container | |
| streamReady.classList.remove('hidden'); // Show the join button | |
| preStreamMessageElement.classList.add('hidden'); // Hide pre-stream message | |
| // Ensure stream link is correctly set one last time | |
| streamLink.href = streamTargetUrl; | |
| streamLink.target = "_blank"; // Ensure it opens in a new tab | |
| streamTimeDisplay.textContent = "Stream should be live now!"; | |
| timezoneDisplay.textContent = ""; // Clear timezone note | |
| errorDisplay.textContent = ""; // Clear any lingering error messages | |
| // Play sound effect (if loaded and allowed by browser) | |
| if (countdownEndSound) { | |
| countdownEndSound.play().catch(e => console.warn("Audio playback blocked:", e)); | |
| } | |
| // Optional: Automatically redirect after a delay or on user interaction | |
| // For automatic redirect, use: window.open(streamTargetUrl, "_blank"); | |
| // Added target="_blank" above to the link itself which is usually sufficient. | |
| return; | |
| } | |
| // --- Pre-stream Message --- | |
| if (distance <= preStreamThreshold) { | |
| preStreamMessageElement.classList.remove('hidden'); | |
| } else { | |
| preStreamMessageElement.classList.add('hidden'); | |
| } | |
| // Calculate time units | |
| const days = Math.floor(distance / (1000 * 60 * 60 * 24)); | |
| const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); | |
| const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); | |
| const seconds = Math.floor((distance % (1000 * 60)) / 1000); | |
| // Update display elements, using ?.textContent for safety just in case | |
| // Add a temporary class to trigger animation | |
| const updateDigit = (element, value) => { | |
| const stringValue = value.toString().padStart(2, '0'); | |
| if (element && element.textContent !== stringValue) { | |
| element.textContent = stringValue; | |
| // Trigger animation class | |
| element.classList.add('animate-scale'); | |
| // Remove class after animation duration | |
| setTimeout(() => { | |
| element.classList.remove('animate-scale'); | |
| }, 300); // Match CSS transition duration | |
| } else if (element) { | |
| element.textContent = stringValue; // Update even if value is same, just in case | |
| } | |
| }; | |
| updateDigit(daysElement, days); | |
| updateDigit(hoursElement, hours); | |
| updateDigit(minutesElement, minutes); | |
| updateDigit(secondsElement, seconds); | |
| // Ensure countdown elements are visible if they were hidden after countdown ended previously | |
| countdownDisplay.classList.remove('hidden'); | |
| streamReady.classList.add('hidden'); | |
| // Also make sure the individual number divs are not hidden if you added display:none to them | |
| if(daysElement && daysElement.parentElement.classList.contains('hidden')) daysElement.parentElement.classList.remove('hidden'); | |
| if(hoursElement && hoursElement.parentElement.classList.contains('hidden')) hoursElement.parentElement.classList.remove('hidden'); | |
| if(minutesElement && minutesElement.parentElement.classList.contains('hidden')) minutesElement.parentElement.classList.remove('hidden'); | |
| if(secondsElement && secondsElement.parentElement.classList.contains('hidden')) secondsElement.parentElement.classList.remove('hidden'); | |
| } | |
| // --- Effects Functions --- | |
| // Create ripple effect on countdown digits | |
| function createRipple(event) { | |
| const element = event.currentTarget; | |
| const circle = document.createElement('span'); | |
| const diameter = Math.max(element.clientWidth, element.clientHeight); | |
| const radius = diameter / 2; | |
| circle.style.width = circle.style.height = `${diameter}px`; | |
| circle.style.left = `${event.clientX - element.getBoundingClientRect().left - radius}px`; | |
| circle.style.top = `${event.clientY - element.getBoundingClientRect().top - radius}px`; | |
| circle.classList.add('ripple'); | |
| // Remove any existing ripple before adding a new one | |
| const ripple = element.getElementsByClassName('ripple')[0]; | |
| if (ripple) { | |
| ripple.remove(); | |
| } | |
| element.appendChild(circle); | |
| } | |
| // Animate floating particles | |
| function animateParticles() { | |
| // Check if particles exist before trying to animate | |
| if (particles.length === 0) return; | |
| particles.forEach(particle => { | |
| // Generate random target position within a reasonable range | |
| // Adjust the multiplier (e.g., 200, 300) to control particle movement range | |
| const x = (Math.random() - 0.5) * 200; // Random value between -100 and 100 | |
| const y = (Math.random() - 0.5) * 200; // Random value between -100 and 100 | |
| const duration = 15 + Math.random() * 15; // Random duration between 15s and 30s | |
| // Apply the transition | |
| particle.style.transition = `transform ${duration}s ease-in-out`; | |
| particle.style.transform = `translate(${x}px, ${y}px)`; | |
| }); | |
| // Schedule the next animation cycle | |
| // Use a timeout slightly longer | |
| </script> | |
| </body> | |
| </html> |