Update 2025-05-21_08:58:06

This commit is contained in:
oib
2025-05-21 08:58:07 +02:00
parent 1011f58d00
commit 39934115a1
28 changed files with 2166 additions and 672 deletions

View File

@ -1,27 +1,52 @@
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
import { playBeep } from "./sound.js";
// 🔔 Minimal toast helper so calls to showToast() dont fail
function showToast(msg) {
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = msg;
toast.style.position = "fixed";
toast.style.bottom = "1.5rem";
toast.style.left = "50%";
toast.style.transform = "translateX(-50%)";
toast.style.background = "#333";
toast.style.color = "#fff";
toast.style.padding = "0.6em 1.2em";
toast.style.borderRadius = "6px";
toast.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)";
toast.style.zIndex = 9999;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
import { playBeep } from "./sound.js";
import { showToast } from "./toast.js";
// Log debug messages to server
export function logToServer(msg) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/log", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ msg }));
}
// Expose for debugging
window.logToServer = logToServer;
// Handle magic link login redirect
(function handleMagicLoginRedirect() {
const params = new URLSearchParams(window.location.search);
if (params.get('login') === 'success' && params.get('confirmed_uid')) {
const username = params.get('confirmed_uid');
localStorage.setItem('uid', username);
logToServer(`[DEBUG] localStorage.setItem('uid', '${username}')`);
localStorage.setItem('confirmed_uid', username);
logToServer(`[DEBUG] localStorage.setItem('confirmed_uid', '${username}')`);
const uidTime = Date.now().toString();
localStorage.setItem('uid_time', uidTime);
logToServer(`[DEBUG] localStorage.setItem('uid_time', '${uidTime}')`);
// Set uid as cookie for backend authentication
document.cookie = "uid=" + encodeURIComponent(username) + "; path=/";
// Remove query params from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Reload to show dashboard as logged in
location.reload();
return;
}
})();
document.addEventListener("DOMContentLoaded", () => {
// (Removed duplicate logToServer definition)
// Guest vs. logged-in toggling is now handled by dashboard.js
// --- Public profile view logic ---
function showProfilePlayerFromUrl() {
@ -43,373 +68,303 @@ document.addEventListener("DOMContentLoaded", () => {
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
const meDesc = document.querySelector("#me-page p");
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
// Load playlist for the given profileUid
loadProfilePlaylist(profileUid);
// Show a Play Stream button for explicit user action
const streamInfo = document.getElementById("stream-info");
if (streamInfo) {
streamInfo.innerHTML = "";
const playBtn = document.createElement('button');
playBtn.textContent = "▶ Play Stream";
playBtn.onclick = () => {
loadProfileStream(profileUid);
playBtn.disabled = true;
};
streamInfo.appendChild(playBtn);
streamInfo.hidden = false;
}
// Do NOT call loadProfileStream(profileUid) automatically!
}
}
}
// Run on popstate (SPA navigation and browser back/forward)
window.addEventListener('popstate', showProfilePlayerFromUrl);
async function loadProfilePlaylist(uid) {
const meAudio = document.getElementById("me-audio");
if (!meAudio) return;
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
const data = await resp.json();
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
meAudio.src = "";
return;
}
// Shuffle playlist
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
window.mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
window.mePlaylistIdx = 0;
const newSrc = window.mePlaylist[window.mePlaylistIdx];
meAudio.src = newSrc;
meAudio.load();
meAudio.play().catch(() => {/* autoplay may be blocked, ignore */});
// --- Only run showProfilePlayerFromUrl after session/profile checks are complete ---
function runProfilePlayerIfSessionValid() {
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
showProfilePlayerFromUrl();
}
window.loadProfilePlaylist = loadProfilePlaylist;
document.addEventListener("DOMContentLoaded", () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.showProfilePlayerFromUrl = showProfilePlayerFromUrl;
// --- Playlist for #me-page ---
const mePageLink = document.getElementById("show-me");
const meAudio = document.getElementById("me-audio");
const copyUrlBtn = document.getElementById("copy-url");
if (copyUrlBtn) copyUrlBtn.onclick = () => {
const uid = localStorage.getItem("uid");
if (uid) {
const streamUrl = `${window.location.origin}/stream/${encodeURIComponent(uid)}`;
navigator.clipboard.writeText(streamUrl);
showToast(`Copied your stream URL: ${streamUrl}`);
} else {
showToast("No user stream URL available");
// Global audio state
let globalAudio = null;
let currentStreamUid = null;
let audioPlaying = false;
let lastPosition = 0;
// Expose main audio element for other scripts
window.getMainAudio = () => globalAudio;
window.stopMainAudio = () => {
if (globalAudio) {
globalAudio.pause();
audioPlaying = false;
updatePlayPauseButton();
}
};
let mePlaylist = [];
let mePlaylistIdx = 0;
// Playlist UI is hidden, so do not render
function getOrCreateAudioElement() {
if (!globalAudio) {
globalAudio = document.getElementById('me-audio');
if (!globalAudio) {
console.error('Audio element not found');
return null;
}
// Set up audio element properties
globalAudio.preload = 'metadata'; // Preload metadata for better performance
globalAudio.crossOrigin = 'use-credentials'; // Use credentials for authenticated requests
globalAudio.setAttribute('crossorigin', 'use-credentials'); // Explicitly set the attribute
// Set up event listeners
globalAudio.addEventListener('play', () => {
audioPlaying = true;
updatePlayPauseButton();
});
globalAudio.addEventListener('pause', () => {
audioPlaying = false;
updatePlayPauseButton();
});
globalAudio.addEventListener('timeupdate', () => lastPosition = globalAudio.currentTime);
// Add error handling
globalAudio.addEventListener('error', (e) => {
console.error('Audio error:', e);
showToast('❌ Audio playback error');
});
}
return globalAudio;
}
// Function to update play/pause button state
function updatePlayPauseButton() {
const audio = getOrCreateAudioElement();
if (playPauseButton && audio) {
playPauseButton.textContent = audio.paused ? '▶' : '⏸️';
}
}
// Initialize play/pause button
const playPauseButton = document.getElementById('play-pause');
if (playPauseButton) {
// Set initial state
updatePlayPauseButton();
// Add click handler
playPauseButton.addEventListener('click', () => {
const audio = getOrCreateAudioElement();
if (audio) {
if (audio.paused) {
// Stop any playing public streams first
const publicPlayers = document.querySelectorAll('.stream-player audio');
publicPlayers.forEach(player => {
if (!player.paused) {
player.pause();
const button = player.closest('.stream-player').querySelector('.play-pause');
if (button) {
button.textContent = '▶';
}
}
});
audio.play().catch(e => {
console.error('Play failed:', e);
audioPlaying = false;
});
} else {
audio.pause();
}
updatePlayPauseButton();
}
});
}
// Preload audio without playing it
function preloadAudio(src) {
return new Promise((resolve) => {
const audio = new Audio();
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
audio.src = src;
audio.load();
audio.oncanplaythrough = () => resolve(audio);
});
}
// Load and play a stream
async function loadProfileStream(uid) {
const audio = getOrCreateAudioElement();
if (!audio) return null;
// Always reset current stream and update audio source
currentStreamUid = uid;
audio.pause();
audio.src = '';
// Wait a moment to ensure the previous source is cleared
await new Promise(resolve => setTimeout(resolve, 50));
// Set new source with cache-busting timestamp
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
// Try to play immediately
try {
await audio.play();
audioPlaying = true;
} catch (e) {
console.error('Play failed:', e);
audioPlaying = false;
}
// Show stream info
const streamInfo = document.getElementById("stream-info");
if (streamInfo) streamInfo.hidden = false;
// Update button state
updatePlayPauseButton();
return audio;
}
// Load and play a stream
async function loadProfileStream(uid) {
const audio = getOrCreateAudioElement();
if (!audio) return null;
// Hide playlist controls
const mePrevBtn = document.getElementById("me-prev");
if (mePrevBtn) mePrevBtn.style.display = "none";
const meNextBtn = document.getElementById("me-next");
if (meNextBtn) meNextBtn.style.display = "none";
async function loadUserPlaylist() {
const uid = localStorage.getItem("uid");
if (!uid) return;
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
const data = await resp.json();
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
meAudio.src = "";
return;
}
// Shuffle playlist
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
mePlaylistIdx = 0;
const newSrc = mePlaylist[mePlaylistIdx];
const prevSrc = meAudio.src;
const wasPlaying = !meAudio.paused && !meAudio.ended && meAudio.currentTime > 0;
const fullNewSrc = window.location.origin + newSrc;
if (prevSrc !== fullNewSrc) {
meAudio.src = newSrc;
meAudio.load();
} // else: do nothing, already loaded
// Don't call load() if already playing the correct file
// Don't call load() redundantly
// Don't set src redundantly
// This prevents DOMException from fetch aborts
}
// Handle navigation to "Your Stream"
const mePageLink = document.getElementById("show-me");
if (mePageLink) {
mePageLink.addEventListener("click", async (e) => {
e.preventDefault();
const uid = localStorage.getItem("uid");
if (!uid) return;
if (mePageLink && meAudio) {
mePageLink.addEventListener("click", async () => {
await loadUserPlaylist();
// Start playback from current index
if (mePlaylist.length > 0) {
const newSrc = mePlaylist[mePlaylistIdx];
const prevSrc = meAudio.src;
const fullNewSrc = window.location.origin + newSrc;
if (prevSrc !== fullNewSrc) {
meAudio.src = newSrc;
meAudio.load();
// Show loading state
const streamInfo = document.getElementById("stream-info");
if (streamInfo) {
streamInfo.hidden = false;
streamInfo.innerHTML = '<p>Loading stream...</p>';
}
try {
// Load the stream but don't autoplay
await loadProfileStream(uid);
// Update URL without triggering a full page reload
if (window.location.pathname !== '/') {
window.history.pushState({}, '', '/');
}
meAudio.play();
}
});
meAudio.addEventListener("ended", () => {
if (mePlaylist.length > 1) {
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
} else if (mePlaylist.length === 1) {
// Only one file: restart
meAudio.currentTime = 0;
meAudio.load();
meAudio.play();
}
});
// Detect player stop and random play a new track
meAudio.addEventListener("pause", () => {
// Only trigger if playback reached the end and playlist has more than 1 track
if (meAudio.ended && mePlaylist.length > 1) {
let nextIdx;
do {
nextIdx = Math.floor(Math.random() * mePlaylist.length);
} while (nextIdx === mePlaylistIdx && mePlaylist.length > 1);
mePlaylistIdx = nextIdx;
meAudio.currentTime = 0;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
// Show the me-page section
const mePage = document.getElementById('me-page');
if (mePage) {
document.querySelectorAll('main > section').forEach(s => s.hidden = s.id !== 'me-page');
}
// Clear loading state
const streamInfo = document.getElementById('stream-info');
if (streamInfo) {
streamInfo.innerHTML = '';
}
} catch (error) {
console.error('Error loading stream:', error);
const streamInfo = document.getElementById('stream-info');
if (streamInfo) {
streamInfo.innerHTML = '<p>Error loading stream. Please try again.</p>';
}
}
});
}
const deleteBtn = document.getElementById("delete-account");
if (deleteBtn) deleteBtn.onclick = async () => {
if (!confirm("Are you sure you want to delete your account and all uploaded audio?")) return;
const res = await fetch("/delete-account", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uid })
});
if (res.ok) {
showToast("✅ Account deleted");
localStorage.removeItem("uid");
setTimeout(() => window.location.reload(), 2000);
} else {
const msg = (await res.json()).detail || res.status;
showToast("❌ Delete failed: " + msg);
}
};
// Always reset current stream and update audio source
currentStreamUid = uid;
audio.pause();
audio.src = '';
const fadeAllSections = () => {
const uid = localStorage.getItem('uid');
document.querySelectorAll("main > section").forEach(section => {
// Always keep upload-area visible for logged-in users
if (uid && section.id === 'upload-area') return;
if (!section.hidden) {
section.classList.add("fade-out");
setTimeout(() => {
section.classList.remove("fade-out");
section.hidden = true;
}, 300);
}
});
};
// Wait a moment to ensure the previous source is cleared
await new Promise(resolve => setTimeout(resolve, 50));
const dropzone = document.getElementById("upload-area");
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
const fileInput = document.getElementById("fileInput");
const fileInfo = document.createElement("div");
fileInfo.id = "file-info";
fileInfo.style.textAlign = "center";
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
// Set new source with cache-busting timestamp
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
// Try to play immediately
try {
await audio.play();
audioPlaying = true;
} catch (e) {
console.error('Play failed:', e);
audioPlaying = false;
}
// Show stream info
const streamInfo = document.getElementById("stream-info");
const streamUrlEl = document.getElementById("streamUrl");
if (streamInfo) streamInfo.hidden = false;
const status = document.getElementById("status");
const spinner = document.getElementById("spinner");
// Update button state
updatePlayPauseButton();
const uid = localStorage.getItem("uid");
const uidTime = parseInt(localStorage.getItem("uid_time"), 10);
const now = Date.now();
// Hide register button if logged in
const registerBtn = document.getElementById("show-register");
if (uid && localStorage.getItem("confirmed_uid") === uid && uidTime && (now - uidTime) < 3600000) {
if (registerBtn) registerBtn.style.display = "none";
} else {
if (registerBtn) registerBtn.style.display = "";
}
if (!uid || !uidTime || (now - uidTime) > 3600000) {
localStorage.removeItem("uid");
localStorage.removeItem("confirmed_uid");
localStorage.removeItem("uid_time");
status.className = "error-toast";
status.innerText = "❌ Session expired. Please log in again.";
// Add Login or Register button only for this error
let loginBtn = document.createElement('button');
loginBtn.textContent = 'Login or Register';
loginBtn.className = 'login-register-btn';
loginBtn.onclick = () => {
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
};
status.appendChild(document.createElement('br'));
status.appendChild(loginBtn);
// Remove the status div after a short delay so only toast remains
setTimeout(() => {
if (status.parentNode) status.parentNode.removeChild(status);
}, 100);
return;
}
const confirmed = localStorage.getItem("confirmed_uid");
if (!confirmed || uid !== confirmed) {
status.className = "error-toast";
status.innerText = "❌ Please confirm your account via email first.";
showToast(status.innerText);
return;
}
return audio;
}
let abortController;
// Export the function for use in other modules
window.loadProfileStream = loadProfileStream;
const upload = async (file) => {
if (abortController) abortController.abort();
abortController = new AbortController();
fileInfo.innerText = `📁 ${file.name}${(file.size / 1024 / 1024).toFixed(2)} MB`;
if (file.size > 100 * 1024 * 1024) {
status.className = "error-toast";
status.innerText = "❌ Session expired. Please log in again.";
// Add Login or Register button only for this error
let loginBtn = document.createElement('button');
loginBtn.textContent = 'Login or Register';
loginBtn.className = 'login-register-btn';
loginBtn.onclick = () => {
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
};
status.appendChild(document.createElement('br'));
status.appendChild(loginBtn);
showToast(status.innerText);
return;
}
spinner.style.display = "block";
status.innerHTML = '📡 Uploading…';
status.className = "uploading-toast";
fileInput.disabled = true;
dropzone.classList.add("uploading");
const formData = new FormData();
formData.append("uid", uid);
formData.append("file", file);
const res = await fetch("/upload", {
signal: abortController.signal,
method: "POST",
body: formData,
document.addEventListener("DOMContentLoaded", () => {
// Initialize play/pause button
const playPauseButton = document.getElementById('play-pause');
if (playPauseButton) {
// Set initial state
audioPlaying = false;
updatePlayPauseButton();
// Add event listener
playPauseButton.addEventListener('click', () => {
const audio = getMainAudio();
if (audio) {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
updatePlayPauseButton();
}
});
}
let data, parseError;
try {
data = await res.json();
} catch (e) {
parseError = e;
}
if (!data) {
status.className = "error-toast";
status.innerText = "❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error");
showToast(status.innerText);
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
return;
}
if (res.ok) {
status.className = "success-toast";
streamInfo.hidden = false;
streamInfo.innerHTML = `
<p>Your stream is now live:</p>
<audio controls id="me-audio" aria-label="Stream audio player. Your uploaded voice loop plays here.">
<p style='font-size: 0.9em; text-align: center;'>🔁 This stream loops forever</p>
<source src="${data.stream_url}" type="audio/ogg">
</audio>
<p><a href="${data.stream_url}" target="_blank" class="button" aria-label="Open your stream in a new tab">Open in external player</a></p>
`;
const meAudio = document.getElementById("me-audio");
meAudio.addEventListener("ended", () => {
if (mePlaylist.length > 1) {
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
meAudio.src = mePlaylist[mePlaylistIdx];
meAudio.load();
meAudio.play();
meUrl.value = mePlaylist[mePlaylistIdx];
} else if (mePlaylist.length === 1) {
// Only one file: restart
meAudio.currentTime = 0;
meAudio.load();
meAudio.play();
}
});
if (data.quota && data.quota.used_mb !== undefined) {
const bar = document.getElementById("quota-bar");
const text = document.getElementById("quota-text");
const quotaSec = document.getElementById("quota-meter");
if (bar && text && quotaSec) {
quotaSec.hidden = false;
const used = parseFloat(data.quota.used_mb);
bar.value = used;
bar.max = 100;
text.textContent = `${used.toFixed(1)} MB used`;
}
// Add bot protection for registration form
const registerForm = document.getElementById('register-form');
if (registerForm) {
registerForm.addEventListener('submit', (e) => {
const botTrap = e.target.elements.bot_trap;
if (botTrap && botTrap.value) {
e.preventDefault();
showToast('❌ Bot detected! Please try again.');
return false;
}
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
showToast(status.innerText);
status.innerText = "✅ Upload successful.";
playBeep(432, 0.25, "sine");
setTimeout(() => status.innerText = "", 5000);
streamInfo.classList.add("visible", "slide-in");
} else {
streamInfo.hidden = true;
status.className = "error-toast";
spinner.style.display = "none";
if ((data.detail || data.error || "").includes("music")) {
status.innerText = "🎵 Upload rejected: singing or music detected.";
} else {
status.innerText = `❌ Upload failed: ${data.detail || data.error}`;
}
showToast(status.innerText);
fileInput.value = null;
dropzone.classList.remove("uploading");
fileInput.disabled = false;
streamInfo.classList.remove("visible", "slide-in");
}
};
dropzone.addEventListener("click", () => {
console.log("[DEBUG] Dropzone clicked");
fileInput.click();
console.log("[DEBUG] fileInput.click() called");
});
dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
dropzone.classList.add("dragover");
dropzone.style.transition = "background-color 0.3s ease";
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", (e) => {
dropzone.classList.add("pulse");
setTimeout(() => dropzone.classList.remove("pulse"), 400);
e.preventDefault();
dropzone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) upload(file);
});
fileInput.addEventListener("change", (e) => {
status.innerText = "";
status.className = "";
const file = e.target.files[0];
if (file) upload(file);
});
return true;
});
}
// Initialize navigation
document.querySelectorAll('#links a[data-target]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
@ -426,4 +381,34 @@ document.addEventListener("DOMContentLoaded", () => {
if (burger && burger.checked) burger.checked = false;
});
});
// Initialize profile player if valid session
setTimeout(runProfilePlayerIfSessionValid, 200);
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
});
// Initialize navigation
document.querySelectorAll('#links a[data-target]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('data-target');
// Only hide other sections when not opening #me-page
if (target !== 'me-page') fadeAllSections();
const section = document.getElementById(target);
if (section) {
section.hidden = false;
section.classList.add("slide-in");
section.scrollIntoView({ behavior: "smooth" });
}
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
});
});
// Initialize profile player if valid session
setTimeout(runProfilePlayerIfSessionValid, 200);
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
});

