Update 2025-05-21_08:58:06
This commit is contained in:
695
static/app.js
695
static/app.js
@ -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() don’t 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);
|
||||
});
|
||||
});
|
||||
|
@ -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'});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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>You’ll 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 & 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 | 48 kHz | 60 kbps</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>
|
||||
|
@ -1,18 +1,63 @@
|
||||
// static/magic-login.js — handles magic‑link 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);
|
||||
}
|
||||
}
|
||||
|
133
static/nav.js
133
static/nav.js
@ -1,8 +1,10 @@
|
||||
// nav.js — lightweight navigation & magic‑link 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 magic‑link 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
0
static/reload.txt
Normal 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);
|
||||
}
|
||||
|
605
static/style.css
605
static/style.css
@ -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
19
static/toast.js
Normal 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
128
static/upload.js
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user