View File

@ -1,28 +1,34 @@
import { showToast } from "./toast.js";
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
async function initDashboard() {
const uploadArea = document.querySelector('#upload-area');
const userDashboard = document.querySelector('#me-page');
const meAudio = document.querySelector('#me-audio');
const quotaBar = document.querySelector('#quota-bar');
const quotaText = document.querySelector('#quota-text');
const streamsLink = document.querySelector('#show-streams');
const registerLink = document.querySelector('#show-register');
// New dashboard toggling logic
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const userUpload = document.getElementById('user-upload-area');
// Default state: hide both
uploadArea.hidden = true;
userDashboard.hidden = true;
// Hide all by default
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
const uid = localStorage.getItem('uid');
const uid = getCookie('uid');
if (!uid) {
// Guest: only upload area and move Streams next to Register
uploadArea.hidden = false;
userDashboard.hidden = true;
if (registerLink && streamsLink) {
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
}
return;
}
// Guest view: only nav
if (guestDashboard) guestDashboard.style.display = '';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = 'none';
return;
}
try {
const res = await fetch(`/me/${uid}`);
@ -30,22 +36,46 @@ async function initDashboard() {
const data = await res.json();
// Logged-in view
uploadArea.hidden = false;
userDashboard.hidden = false;
// Restore links section and show-me link
const linksSection = document.getElementById('links');
if (linksSection) linksSection.style.display = '';
const showMeLink = document.getElementById('show-me');
if (showMeLink && showMeLink.parentElement) showMeLink.parentElement.style.display = '';
// Show me-page for logged-in users
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = '';
// Ensure upload area is visible if last_page was me-page
const userUpload = document.getElementById('user-upload-area');
if (userUpload && localStorage.getItem('last_page') === 'me-page') {
// userUpload visibility is now only controlled by nav.js SPA logic
}
// Remove guest warning if present
const guestMsg = document.getElementById('guest-warning-msg');
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
userDashboard.style.display = '';
// Set audio source
meAudio.src = data.stream_url;
const meAudio = document.getElementById('me-audio');
if (meAudio && uid) {
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`;
}
// Update quota
quotaBar.value = data.quota;
quotaText.textContent = `${data.quota} MB used`;
const quotaBar = document.getElementById('quota-bar');
const quotaText = document.getElementById('quota-text');
if (quotaBar) quotaBar.value = data.quota;
if (quotaText) quotaText.textContent = `${data.quota} MB used`;
// Ensure Streams link remains in nav, not moved
// (No action needed if static)
} catch (e) {
console.warn('Dashboard init error, treating as guest:', e);
localStorage.removeItem('uid');
uploadArea.hidden = false;
userDashboard.hidden = true;
userUpload.style.display = '';
userDashboard.style.display = 'none';
const registerLink = document.getElementById('guest-login');
const streamsLink = document.getElementById('guest-streams');
if (registerLink && streamsLink) {
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
}
@ -53,3 +83,111 @@ async function initDashboard() {
}
document.addEventListener('DOMContentLoaded', initDashboard);
// Registration form handler for guests
// Handles the submit event on #register-form, sends data to /register, and alerts the user with the result
document.addEventListener('DOMContentLoaded', () => {
const regForm = document.getElementById('register-form');
if (regForm) {
regForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(regForm);
try {
const res = await fetch('/register', {
method: 'POST',
body: formData
});
let data;
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await res.json();
} else {
data = { detail: await res.text() };
}
if (res.ok) {
showToast('Confirmation sent! Check your email.');
} else {
showToast('Registration failed: ' + (data.detail || res.status));
}
} catch (err) {
showToast('Network error: ' + err);
}
});
}
});
// Connect Login or Register link to register form
document.addEventListener('DOMContentLoaded', () => {
// Login/Register (guest)
const loginLink = document.getElementById('guest-login');
if (loginLink) {
loginLink.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'register-page';
});
const reg = document.getElementById('register-page');
if (reg) reg.hidden = false;
reg.scrollIntoView({behavior:'smooth'});
});
}
// Terms of Service (all dashboards)
const termsLinks = [
document.getElementById('guest-terms'),
document.getElementById('user-terms')
];
termsLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'terms-page';
});
const terms = document.getElementById('terms-page');
if (terms) terms.hidden = false;
terms.scrollIntoView({behavior:'smooth'});
});
}
});
// Imprint (all dashboards)
const imprintLinks = [
document.getElementById('guest-imprint'),
document.getElementById('user-imprint')
];
imprintLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'imprint-page';
});
const imprint = document.getElementById('imprint-page');
if (imprint) imprint.hidden = false;
imprint.scrollIntoView({behavior:'smooth'});
});
}
});
// Privacy Policy (all dashboards)
const privacyLinks = [
document.getElementById('guest-privacy'),
document.getElementById('user-privacy')
];
privacyLinks.forEach(link => {
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== 'privacy-page';
});
const privacy = document.getElementById('privacy-page');
if (privacy) privacy.hidden = false;
privacy.scrollIntoView({behavior:'smooth'});
});
}
});
});

View File

@ -2,12 +2,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/style.css" media="all" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
<title>dicta2stream</title>
<link rel="stylesheet" href="/static/style.css" />
<!-- Responsive burger menu display -->
<style>
#burger-label, #burger-toggle { display: none; }
@ -30,67 +30,68 @@
<main>
<section id="upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInput" accept="audio/*" hidden />
<!-- Guest Dashboard -->
<nav id="guest-dashboard" class="dashboard-nav">
<a href="#" id="guest-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="guest-streams" data-target="stream-page">Streams</a> |
<a href="#" id="guest-login" data-target="register-page">Login or Register</a>
</nav>
<!-- User Dashboard -->
<nav id="user-dashboard" class="dashboard-nav" style="display:none;">
<a href="#" id="user-welcome" data-target="welcome-page">Welcome</a> |
<a href="#" id="user-streams" data-target="stream-page">Streams</a> |
<a href="#" id="show-me" data-target="me-page">Your Stream</a>
</nav>
<section id="me-page">
<article>
<h2>Your Stream 🎙️</h2>
<p>This is your personal stream. Only you can upload to it.</p>
<audio id="me-audio"></audio>
<div class="audio-controls">
<button id="play-pause" type="button">▶️</button>
</div>
</article>
<section id="user-upload-area" class="dropzone">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInputUser" accept="audio/*" hidden />
</section>
</section>
<div id="spinner" class="spinner">
<div id="spinner" class="spinner"></div>
</div>
<div id="status"></div>
<section id="stream-info" hidden>
<p>Your loop stream:</p>
<code id="streamUrl">...</code>
<audio controls id="player" loop></audio>
<p><button id="delete-account" class="delete-account">🗑️ Delete My Account</button></p>
</section>
<input type="checkbox" id="burger-toggle" hidden>
<label for="burger-toggle" id="burger-label" aria-label="Menu">
<span></span>
<span></span>
<span></span>
</label>
<section id="links">
<p><a href="#" id="show-me" data-target="me-page">Your Stream</a></p>
<p><a href="#" id="show-register" data-target="register-page">Login or Register</a></p>
<p>
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a> |
<a href="#" id="show-streams" data-target="stream-page">Streams</a>
</p>
</section>
<!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden>
<article>
<h2>Terms of Service</h2>
<p><em>Last updated: April 18, 2025</em></p>
<p>By accessing or using dicta2stream.net (the “Service”), you agree to be bound by these Terms of Service (“Terms”). If you do not agree, do not use the Service.</p>
<ul>
<li>You must be at least 18 years old to register.</li>
<li>UID in localStorage must be uniquely yours.</li>
<li>Each account must be unique and used by only one person.</li>
<li>One account per device/IP per 24 hours.</li>
<li>If hate speech, illegal, or harmful content is detected, the account and all associated data will be deleted.</li>
<li>The associated email address will be banned from recreating an account.</li>
<li>Uploads are limited to 100 MB and must be voice only.</li>
<li>Music/singing will be rejected.</li>
</ul>
<p>Uploads are limited to <strong>100 MB</strong> and must be <strong>voice only</strong>. Music/singing will be rejected. Your stream will loop publicly and anonymously via Icecast.</p>
<p>See full legal terms in the Git repository or request via support@dicta2stream.net.</p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="privacy-page" hidden>
<article>
<h2>Privacy Policy</h2>
<p><em>Last updated: April 18, 2025</em></p>
<ul>
<li>No cookies. UID is stored locally only.</li>
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
<li>We log IP + UID only for abuse protection and quota enforcement.</li>
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
<li>Data is never sold. Contact us for account deletion.</li>
</ul>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
@ -99,90 +100,94 @@
<h2>Imprint</h2>
<p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
<p><strong>Contact:</strong><br>
<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="welcome-page">
<article>
<h2>Welcome</h2>
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br>
<strong>What you can do here:</strong></p>
<ul>
<li>🎧 Listen to public voice streams from others, instantly</li>
<li>🎙️ Upload your own audio and share your voice with the world</li>
<li>🕵️ No sign-up required for listening</li>
<li>🔒 Optional registration for uploading and managing your own stream</li>
</ul>
</article>
</section>
<section id="stream-page" hidden>
<article>
<h2>🎧 Public Streams</h2>
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
<ul id="stream-list"><li>Loading...</li></ul>
<p><a href="#" data-back="upload-area">← Back</a></p>
<p style="margin-top:1.5em;font-size:0.98em;">
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a>
</p>
</article>
</section>
<section id="register-page" hidden>
<article>
<h2>Register</h2>
<h2>Login or Register</h2>
<form id="register-form">
<p><label>Email<br><input type="email" name="email" required /></label></p>
<p><label>Username<br><input type="text" name="user" required /></label></p>
<p style="display: none;">
<label>Leave this empty:<br>
<input type="text" name="bot_trap" autocomplete="off" />
</label>
</p>
<p><button type="submit">Create Account</button></p>
</form>
<p><small>Youll receive a magic login link via email. No password required.</small></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
</article>
</section>
<section id="magic-login-page" hidden>
<article>
<h2>Magic Login</h2>
<p>If you received a magic login link, you're almost in. Click below to confirm your account and activate streaming.</p>
<form id="magic-login-form">
<div id="magic-error" style="color: #b22222; font-size: 0.9em; display: none; margin-bottom: 1em;"></div>
<input type="hidden" name="token" id="magic-token" />
<button type="submit">Confirm &amp; Activate</button>
</form>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
<section id="quota-meter" hidden>
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
</section>
<section id="me-page" hidden>
<article>
<h2>Your Stream 🎙️</h2>
<p>This is your personal stream. Only you can upload to it.</p>
<audio controls id="me-audio"></audio>
<!-- Playlist and URL input hidden as per user request -->
<div class="playlist-controls">
<button id="me-prev" aria-label="Previous track">⏮️</button>
<button id="me-next" aria-label="Next track">⏭️</button>
</div>
<!-- <ul id="me-playlist" class="playlist"></ul> -->
<!-- <p><input id="me-url" readonly class="me-url" /></p> -->
<p><button id="copy-url">📋 Copy URL to clipboard</button></p>
<p><a href="#" data-back="upload-area">← Back</a></p>
</article>
</section>
</main>
<footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p style="font-size: 0.85em; opacity: 0.65;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
<p class="footer-hint">Need more space? Contact<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p class="footer-links">
<a href="#" id="footer-terms" data-target="terms-page">Terms of Service</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy Policy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
</p>
</footer>
<script type="module" src="/static/dashboard.js"></script>
<script type="module" src="/static/app.js"></script>
<!-- Load public streams UI logic -->
<script type="module" src="/static/streams-ui.js"></script>
<!-- Load upload functionality -->
<script type="module" src="/static/upload.js"></script>
<script type="module">
import "/static/nav.js";
window.addEventListener("pageshow", () => {
const dz = document.querySelector("#upload-area");
dz.classList.remove("uploading");
const dz = document.querySelector("#user-upload-area");
if (dz) dz.classList.remove("uploading");
const spinner = document.querySelector("#spinner");
if (spinner) spinner.style.display = "none";
});
</script>
<script type="module">
import { initMagicLogin } from '/static/magic-login.js';
const params = new URLSearchParams(window.location.search);
if (params.has('token')) {
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initMagicLogin);
} else {
initMagicLogin();
}
}
</script>
</body>
</html>

View File

@ -1,18 +1,63 @@
// static/magic-login.js — handles magiclink token UI
import { showOnly } from './router.js';
export function initMagicLogin() {
let magicLoginSubmitted = false;
export async function initMagicLogin() {
console.debug('[magic-login] initMagicLogin called');
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (token) {
const tokenInput = document.getElementById('magic-token');
if (tokenInput) tokenInput.value = token;
showOnly('magic-login-page');
const err = params.get('error');
if (err) {
const box = document.getElementById('magic-error');
box.textContent = decodeURIComponent(err);
box.style.display = 'block';
if (!token) {
console.debug('[magic-login] No token in URL');
return;
}
// Remove token from URL immediately to prevent loops
const url = new URL(window.location.href);
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.pathname + url.search);
try {
const formData = new FormData();
formData.append('token', token);
const res = await fetch('/magic-login', {
method: 'POST',
body: formData,
});
if (res.redirected) {
// If redirected, backend should set cookie; but set localStorage for SPA
const url = new URL(res.url);
const confirmedUid = url.searchParams.get('confirmed_uid');
if (confirmedUid) {
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/";
// Set localStorage for SPA session logic instantly
localStorage.setItem('uid', confirmedUid);
localStorage.setItem('confirmed_uid', confirmedUid);
localStorage.setItem('uid_time', Date.now().toString());
}
window.location.href = res.url;
return;
}
// If not redirected, show error (shouldn't happen in normal flow)
let data;
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await res.json();
if (data && data.confirmed_uid) {
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/";
// Set localStorage for SPA session logic
localStorage.setItem('uid', data.confirmed_uid);
localStorage.setItem('confirmed_uid', data.confirmed_uid);
localStorage.setItem('uid_time', Date.now().toString());
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
// Optionally reload or navigate
setTimeout(() => location.reload(), 700);
return;
}
alert(data.detail || 'Login failed.');
} else {
const text = await res.text();
alert(text || 'Login failed.');
}
} catch (err) {
alert('Network error: ' + err);
}
}

View File

@ -1,8 +1,10 @@
// nav.js — lightweight navigation & magiclink handling
import { showToast } from "./toast.js";
// fallback toast if app.js not yet loaded
if (typeof window.showToast !== "function") {
window.showToast = (msg) => alert(msg);
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
document.addEventListener("DOMContentLoaded", () => {
@ -13,6 +15,12 @@ document.addEventListener("DOMContentLoaded", () => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
});
// Show user-upload-area only when me-page is shown and user is logged in
const userUpload = document.getElementById("user-upload-area");
if (userUpload) {
const uid = getCookie("uid");
userUpload.style.display = (id === "me-page" && uid) ? '' : 'none';
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
@ -20,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => {
init() {
initNavLinks();
initBackButtons();
initStreamsLoader();
initStreamLinks();
}
};
@ -38,14 +46,30 @@ document.addEventListener("DOMContentLoaded", () => {
link.classList.toggle('active', uidParam === profileUid);
});
}
window.addEventListener('popstate', highlightActiveProfileLink);
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get('profile');
if (profileUid) {
showOnly('me-page');
if (typeof window.showProfilePlayerFromUrl === 'function') {
window.showProfilePlayerFromUrl();
}
} else {
highlightActiveProfileLink();
}
});
/* restore last page (unless magiclink token present) */
const params = new URLSearchParams(location.search);
const token = params.get("token");
if (!token) {
const last = localStorage.getItem("last_page");
if (last && document.getElementById(last)) showOnly(last);
if (last && document.getElementById(last)) {
showOnly(last);
} else if (document.getElementById("welcome-page")) {
// Show Welcome page by default for all new/guest users
showOnly("welcome-page");
}
// Highlight active link on initial load
highlightActiveProfileLink();
}
@ -62,8 +86,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
}
// Debounce loading and helper for streams list
let loadingStreams = false;
function renderStreamList(streams) {
const ul = document.getElementById("stream-list");
if (!ul) return;
@ -81,49 +104,79 @@ document.addEventListener("DOMContentLoaded", () => {
// Initialize navigation listeners
function initNavLinks() {
const linksContainer = document.getElementById("links");
if (!linksContainer) return;
linksContainer.addEventListener("click", e => {
const a = e.target.closest("a[data-target]");
if (!a || !linksContainer.contains(a)) return;
e.preventDefault();
const target = a.dataset.target;
if (target) showOnly(target);
const burger = document.getElementById("burger-toggle");
if (burger && burger.checked) burger.checked = false;
const navIds = ["links", "user-dashboard", "guest-dashboard"];
navIds.forEach(id => {
const nav = document.getElementById(id);
if (!nav) return;
nav.addEventListener("click", e => {
const a = e.target.closest("a[data-target]");
if (!a || !nav.contains(a)) return;
e.preventDefault();
// Save audio state before navigation
const audio = document.getElementById('me-audio');
const wasPlaying = audio && !audio.paused;
const currentTime = audio ? audio.currentTime : 0;
const target = a.dataset.target;
if (target) showOnly(target);
// Handle stream page specifically
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
window.maybeLoadStreamsOnShow();
}
// Handle me-page specifically
else if (target === "me-page" && audio) {
// Restore audio state if it was playing
if (wasPlaying) {
audio.currentTime = currentTime;
audio.play().catch(e => console.error('Play failed:', e));
}
}
});
});
// Add click handlers for footer links with audio state saving
document.querySelectorAll(".footer-links a").forEach(link => {
link.addEventListener("click", (e) => {
e.preventDefault();
const target = link.dataset.target;
if (!target) return;
// Save audio state before navigation
const audio = document.getElementById('me-audio');
const wasPlaying = audio && !audio.paused;
const currentTime = audio ? audio.currentTime : 0;
showOnly(target);
// Handle me-page specifically
if (target === "me-page" && audio) {
// Restore audio state if it was playing
if (wasPlaying) {
audio.currentTime = currentTime;
audio.play().catch(e => console.error('Play failed:', e));
}
}
});
});
}
function initBackButtons() {
document.querySelectorAll('a[data-back]').forEach(btn => {
btn.addEventListener("click", e => {
e.preventDefault();
const target = btn.dataset.back;
if (target) showOnly(target);
// Ensure streams load instantly when stream-page is shown
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
window.maybeLoadStreamsOnShow();
}
});
});
}
function initStreamsLoader() {
const streamsLink = document.getElementById("show-streams");
streamsLink?.addEventListener("click", async e => {
e.preventDefault();
if (loadingStreams) return;
loadingStreams = true;
showOnly("stream-page");
try {
const res = await fetch("/streams");
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
renderStreamList(data.streams || []);
} catch {
const ul = document.getElementById("stream-list");
if (ul) ul.innerHTML = "<li>Error loading stream list</li>";
} finally {
loadingStreams = false;
}
});
}
function initStreamLinks() {
const ul = document.getElementById("stream-list");

0
static/reload.txt Normal file
View File

View File

@ -1,22 +1,198 @@
// static/streams-ui.js — public streams loader and profile-link handling
import { showOnly } from './router.js';
let loadingStreams = false;
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
export function initStreamsUI() {
initStreamLinks();
window.addEventListener('popstate', () => {
highlightActiveProfileLink();
maybeLoadStreamsOnShow();
});
document.addEventListener('visibilitychange', maybeLoadStreamsOnShow);
maybeLoadStreamsOnShow();
}
function maybeLoadStreamsOnShow() {
// Expose globally for nav.js
const streamPage = document.getElementById('stream-page');
const isVisible = streamPage && !streamPage.hidden;
if (isVisible) {
loadAndRenderStreams();
}
}
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
let currentlyPlayingAudio = null;
let currentlyPlayingButton = null;
document.addEventListener('DOMContentLoaded', initStreamsUI);
function loadAndRenderStreams() {
const ul = document.getElementById('stream-list');
if (!ul) {
console.warn('[streams-ui] #stream-list not found in DOM');
return;
}
console.debug('[streams-ui] loadAndRenderStreams (SSE mode) called');
ul.innerHTML = '<li>Loading...</li>';
let gotAny = false;
let streams = [];
// Close previous EventSource if any
if (window._streamsSSE) {
window._streamsSSE.close();
}
const evtSource = new window.EventSource('/streams-sse');
window._streamsSSE = evtSource;
evtSource.onmessage = function(event) {
console.debug('[streams-ui] SSE event received:', event.data);
try {
const data = JSON.parse(event.data);
if (data.end) {
if (!gotAny) {
ul.innerHTML = '<li>No active streams.</li>';
}
evtSource.close();
highlightActiveProfileLink();
return;
}
// Remove Loading... on any valid event
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
streams.push(data);
const uid = data.uid || '';
const sizeMb = data.size ? (data.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = data.mtime ? new Date(data.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
const li = document.createElement('li');
li.innerHTML = `
<article class="stream-player">
<h3>${uid}</h3>
<audio id="audio-${uid}" class="stream-audio" preload="auto" crossOrigin="anonymous" src="/audio/${encodeURIComponent(uid)}/stream.opus"></audio>
<div class="audio-controls">
<button id="play-pause-${uid}">▶</button>
</div>
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
// Add play/pause handler after appending to DOM
ul.appendChild(li);
// Wait for DOM update
requestAnimationFrame(() => {
const playPauseButton = document.getElementById(`play-pause-${uid}`);
const audio = document.getElementById(`audio-${uid}`);
if (playPauseButton && audio) {
playPauseButton.addEventListener('click', () => {
try {
if (audio.paused) {
// Stop any currently playing audio first
if (currentlyPlayingAudio && currentlyPlayingAudio !== audio) {
currentlyPlayingAudio.pause();
if (currentlyPlayingButton) {
currentlyPlayingButton.textContent = '▶';
}
}
// Stop the main player if it's playing
if (typeof window.stopMainAudio === 'function') {
window.stopMainAudio();
}
audio.play().then(() => {
playPauseButton.textContent = '⏸️';
currentlyPlayingAudio = audio;
currentlyPlayingButton = playPauseButton;
}).catch(e => {
console.error('Play failed:', e);
// Reset button if play fails
playPauseButton.textContent = '▶';
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
});
} else {
audio.pause();
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
} catch (e) {
console.error('Audio error:', e);
playPauseButton.textContent = '▶';
if (currentlyPlayingAudio === audio) {
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}
});
}
});
highlightActiveProfileLink();
ul.appendChild(li);
highlightActiveProfileLink();
} catch (e) {
// Remove Loading... even if JSON parse fails, to avoid stuck UI
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
console.error('[streams-ui] SSE parse error', e, event.data);
}
};
evtSource.onerror = function(err) {
console.error('[streams-ui] SSE error', err);
ul.innerHTML = '<li>Error loading stream list</li>';
if (typeof showToast === 'function') {
showToast('❌ Error loading public streams.');
}
evtSource.close();
// Add reload button if not present
const reloadButton = document.getElementById('reload-streams');
if (!reloadButton) {
const reloadHtml = '<button id="reload-streams" onclick="loadAndRenderStreams()">Reload</button>';
ul.insertAdjacentHTML('beforeend', reloadHtml);
}
};
}
export function renderStreamList(streams) {
const ul = document.getElementById('stream-list');
if (!ul) return;
if (streams.length) {
streams.sort();
ul.innerHTML = streams
.map(
uid => `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a></li>`
)
.join('');
if (!ul) {
console.warn('[streams-ui] renderStreamList: #stream-list not found');
return;
}
console.debug('[streams-ui] Rendering stream list:', streams);
if (Array.isArray(streams)) {
if (streams.length) {
// Sort by mtime descending (most recent first)
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
ul.innerHTML = streams
.map(stream => {
const uid = stream.uid || '';
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:gray;font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
})
.join('');
} else {
ul.innerHTML = '<li>No active streams.</li>';
}
} else {
ul.innerHTML = '<li>No active streams.</li>';
ul.innerHTML = '<li>Error: Invalid stream data.</li>';
console.error('[streams-ui] renderStreamList: streams is not an array', streams);
}
highlightActiveProfileLink();
console.debug('[streams-ui] renderStreamList complete');
}
export function highlightActiveProfileLink() {
@ -31,26 +207,7 @@ export function highlightActiveProfileLink() {
});
}
export function initStreamsLoader() {
const streamsLink = document.getElementById('show-streams');
streamsLink?.addEventListener('click', async e => {
e.preventDefault();
if (loadingStreams) return;
loadingStreams = true;
showOnly('stream-page');
try {
const res = await fetch('/streams');
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
renderStreamList(data.streams || []);
} catch {
const ul = document.getElementById('stream-list');
if (ul) ul.innerHTML = '<li>Error loading stream list</li>';
} finally {
loadingStreams = false;
}
});
}
export function initStreamLinks() {
const ul = document.getElementById('stream-list');
@ -61,16 +218,17 @@ export function initStreamLinks() {
e.preventDefault();
const url = new URL(a.href, window.location.origin);
const profileUid = url.searchParams.get('profile');
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
window.profileNavigationTriggered = true;
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
window.dispatchEvent(new Event('popstate'));
if (profileUid) {
if (window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
window.profileNavigationTriggered = true;
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}#`);
window.dispatchEvent(new Event('popstate'));
} else {
// If already on this profile, still highlight
if (typeof window.highlightActiveProfileLink === "function") {
window.highlightActiveProfileLink();
}
}
}
});
}
export function initStreamsUI() {
initStreamsLoader();
initStreamLinks();
window.addEventListener('popstate', highlightActiveProfileLink);
}

View File

@ -1,4 +1,48 @@
/* style.css — minimal UI styling for dicta2stream */
main {
display: flex;
flex-direction: column;
align-items: center; /* centers children horizontally */
}
nav#guest-dashboard.dashboard-nav {
width: fit-content; /* optional: shrink to fit content */
margin: 0 auto; /* fallback for block centering */
}
/* Toast notification styles */
.toast-container {
position: fixed;
left: 50%;
bottom: 40px;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.toast {
background: var(--crt-screen);
color: var(--crt-text);
padding: 1em 2em;
border-radius: 8px;
box-shadow: 0 4px 20px var(--crt-shadow);
margin-top: 0.5em;
opacity: 0;
animation: fadeInOut 3.5s both;
font-size: 1.1em;
pointer-events: auto;
border: 1px solid var(--crt-border);
text-shadow: 0 0 2px rgba(0, 255, 0, 0.1);
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(40px) scale(0.96); }
10% { opacity: 1; transform: translateY(0) scale(1); }
90% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(40px) scale(0.96); }
}
.spinner {
position: absolute;
@ -27,6 +71,8 @@
opacity: 0.7;
}
.cancel-upload {
display: none;
margin-top: 0.4em;
@ -83,7 +129,7 @@ button.logout:hover {
audio {
display: block;
margin: 1em auto;
margin: 0;
max-width: 100%;
outline: none;
border-radius: 6px;
@ -91,6 +137,366 @@ audio {
background: #fff;
}
.audio-controls {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
}
.audio-controls button {
background: none;
border: none;
cursor: pointer;
padding: 1.2em;
border-radius: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 96px;
min-height: 96px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
font-size: 48px;
font-weight: bold;
line-height: 1;
color: #333;
}
.audio-controls button:hover {
background-color: #f0f0f0;
transform: scale(1.1);
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.audio-controls button:active {
color: #000;
transform: scale(0.9);
}
.audio-controls svg {
fill: #333;
transition: all 0.2s ease;
width: 56px;
height: 56px;
}
.audio-controls button:hover svg {
fill: #000;
transform: scale(1.1);
}
/* Hide the native controls */
audio::-webkit-media-controls-panel {
display: none !important;
}
audio::-webkit-media-controls-play-button {
display: none !important;
}
audio::-webkit-media-controls-volume-slider {
display: none !important;
}
main > section {
width: 100%;
max-width: 900px;
padding: 2em;
background: #1a1a1a;
border: 1px solid var(--audio-metal);
border-radius: 8px;
margin-bottom: 1.5em;
color: var(--audio-text);
text-shadow: 0 0 2px rgba(255, 102, 0, 0.2);
position: relative;
}
main > section article {
background: #222;
border: 1px solid #444;
padding: 1.5em;
border-radius: 6px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3; /* Light gray for better contrast */
}
main > section article::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
pointer-events: none;
z-index: -1;
}
main > section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
pointer-events: none;
z-index: -1;
}
/* Audio controls styling */
button.audio-control {
background: #333;
border: 2px solid #555;
color: #fff;
padding: 0.5em 1em;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Roboto', sans-serif;
font-weight: 500;
}
#register-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
text-align: center;
}
#register-form p {
margin-bottom: 1em;
text-align: left;
}
#register-form label {
display: inline;
margin-right: 10px;
color: #d3d3d3;
font-family: 'Courier New', monospace;
}
#register-form input[type="text"],
#register-form input[type="email"],
#register-form input[type="password"] {
display: inline;
background-color: rgb(26, 26, 26);
border: 1px solid #444;
color: #d3d3d3;
padding: 0.8em;
border-radius: 4px;
width: 250px;
transition: all 0.2s ease;
}
#register-form input[type="text"]:focus,
#register-form input[type="email"]:focus,
#register-form input[type="password"]:focus {
outline: none;
border-color: #ff6600;
box-shadow: 0 0 0 2px rgba(255, 102, 0, 0.1);
}
/* Submit button styling */
#register-form button[type="submit"] {
display: inline;
width: calc(250px + 1.6em);
padding: 0.8em;
background: transparent;
color: #d3d3d3;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-top: 1.25em;
transition: color 0.3s;
font-family: 'Courier New', monospace;
text-align: center;
border: 1px solid #444;
background: #1a1a1a;
}
#register-form button[type="submit"]:hover {
color: #ff6600;
}
#register-form button[type="submit"]:active {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
button.audio-control:hover {
background: #444;
border-color: #666;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
/* Audio meter styling */
.audio-meter {
background: #333;
border: 1px solid #555;
height: 10px;
border-radius: 5px;
overflow: hidden;
}
.audio-meter-fill {
background: linear-gradient(90deg, #ff6600, #ff8800);
height: 100%;
transition: width 0.3s ease-in-out;
}
/* Audio list styling */
.audio-list {
list-style: none;
padding: 0;
margin: 0;
background: #1a1a1a;
border: 1px solid var(--audio-metal);
border-radius: 8px;
overflow: hidden;
}
#stream-list > li {
background: #1a1a1a;
}
/* Stream player styling */
.stream-player {
background: #1a1a1a;
border: 1px solid #444;
padding: 1.5em;
border-radius: 8px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3;
}
.stream-player::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
pointer-events: none;
z-index: -1;
}
.stream-player h3 {
color: var(--audio-accent);
margin: 0 0 1em 0;
font-size: 1.1em;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.stream-player audio {
width: 100%;
max-width: 500px;
margin: 1em 0;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
}
.stream-player audio {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: #1a1a1a;
border: none;
border-radius: 6px;
width: 100%;
max-width: 500px;
margin: 1em 0;
padding: 0.5em;
}
/* Custom play button */
.stream-player audio::before {
content: '▶️';
position: absolute;
left: 0.5em;
top: 50%;
transform: translateY(-50%);
color: #2e8b57;
font-size: 1.5em;
cursor: pointer;
z-index: 1;
}
/* Custom progress bar */
.stream-player audio::-webkit-progress-bar {
background: #444;
border-radius: 3px;
}
.stream-player audio::-webkit-progress-value {
background: linear-gradient(90deg, #ff6600, #ff8800);
border-radius: 3px;
}
/* Custom volume slider */
.stream-player audio::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #ff6600;
border-radius: 50%;
cursor: pointer;
}
.stream-player audio::-webkit-slider-runnable-track {
height: 4px;
background: #444;
border-radius: 2px;
}
/* Guest login heading styling */
#guest-login h2 {
color: var(--audio-accent);
font-size: 1.1em;
margin: 0.5em 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
text-align: center;
display: inline-block;
background: #2e8b57;
color: white;
padding: 0.4em 1em;
border-radius: 5px;
text-decoration: none;
transition: background 0.2s ease;
}
#guest-login h2:hover {
background: #256b45;
}
.audio-list li {
background: #333;
border: 1px solid #555;
padding: 1em;
margin-bottom: 0.5em;
border-radius: 6px;
transition: all 0.2s;
}
.audio-list li:hover {
background: #444;
border-color: #666;
transform: translateY(-1px);
}
#me-wrap {
background: #fdfdfd;
padding: 1.5em;
@ -149,6 +555,13 @@ input[disabled], button[disabled] {
display: inline-block;
}
#stream-info, #stream-info * {
display: initial !important;
visibility: visible !important;
color: #222 !important;
background: #fff !important;
}
body {
font-family: sans-serif;
background: #fafafa;
@ -357,6 +770,68 @@ section article {
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
section article.stream-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.stream-player {
background: #1a1a1a;
border: 1px solid #444;
padding: 1.5em;
border-radius: 8px;
margin-bottom: 1em;
position: relative;
color: #d3d3d3;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stream-player h3 {
margin: 0 0 15px 0;
color: #d3d3d3;
}
.stream-audio {
width: 100%;
margin-bottom: 15px;
}
.audio-controls {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.audio-controls button {
background: none;
border: none;
cursor: pointer;
padding: 12px;
border-radius: 8px;
transition: all 0.2s ease;
font-size: 24px;
font-weight: bold;
color: #666;
}
.audio-controls button:hover {
background-color: #f0f0f0;
color: #333;
transform: scale(1.05);
}
.audio-controls button:active {
color: #000;
transform: scale(0.95);
}
.stream-info {
margin: 0;
color: #666;
font-size: 0.9em;
}
ul#stream-list,
ul#me-files {
padding-left: 0;
@ -413,10 +888,130 @@ section article a[href^="mailto"]:hover {
}
code {
background: #eee;
background: #1a1a1a;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
font-family: 'Courier New', monospace;
color: #fff;
text-shadow: 0 0 4px rgba(0, 255, 0, 0.1);
}
:root {
--audio-bg: #222;
--audio-metal: #333;
--audio-text: #fff;
--audio-accent: #ff6600;
--audio-shadow: rgba(0, 0, 0, 0.5);
}
body {
margin: 0;
font-family: 'Roboto', sans-serif;
background-color: var(--audio-bg);
color: var(--audio-text);
line-height: 1.6;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, rgba(255, 102, 0, 0.05) 0%, rgba(255, 102, 0, 0) 20%);
pointer-events: none;
z-index: -1;
}
main {
display: flex;
flex-direction: column;
align-items: center;
max-width: 960px;
width: 95%;
margin: 0 auto;
padding: 20px;
background: var(--audio-bg);
border: 2px solid var(--audio-metal);
border-radius: 12px;
box-shadow: 0 4px 15px var(--audio-shadow);
}
main::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
45deg,
rgba(0, 255, 0, 0.05),
rgba(0, 255, 0, 0.05) 2px,
transparent 2px,
transparent 4px
);
pointer-events: none;
z-index: -1;
}
nav#guest-dashboard.dashboard-nav,
nav#user-dashboard.dashboard-nav {
width: fit-content;
margin: 20px auto;
padding: 10px;
background: var(--crt-screen);
border: 1px solid var(--crt-border);
border-radius: 5px;
font-family: 'Courier New', monospace;
}
nav.dashboard-nav a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
padding: 5px;
border-radius: 3px;
transition: all 0.2s ease;
}
nav.dashboard-nav a:hover {
color: #ff6600;
}
/* Toast notification styles */
/* Footer links styling */
.footer-links {
margin-top: 1em;
font-size: 0.85em;
}
.footer-links a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
}
.footer-links a:hover {
color: #ff6600;
}
footer p.footer-hint a {
color: #d3d3d3;
text-decoration: none;
font-family: 'Courier New', monospace;
margin: 0 0.5em;
transition: color 0.3s;
}
footer p.footer-hint a:hover {
color: #ff6600;
}
@media (min-width: 960px) {
@ -424,7 +1019,7 @@ code {
display: flex;
flex-direction: column;
align-items: center;
background: #e0f7ff;
background: var(--crt-screen);
padding: 1em;
margin: 2em auto;
border-radius: 6px;

19
static/toast.js Normal file
View File

@ -0,0 +1,19 @@
// toast.js — centralized toast notification logic for dicta2stream
export function showToast(message) {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
// Do not remove the container; let it persist for stacking
}, 3500);
}

128
static/upload.js Normal file
View File

@ -0,0 +1,128 @@
// upload.js — Frontend file upload handler
import { showToast } from "./toast.js";
import { playBeep } from "./sound.js";
import { logToServer } from "./app.js";
// Initialize upload system when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const dropzone = document.getElementById("user-upload-area");
if (dropzone) {
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
}
const fileInput = document.getElementById("fileInputUser");
const fileInfo = document.createElement("div");
fileInfo.id = "file-info";
fileInfo.style.textAlign = "center";
if (fileInput) {
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
}
const streamInfo = document.getElementById("stream-info");
const streamUrlEl = document.getElementById("streamUrl");
const spinner = document.getElementById("spinner");
let abortController;
// Upload function
const upload = async (file) => {
if (abortController) abortController.abort();
abortController = new AbortController();
fileInfo.innerText = `📁 ${file.name}${(file.size / 1024 / 1024).toFixed(2)} MB`;
if (file.size > 100 * 1024 * 1024) {
showToast("❌ File too large. Please upload a file smaller than 100MB.");
return;
}
spinner.style.display = "block";
showToast('📡 Uploading…');
fileInput.disabled = true;
dropzone.classList.add("uploading");
const formData = new FormData();
const sessionUid = localStorage.getItem("uid");
formData.append("uid", sessionUid);
formData.append("file", file);
const res = await fetch("/upload", {
signal: abortController.signal,
method: "POST",
body: formData,
});
let data, parseError;
try {
data = await res.json();
} catch (e) {
parseError = e;
}
if (!data) {
showToast("❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error"));
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
return;
}
if (res.ok) {
if (data.quota && data.quota.used_mb !== undefined) {
const bar = document.getElementById("quota-bar");
const text = document.getElementById("quota-text");
const quotaSec = document.getElementById("quota-meter");
if (bar && text && quotaSec) {
quotaSec.hidden = false;
const used = parseFloat(data.quota.used_mb);
bar.value = used;
bar.max = 100;
text.textContent = `${used.toFixed(1)} MB used`;
}
}
spinner.style.display = "none";
fileInput.disabled = false;
dropzone.classList.remove("uploading");
showToast("✅ Upload successful.");
playBeep(432, 0.25, "sine");
} else {
streamInfo.hidden = true;
spinner.style.display = "none";
if ((data.detail || data.error || "").includes("music")) {
showToast("🎵 Upload rejected: singing or music detected.");
} else {
showToast(`❌ Upload failed: ${data.detail || data.error}`);
}
if (fileInput) fileInput.value = null;
if (dropzone) dropzone.classList.remove("uploading");
if (fileInput) fileInput.disabled = false;
if (streamInfo) streamInfo.classList.remove("visible", "slide-in");
}
};
// Export the upload function for use in other modules
window.upload = upload;
if (dropzone && fileInput) {
dropzone.addEventListener("click", () => {
console.log("[DEBUG] Dropzone clicked");
fileInput.click();
console.log("[DEBUG] fileInput.click() called");
});
dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
dropzone.classList.add("dragover");
dropzone.style.transition = "background-color 0.3s ease";
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", (e) => {
dropzone.classList.add("pulse");
setTimeout(() => dropzone.classList.remove("pulse"), 400);
e.preventDefault();
dropzone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) upload(file);
});
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) upload(file);
});
}
});