feat: Add database migrations and auth system
- Add Alembic for database migrations - Implement user authentication system - Update frontend styles and components - Add new test audio functionality - Update stream management and UI
This commit is contained in:
790
static/app.js
790
static/app.js
@ -1,5 +1,15 @@
|
||||
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
|
||||
|
||||
import { playBeep } from "./sound.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
// Global audio state
|
||||
let globalAudio = null;
|
||||
let currentStreamUid = null;
|
||||
let audioPlaying = false;
|
||||
let lastPosition = 0;
|
||||
|
||||
// Utility functions
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
@ -7,10 +17,6 @@ function getCookie(name) {
|
||||
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();
|
||||
@ -19,396 +25,476 @@ export function logToServer(msg) {
|
||||
xhr.send(JSON.stringify({ msg }));
|
||||
}
|
||||
|
||||
// Expose for debugging
|
||||
window.logToServer = logToServer;
|
||||
|
||||
// Handle magic link login redirect
|
||||
(function handleMagicLoginRedirect() {
|
||||
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
|
||||
localStorage.setItem('uid_time', Date.now().toString());
|
||||
document.cookie = `uid=${encodeURIComponent(username)}; path=/`;
|
||||
|
||||
// Update UI state immediately without reload
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const registerPage = document.getElementById('register-page');
|
||||
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
if (userDashboard) userDashboard.style.display = 'block';
|
||||
if (registerPage) registerPage.style.display = 'none';
|
||||
|
||||
// Update URL and history without reloading
|
||||
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() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get("profile");
|
||||
if (profileUid) {
|
||||
const mePage = document.getElementById("me-page");
|
||||
if (mePage) {
|
||||
document.querySelectorAll("main > section").forEach(sec => sec.hidden = sec.id !== "me-page");
|
||||
// Hide upload/delete/copy-url controls for guest view
|
||||
const uploadArea = document.getElementById("upload-area");
|
||||
if (uploadArea) uploadArea.hidden = true;
|
||||
const copyUrlBtn = document.getElementById("copy-url");
|
||||
if (copyUrlBtn) copyUrlBtn.style.display = "none";
|
||||
const deleteBtn = document.getElementById("delete-account");
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
// Update heading and description for guest view
|
||||
const meHeading = document.querySelector("#me-page h2");
|
||||
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
|
||||
const meDesc = document.querySelector("#me-page p");
|
||||
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
|
||||
// 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!
|
||||
}
|
||||
|
||||
// Navigate to user's profile page
|
||||
if (window.showOnly) {
|
||||
window.showOnly('me-page');
|
||||
} else if (window.location.hash !== '#me') {
|
||||
window.location.hash = '#me';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Only run showProfilePlayerFromUrl after session/profile checks are complete ---
|
||||
function runProfilePlayerIfSessionValid() {
|
||||
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
|
||||
showProfilePlayerFromUrl();
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
window.showProfilePlayerFromUrl = showProfilePlayerFromUrl;
|
||||
|
||||
// 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();
|
||||
// Audio player functions
|
||||
function getOrCreateAudioElement() {
|
||||
if (!globalAudio) {
|
||||
globalAudio = document.getElementById('me-audio');
|
||||
if (!globalAudio) {
|
||||
console.error('Audio element not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
globalAudio.preload = 'metadata';
|
||||
globalAudio.crossOrigin = 'use-credentials';
|
||||
globalAudio.setAttribute('crossorigin', 'use-credentials');
|
||||
|
||||
// Set up event listeners
|
||||
globalAudio.addEventListener('play', () => {
|
||||
audioPlaying = true;
|
||||
updatePlayPauseButton();
|
||||
});
|
||||
|
||||
globalAudio.addEventListener('pause', () => {
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
globalAudio.addEventListener('timeupdate', () => {
|
||||
lastPosition = globalAudio.currentTime;
|
||||
});
|
||||
|
||||
globalAudio.addEventListener('error', handleAudioError);
|
||||
}
|
||||
return globalAudio;
|
||||
}
|
||||
|
||||
function handleAudioError(e) {
|
||||
const error = this.error;
|
||||
let errorMessage = 'Audio playback error';
|
||||
let shouldShowToast = true;
|
||||
|
||||
if (error) {
|
||||
switch(error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = 'Audio playback was aborted';
|
||||
shouldShowToast = false; // Don't show toast for aborted operations
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = 'Network error while loading audio';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = 'Error decoding audio. The file may be corrupted.';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
// Don't show error for missing audio files on new accounts
|
||||
if (this.currentSrc && this.currentSrc.includes('stream.opus')) {
|
||||
console.log('Audio format not supported or file not found:', this.currentSrc);
|
||||
return;
|
||||
}
|
||||
updatePlayPauseButton();
|
||||
errorMessage = 'Audio format not supported. Please upload a supported format (Opus/OGG).';
|
||||
break;
|
||||
}
|
||||
|
||||
console.error('Audio error:', errorMessage, error);
|
||||
|
||||
// Only show error toast if we have a valid error and it's not a missing file
|
||||
if (shouldShowToast && !(error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && !this.src)) {
|
||||
showToast(errorMessage, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Audio error:', {
|
||||
error: error,
|
||||
src: this.currentSrc,
|
||||
networkState: this.networkState,
|
||||
readyState: this.readyState
|
||||
});
|
||||
|
||||
if (errorMessage !== 'Audio format not supported') {
|
||||
showToast(`❌ ${errorMessage}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayPauseButton(audio, button) {
|
||||
if (button && audio) {
|
||||
button.textContent = audio.paused ? '▶️' : '⏸️';
|
||||
}
|
||||
}
|
||||
|
||||
// Stream loading and playback
|
||||
async function loadProfileStream(uid) {
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (!audio) {
|
||||
console.error('Failed to initialize audio element');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide playlist controls
|
||||
const mePrevBtn = document.getElementById("me-prev");
|
||||
const meNextBtn = document.getElementById("me-next");
|
||||
if (mePrevBtn) mePrevBtn.style.display = "none";
|
||||
if (meNextBtn) meNextBtn.style.display = "none";
|
||||
|
||||
// Reset current stream and update audio source
|
||||
currentStreamUid = uid;
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
|
||||
// Wait a moment to ensure the previous source is cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const username = localStorage.getItem('username') || uid;
|
||||
const audioUrl = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`;
|
||||
|
||||
try {
|
||||
console.log('Checking audio file at:', audioUrl);
|
||||
|
||||
// First check if the audio file exists and get its content type
|
||||
const response = await fetch(audioUrl, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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;
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('No audio file found for user:', username);
|
||||
updatePlayPauseButton(audio, document.querySelector('.play-pause-btn'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
console.log('Audio content type:', contentType);
|
||||
|
||||
if (!contentType || !contentType.includes('audio/')) {
|
||||
throw new Error(`Invalid content type: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
// Set the audio source with proper type hint
|
||||
const source = document.createElement('source');
|
||||
source.src = audioUrl;
|
||||
source.type = 'audio/ogg; codecs=opus';
|
||||
|
||||
// Clear any existing sources
|
||||
while (audio.firstChild) {
|
||||
audio.removeChild(audio.firstChild);
|
||||
}
|
||||
audio.appendChild(source);
|
||||
|
||||
// Load the new source
|
||||
await new Promise((resolve, reject) => {
|
||||
audio.load();
|
||||
audio.oncanplaythrough = () => resolve(audio);
|
||||
audio.oncanplaythrough = resolve;
|
||||
audio.onerror = () => {
|
||||
reject(new Error('Failed to load audio source'));
|
||||
};
|
||||
// Set a timeout in case the audio never loads
|
||||
setTimeout(() => reject(new Error('Audio load timeout')), 10000);
|
||||
});
|
||||
}
|
||||
|
||||
// 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()}`;
|
||||
console.log('Audio loaded, attempting to play...');
|
||||
|
||||
// Try to play immediately
|
||||
try {
|
||||
await audio.play();
|
||||
audioPlaying = true;
|
||||
console.log('Audio playback started successfully');
|
||||
} catch (e) {
|
||||
console.error('Play failed:', e);
|
||||
console.log('Auto-play failed, waiting for user interaction:', e);
|
||||
audioPlaying = false;
|
||||
// Don't show error for autoplay restrictions
|
||||
if (!e.message.includes('play() failed because the user')) {
|
||||
showToast('Click the play button to start playback', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Show stream info
|
||||
// Show stream info if available
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
if (streamInfo) streamInfo.hidden = false;
|
||||
|
||||
// Update button state
|
||||
updatePlayPauseButton();
|
||||
|
||||
return audio;
|
||||
} catch (error) {
|
||||
console.error('Error checking/loading audio:', error);
|
||||
// Don't show error toasts for missing audio files or aborted requests
|
||||
if (error.name !== 'AbortError' &&
|
||||
!error.message.includes('404') &&
|
||||
!error.message.includes('Failed to load')) {
|
||||
showToast('Error loading audio: ' + (error.message || 'Unknown error'), 'error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
// 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;
|
||||
|
||||
// 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({}, '', '/');
|
||||
}
|
||||
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
updatePlayPauseButton(audio, document.querySelector('.play-pause-btn'));
|
||||
return audio;
|
||||
}
|
||||
|
||||
// Export the function for use in other modules
|
||||
window.loadProfileStream = loadProfileStream;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Initialize play/pause button
|
||||
const playPauseButton = document.getElementById('play-pause');
|
||||
if (playPauseButton) {
|
||||
// Set initial state
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
// Navigation and UI functions
|
||||
function showProfilePlayerFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get("profile");
|
||||
|
||||
if (profileUid) {
|
||||
const mePage = document.getElementById("me-page");
|
||||
if (!mePage) return;
|
||||
|
||||
// Add event listener
|
||||
playPauseButton.addEventListener('click', () => {
|
||||
const audio = getMainAudio();
|
||||
if (audio) {
|
||||
document.querySelectorAll("main > section").forEach(sec =>
|
||||
sec.hidden = sec.id !== "me-page"
|
||||
);
|
||||
|
||||
// Hide upload/delete/copy-url controls for guest view
|
||||
const uploadArea = document.getElementById("upload-area");
|
||||
if (uploadArea) uploadArea.hidden = true;
|
||||
|
||||
const copyUrlBtn = document.getElementById("copy-url");
|
||||
if (copyUrlBtn) copyUrlBtn.style.display = "none";
|
||||
|
||||
const deleteBtn = document.getElementById("delete-account");
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
|
||||
// Update UI for guest view
|
||||
const meHeading = document.querySelector("#me-page h2");
|
||||
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
|
||||
|
||||
const meDesc = document.querySelector("#me-page p");
|
||||
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initNavigation() {
|
||||
const navLinks = document.querySelectorAll('nav a');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Skip if href is empty or doesn't start with '#'
|
||||
if (!href || !href.startsWith('#')) {
|
||||
return; // Let the browser handle the link normally
|
||||
}
|
||||
|
||||
const sectionId = href.substring(1); // Remove the '#'
|
||||
|
||||
// Skip if sectionId is empty after removing '#'
|
||||
if (!sectionId) {
|
||||
console.warn('Empty section ID in navigation link:', link);
|
||||
return;
|
||||
}
|
||||
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
e.preventDefault();
|
||||
|
||||
// Hide all sections first
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== sectionId;
|
||||
});
|
||||
|
||||
// Special handling for me-page
|
||||
if (sectionId === 'me-page') {
|
||||
const registerPage = document.getElementById('register-page');
|
||||
if (registerPage) registerPage.hidden = true;
|
||||
|
||||
// Show the upload box in me-page
|
||||
const uploadBox = document.querySelector('#me-page #user-upload-area');
|
||||
if (uploadBox) uploadBox.style.display = 'block';
|
||||
} else if (sectionId === 'register-page') {
|
||||
// Ensure me-page is hidden when register-page is shown
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) mePage.hidden = true;
|
||||
}
|
||||
|
||||
section.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
// Close mobile menu if open
|
||||
const burger = document.getElementById('burger-toggle');
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initProfilePlayer() {
|
||||
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
|
||||
showProfilePlayerFromUrl();
|
||||
}
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Handle magic link redirect if needed
|
||||
handleMagicLoginRedirect();
|
||||
|
||||
// Initialize components
|
||||
initNavigation();
|
||||
|
||||
// Initialize profile player after a short delay
|
||||
setTimeout(() => {
|
||||
initProfilePlayer();
|
||||
|
||||
// Set up play/pause button click handler
|
||||
document.addEventListener('click', (e) => {
|
||||
const playPauseBtn = e.target.closest('.play-pause-btn');
|
||||
if (!playPauseBtn || playPauseBtn.id === 'logout-button') return;
|
||||
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (!audio) return;
|
||||
|
||||
try {
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
// Stop any currently playing audio first
|
||||
if (window.currentlyPlayingAudio && window.currentlyPlayingAudio !== audio) {
|
||||
window.currentlyPlayingAudio.pause();
|
||||
if (window.currentlyPlayingButton) {
|
||||
updatePlayPauseButton(window.currentlyPlayingAudio, window.currentlyPlayingButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any playing public streams
|
||||
const publicPlayers = document.querySelectorAll('.stream-player audio');
|
||||
publicPlayers.forEach(player => {
|
||||
if (!player.paused) {
|
||||
player.pause();
|
||||
const btn = player.closest('.stream-player').querySelector('.play-pause-btn');
|
||||
if (btn) updatePlayPauseButton(player, btn);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if audio has a valid source before attempting to play
|
||||
// Only show this message for the main player, not public streams
|
||||
if (!audio.src && !playPauseBtn.closest('.stream-player')) {
|
||||
console.log('No audio source available for main player');
|
||||
showToast('No audio file available. Please upload an audio file first.', 'info');
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton(audio, playPauseBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the current play promise to handle aborts
|
||||
const playPromise = audio.play();
|
||||
|
||||
// Handle successful play
|
||||
playPromise.then(() => {
|
||||
// Only update state if this is still the current play action
|
||||
if (audio === getMainAudio()) {
|
||||
window.currentlyPlayingAudio = audio;
|
||||
window.currentlyPlayingButton = playPauseBtn;
|
||||
updatePlayPauseButton(audio, playPauseBtn);
|
||||
}
|
||||
}).catch(e => {
|
||||
// Don't log aborted errors as they're normal during rapid play/pause
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('Play failed:', e);
|
||||
} else {
|
||||
console.log('Playback was aborted as expected');
|
||||
return; // Skip UI updates for aborted play
|
||||
}
|
||||
|
||||
// Only update state if this is still the current audio element
|
||||
if (audio === getMainAudio()) {
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton(audio, playPauseBtn);
|
||||
|
||||
// Provide more specific error messages
|
||||
if (e.name === 'NotSupportedError' || e.name === 'NotAllowedError') {
|
||||
showToast('Could not play audio. The format may not be supported.', 'error');
|
||||
} else if (e.name !== 'AbortError') { // Skip toast for aborted errors
|
||||
showToast('Failed to play audio. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
if (window.currentlyPlayingAudio === audio) {
|
||||
window.currentlyPlayingAudio = null;
|
||||
window.currentlyPlayingButton = null;
|
||||
}
|
||||
updatePlayPauseButton(audio, playPauseBtn);
|
||||
}
|
||||
updatePlayPauseButton();
|
||||
} catch (e) {
|
||||
console.error('Audio error:', e);
|
||||
updatePlayPauseButton(audio, playPauseBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
// Set up delete account button if it exists
|
||||
const deleteAccountBtn = document.getElementById('delete-account');
|
||||
if (deleteAccountBtn) {
|
||||
deleteAccountBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
showToast('❌ Bot detected! Please try again.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/delete-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Clear local storage and redirect to home page
|
||||
localStorage.clear();
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete account');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
showToast(`❌ ${error.message || 'Failed to delete account'}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}, 200); // End of setTimeout
|
||||
});
|
||||
|
||||
// Expose functions for global access
|
||||
window.logToServer = logToServer;
|
||||
window.getMainAudio = () => globalAudio;
|
||||
window.stopMainAudio = () => {
|
||||
if (globalAudio) {
|
||||
globalAudio.pause();
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
window.loadProfileStream = loadProfileStream;
|
||||
|
208
static/css/base.css
Normal file
208
static/css/base.css
Normal file
@ -0,0 +1,208 @@
|
||||
/* Base styles and resets */
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-primary: #4a90e2;
|
||||
--color-primary-dark: #2a6fc9;
|
||||
--color-text: #333;
|
||||
--color-text-light: #666;
|
||||
--color-bg: #f8f9fa;
|
||||
--color-border: #e9ecef;
|
||||
--color-white: #fff;
|
||||
--color-black: #000;
|
||||
|
||||
/* Typography */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
--font-size-base: 1rem;
|
||||
--line-height-base: 1.5;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
|
||||
/* Border radius */
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-base: all 0.2s ease;
|
||||
--transition-slow: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-family);
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 6rem 1.5rem 2rem; /* Add top padding to account for fixed header */
|
||||
min-height: calc(100vh - 200px); /* Ensure footer stays at bottom */
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
color: var(--color-primary);
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.main-heading {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 2rem 0;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-heading .mic-icon {
|
||||
display: inline-flex;
|
||||
animation: pulse 2s infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
padding-left: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.app-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-white);
|
||||
z-index: 9999;
|
||||
transition: opacity var(--transition-slow);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-loading > div:first-child {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.app-loading.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
opacity: 1;
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
/* This class can be used for initial fade-in if needed */
|
||||
.app-content.initial-load {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-content.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
0
static/css/components/buttons.css
Normal file
0
static/css/components/buttons.css
Normal file
0
static/css/components/forms.css
Normal file
0
static/css/components/forms.css
Normal file
80
static/css/layout/footer.css
Normal file
80
static/css/layout/footer.css
Normal file
@ -0,0 +1,80 @@
|
||||
/* Footer styles */
|
||||
footer {
|
||||
background: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 2rem 0;
|
||||
margin-top: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #ecf0f1;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-links a:hover,
|
||||
.footer-links a:focus {
|
||||
color: #3498db;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #7f8c8d;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #bdc3c7;
|
||||
}
|
||||
|
||||
.footer-hint a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-hint a:hover,
|
||||
.footer-hint a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767px) {
|
||||
footer {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
149
static/css/layout/header.css
Normal file
149
static/css/layout/header.css
Normal file
@ -0,0 +1,149 @@
|
||||
/* Header and navigation styles */
|
||||
header {
|
||||
width: 100%;
|
||||
background: rgba(33, 37, 41, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
text-decoration: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Menu toggle button */
|
||||
.menu-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: none; /* Hidden by default, shown on mobile */
|
||||
}
|
||||
|
||||
/* Navigation list */
|
||||
.nav-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link:focus {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Active navigation item */
|
||||
.nav-link.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Mobile menu */
|
||||
@media (max-width: 767px) {
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.nav-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
height: 100vh;
|
||||
background: rgba(33, 37, 41, 0.98);
|
||||
padding: 5rem 1.5rem 2rem;
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-wrapper.active {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link:focus {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
0
static/css/pages/auth.css
Normal file
0
static/css/pages/auth.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/stream.css
Normal file
0
static/css/pages/stream.css
Normal file
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/typography.css
Normal file
0
static/css/utilities/typography.css
Normal file
@ -8,44 +8,250 @@ function getCookie(name) {
|
||||
}
|
||||
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
|
||||
|
||||
// Logout function
|
||||
let isLoggingOut = false;
|
||||
|
||||
async function handleLogout(event) {
|
||||
// Prevent multiple simultaneous logout attempts
|
||||
if (isLoggingOut) return;
|
||||
isLoggingOut = true;
|
||||
|
||||
// Prevent default button behavior
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[LOGOUT] Starting logout process');
|
||||
|
||||
// Clear user data from localStorage
|
||||
localStorage.removeItem('uid');
|
||||
localStorage.removeItem('uid_time');
|
||||
localStorage.removeItem('confirmed_uid');
|
||||
localStorage.removeItem('last_page');
|
||||
|
||||
// Clear cookie
|
||||
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
|
||||
// Update UI state immediately
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const logoutButton = document.getElementById('logout-button');
|
||||
const deleteAccountButton = document.getElementById('delete-account-button');
|
||||
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
if (logoutButton) logoutButton.style.display = 'none';
|
||||
if (deleteAccountButton) deleteAccountButton.style.display = 'none';
|
||||
|
||||
// Show success message (only once)
|
||||
if (window.showToast) {
|
||||
showToast('Logged out successfully');
|
||||
} else {
|
||||
console.log('Logged out successfully');
|
||||
}
|
||||
|
||||
// Navigate to register page
|
||||
if (window.showOnly) {
|
||||
window.showOnly('register-page');
|
||||
} else {
|
||||
// Fallback to URL change if showOnly isn't available
|
||||
window.location.href = '/#register-page';
|
||||
}
|
||||
|
||||
console.log('[LOGOUT] Logout completed');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[LOGOUT] Logout failed:', error);
|
||||
if (window.showToast) {
|
||||
showToast('Logout failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
isLoggingOut = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete account function
|
||||
async function handleDeleteAccount() {
|
||||
try {
|
||||
const uid = localStorage.getItem('uid');
|
||||
if (!uid) {
|
||||
showToast('No user session found. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
const confirmed = confirm('⚠️ WARNING: This will permanently delete your account and all your data. This action cannot be undone.\n\nAre you sure you want to delete your account?');
|
||||
|
||||
if (!confirmed) {
|
||||
return; // User cancelled the deletion
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteButton = document.getElementById('delete-account-button');
|
||||
const originalText = deleteButton.textContent;
|
||||
deleteButton.disabled = true;
|
||||
deleteButton.textContent = 'Deleting...';
|
||||
|
||||
// Call the delete account endpoint
|
||||
const response = await fetch(`/api/delete-account`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ uid }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Account deleted successfully');
|
||||
|
||||
// Clear user data
|
||||
localStorage.removeItem('uid');
|
||||
localStorage.removeItem('uid_time');
|
||||
localStorage.removeItem('confirmed_uid');
|
||||
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
|
||||
// Redirect to home page
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(result.detail || 'Failed to delete account');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete account failed:', error);
|
||||
showToast(`Failed to delete account: ${error.message}`);
|
||||
|
||||
// Reset button state
|
||||
const deleteButton = document.getElementById('delete-account-button');
|
||||
if (deleteButton) {
|
||||
deleteButton.disabled = false;
|
||||
deleteButton.textContent = '🗑️ Delete Account';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initDashboard() {
|
||||
// New dashboard toggling logic
|
||||
console.log('[DASHBOARD] Initializing dashboard...');
|
||||
|
||||
// Get all dashboard elements
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const userUpload = document.getElementById('user-upload-area');
|
||||
|
||||
// Hide all by default
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
const logoutButton = document.getElementById('logout-button');
|
||||
const deleteAccountButton = document.getElementById('delete-account-button');
|
||||
|
||||
console.log('[DASHBOARD] Elements found:', {
|
||||
guestDashboard: !!guestDashboard,
|
||||
userDashboard: !!userDashboard,
|
||||
userUpload: !!userUpload,
|
||||
logoutButton: !!logoutButton,
|
||||
deleteAccountButton: !!deleteAccountButton
|
||||
});
|
||||
|
||||
// Add click event listeners for logout and delete account buttons
|
||||
if (logoutButton) {
|
||||
console.log('[DASHBOARD] Adding logout button handler');
|
||||
logoutButton.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
if (deleteAccountButton) {
|
||||
console.log('[DASHBOARD] Adding delete account button handler');
|
||||
deleteAccountButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteAccount();
|
||||
});
|
||||
}
|
||||
|
||||
const uid = getCookie('uid');
|
||||
console.log('[DASHBOARD] UID from cookie:', uid);
|
||||
|
||||
// Guest view
|
||||
if (!uid) {
|
||||
// Guest view: only nav
|
||||
if (guestDashboard) guestDashboard.style.display = '';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
console.log('[DASHBOARD] No UID found, showing guest dashboard');
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
if (logoutButton) logoutButton.style.display = 'none';
|
||||
if (deleteAccountButton) deleteAccountButton.style.display = 'none';
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) mePage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged-in view - show user dashboard by default
|
||||
console.log('[DASHBOARD] User is logged in, showing user dashboard');
|
||||
|
||||
// Log current display states
|
||||
console.log('[DASHBOARD] Current display states:', {
|
||||
guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found',
|
||||
userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found',
|
||||
userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found',
|
||||
logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found',
|
||||
deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found'
|
||||
});
|
||||
|
||||
// Show delete account button for logged-in users
|
||||
if (deleteAccountButton) {
|
||||
deleteAccountButton.style.display = 'block';
|
||||
console.log('[DASHBOARD] Showing delete account button');
|
||||
}
|
||||
|
||||
// Hide guest dashboard
|
||||
if (guestDashboard) {
|
||||
console.log('[DASHBOARD] Hiding guest dashboard');
|
||||
guestDashboard.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show user dashboard
|
||||
if (userDashboard) {
|
||||
console.log('[DASHBOARD] Showing user dashboard');
|
||||
userDashboard.style.display = 'block';
|
||||
userDashboard.style.visibility = 'visible';
|
||||
userDashboard.hidden = false;
|
||||
|
||||
// Debug: Check if the element is actually in the DOM
|
||||
console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement);
|
||||
console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display);
|
||||
} else {
|
||||
console.error('[DASHBOARD] userDashboard element not found!');
|
||||
}
|
||||
|
||||
// Show essential elements for logged-in users
|
||||
const linksSection = document.getElementById('links');
|
||||
if (linksSection) {
|
||||
console.log('[DASHBOARD] Showing links section');
|
||||
linksSection.style.display = 'block';
|
||||
}
|
||||
|
||||
const showMeLink = document.getElementById('show-me');
|
||||
if (showMeLink && showMeLink.parentElement) {
|
||||
console.log('[DASHBOARD] Showing show-me link');
|
||||
showMeLink.parentElement.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show me-page for logged-in users
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) mePage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (mePage) {
|
||||
console.log('[DASHBOARD] Showing me-page');
|
||||
mePage.style.display = 'block';
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[DEBUG] Fetching user data for UID: ${uid}`);
|
||||
const res = await fetch(`/me/${uid}`);
|
||||
if (!res.ok) throw new Error('Not authorized');
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error(`[ERROR] Failed to fetch user data: ${res.status} ${res.statusText}`, errorText);
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Logged-in view
|
||||
// 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 = '';
|
||||
console.log('[DEBUG] User data loaded:', data);
|
||||
|
||||
// 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
|
||||
}
|
||||
@ -53,19 +259,40 @@ async function initDashboard() {
|
||||
// Remove guest warning if present
|
||||
const guestMsg = document.getElementById('guest-warning-msg');
|
||||
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
|
||||
userDashboard.style.display = '';
|
||||
// Show user dashboard and logout button
|
||||
if (userDashboard) userDashboard.style.display = '';
|
||||
if (logoutButton) {
|
||||
logoutButton.style.display = 'block';
|
||||
logoutButton.onclick = handleLogout;
|
||||
}
|
||||
|
||||
// Set audio source
|
||||
const meAudio = document.getElementById('me-audio');
|
||||
if (meAudio && uid) {
|
||||
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`;
|
||||
if (meAudio && data && data.username) {
|
||||
// Use username instead of UID for the audio file path
|
||||
meAudio.src = `/audio/${encodeURIComponent(data.username)}/stream.opus?t=${Date.now()}`;
|
||||
console.log('Setting audio source to:', meAudio.src);
|
||||
} else if (meAudio && uid) {
|
||||
// Fallback to UID if username is not available
|
||||
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
|
||||
console.warn('Using UID fallback for audio source:', meAudio.src);
|
||||
}
|
||||
|
||||
// Update quota
|
||||
// Update quota and ensure quota meter is visible
|
||||
const quotaMeter = document.getElementById('quota-meter');
|
||||
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`;
|
||||
if (quotaMeter) {
|
||||
quotaMeter.hidden = false;
|
||||
quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none
|
||||
}
|
||||
|
||||
// Fetch and display the list of uploaded files if the function is available
|
||||
if (window.fetchAndDisplayFiles) {
|
||||
window.fetchAndDisplayFiles(uid);
|
||||
}
|
||||
|
||||
// Ensure Streams link remains in nav, not moved
|
||||
// (No action needed if static)
|
||||
|
97
static/desktop.css
Normal file
97
static/desktop.css
Normal file
@ -0,0 +1,97 @@
|
||||
/* Desktop-specific styles for screens 960px and wider */
|
||||
@media (min-width: 960px) {
|
||||
html {
|
||||
background-color: #111 !important;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(188, 183, 107, 0.1) 0, /* Olive color */
|
||||
rgba(188, 183, 107, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(188, 183, 107, 0.1) 0, /* Olive color */
|
||||
rgba(188, 183, 107, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 20px
|
||||
) !important;
|
||||
background-size: 40px 40px !important;
|
||||
background-repeat: repeat !important;
|
||||
background-attachment: fixed !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent !important;
|
||||
min-height: 100vh !important;
|
||||
}
|
||||
/* Section styles are now handled in style.css */
|
||||
|
||||
nav.dashboard-nav a {
|
||||
padding: 5px;
|
||||
margin: 0 0.5em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Reset mobile-specific styles for desktop */
|
||||
.dashboard-nav {
|
||||
padding: 0.5em;
|
||||
max-width: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-nav a {
|
||||
min-width: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Global article styles */
|
||||
main > section > article,
|
||||
#stream-page > article {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2em auto;
|
||||
padding: 2em;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Stream player styles */
|
||||
#stream-page #stream-list > li {
|
||||
list-style: none;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
#stream-page #stream-list > li .stream-player {
|
||||
padding: 1.5em;
|
||||
background: #1e1e1e;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hover states - only apply to direct article children of sections */
|
||||
main > section > article:hover {
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
|
||||
border: 1px solid #ff6600;
|
||||
}
|
||||
|
||||
/* Stream list desktop styles */
|
||||
#stream-list {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* User upload area desktop styles */
|
||||
#user-upload-area {
|
||||
max-width: 600px !important;
|
||||
width: 100% !important;
|
||||
margin: 1.5rem auto !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
12
static/footer.html
Normal file
12
static/footer.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!-- Footer content -->
|
||||
<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>
|
||||
<div class="footer-links">
|
||||
<a href="#" data-target="terms-page">Terms</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="#" data-target="privacy-page">Privacy</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="#" data-target="imprint-page">Imprint</a>
|
||||
</div>
|
||||
</footer>
|
13
static/generate-test-audio.sh
Executable file
13
static/generate-test-audio.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create a 1-second silent audio file in Opus format
|
||||
ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a libopus -b:a 60k /home/oib/games/dicta2stream/static/test-audio.opus
|
||||
|
||||
# Verify the file was created
|
||||
if [ -f "/home/oib/games/dicta2stream/static/test-audio.opus" ]; then
|
||||
echo "Test audio file created successfully at /home/oib/games/dicta2stream/static/test-audio.opus"
|
||||
echo "File size: $(du -h /home/oib/games/dicta2stream/static/test-audio.opus | cut -f1)"
|
||||
else
|
||||
echo "Failed to create test audio file"
|
||||
exit 1
|
||||
fi
|
@ -3,6 +3,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/style.css" media="all" />
|
||||
<link rel="stylesheet" href="/static/desktop.css" media="(min-width: 960px)">
|
||||
<link rel="stylesheet" href="/static/mobile.css" media="(max-width: 959px)">
|
||||
<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" />
|
||||
@ -32,24 +34,32 @@
|
||||
|
||||
<!-- 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>
|
||||
<a href="#welcome-page" id="guest-welcome">Welcome</a>
|
||||
<a href="#stream-page" id="guest-streams">Streams</a>
|
||||
<a href="#register-page" id="guest-login">Account</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>
|
||||
<a href="#welcome-page" id="user-welcome">Welcome</a>
|
||||
<a href="#stream-page" id="user-streams">Streams</a>
|
||||
<a href="#me-page" id="show-me">Your Stream</a>
|
||||
</nav>
|
||||
<section id="me-page">
|
||||
<div style="position: relative; margin: 0 0 1.5rem 0; text-align: center;">
|
||||
<h2 style="margin: 0; padding: 0; line-height: 1; display: inline-block; position: relative; text-align: center;">
|
||||
Your Stream
|
||||
</h2>
|
||||
<div style="position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: flex; gap: 0.5rem;">
|
||||
<button id="delete-account-button" class="delete-account-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer;">🗑️ Delete Account</button>
|
||||
<button id="logout-button" class="logout-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none;">🚪 LogOut</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button class="play-pause-btn" type="button" aria-label="Play">▶️</button>
|
||||
</div>
|
||||
</article>
|
||||
<section id="user-upload-area" class="dropzone">
|
||||
@ -65,9 +75,9 @@
|
||||
<!-- Burger menu and legacy links section removed for clarity -->
|
||||
|
||||
<section id="terms-page" hidden>
|
||||
<h2>Terms of Service</h2>
|
||||
<article>
|
||||
<h2>Terms of Service</h2>
|
||||
<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>
|
||||
<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>Each account must be unique and used by only one person.</li>
|
||||
@ -77,13 +87,12 @@
|
||||
<li>Uploads are limited to 100 MB and must be voice only.</li>
|
||||
<li>Music/singing will be rejected.</li>
|
||||
</ul>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="privacy-page" hidden>
|
||||
<h2>Privacy Policy</h2>
|
||||
<article>
|
||||
<h2>Privacy Policy</h2>
|
||||
<ul>
|
||||
<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>
|
||||
@ -91,22 +100,20 @@
|
||||
<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>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="imprint-page" hidden>
|
||||
<h2>Imprint</h2>
|
||||
<article>
|
||||
<h2>Imprint</h2>
|
||||
<p><strong>Andreas Michael Fleckl</strong></p>
|
||||
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="welcome-page">
|
||||
<h2>Welcome</h2>
|
||||
<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>
|
||||
@ -119,16 +126,14 @@
|
||||
</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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section id="register-page" hidden>
|
||||
<h2>Account</h2>
|
||||
<article>
|
||||
<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>
|
||||
@ -137,7 +142,7 @@
|
||||
<input type="text" name="bot_trap" autocomplete="off" />
|
||||
</label>
|
||||
</p>
|
||||
<p><button type="submit">Create Account</button></p>
|
||||
<p><button type="submit">Login / Create Account</button></p>
|
||||
</form>
|
||||
<p><small>You’ll receive a magic login link via email. No password required.</small></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>
|
||||
@ -147,6 +152,12 @@
|
||||
|
||||
<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>
|
||||
<div id="uploaded-files" style="margin-top: 10px; font-size: 0.9em;">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">Uploaded Files:</div>
|
||||
<div id="file-list" style="max-height: 200px; overflow-y: auto; border: 1px solid #333; padding: 5px; border-radius: 4px; background: #1a1a1a;">
|
||||
<div style="padding: 5px 0; color: #888; font-style: italic;">Loading files...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -157,8 +168,8 @@
|
||||
<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-terms" data-target="terms-page">Terms</a> |
|
||||
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
|
||||
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
437
static/inject-nav.js
Normal file
437
static/inject-nav.js
Normal file
@ -0,0 +1,437 @@
|
||||
// inject-nav.js - Handles dynamic injection and management of navigation elements
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
// Menu state
|
||||
let isMenuOpen = false;
|
||||
|
||||
// Export the injectNavigation function
|
||||
export function injectNavigation(isAuthenticated = false) {
|
||||
console.log('injectNavigation called with isAuthenticated:', isAuthenticated);
|
||||
const navContainer = document.getElementById('main-navigation');
|
||||
if (!navContainer) {
|
||||
console.error('Navigation container not found. Looking for #main-navigation');
|
||||
console.log('Available elements with id:', document.querySelectorAll('[id]'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing content
|
||||
navContainer.innerHTML = '';
|
||||
|
||||
console.log('Creating navigation...');
|
||||
try {
|
||||
// Create the navigation wrapper
|
||||
const navWrapper = document.createElement('nav');
|
||||
navWrapper.className = 'nav-wrapper';
|
||||
|
||||
// Create the navigation content
|
||||
const nav = isAuthenticated ? createUserNav() : createGuestNav();
|
||||
console.log('Navigation HTML created:', nav.outerHTML);
|
||||
|
||||
// Append navigation to wrapper
|
||||
navWrapper.appendChild(nav);
|
||||
|
||||
// Append to container
|
||||
navContainer.appendChild(navWrapper);
|
||||
|
||||
console.log('Navigation appended to container');
|
||||
|
||||
// Initialize menu toggle after navigation is injected
|
||||
setupMenuToggle();
|
||||
|
||||
// Set up menu links
|
||||
setupMenuLinks();
|
||||
|
||||
// Add click handler for the logo to navigate home
|
||||
const logo = document.querySelector('.logo');
|
||||
if (logo) {
|
||||
logo.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showOnly('welcome');
|
||||
closeMenu();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating navigation:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up menu toggle for mobile
|
||||
setupMenuToggle();
|
||||
|
||||
// Set up menu links
|
||||
setupMenuLinks();
|
||||
|
||||
// Close menu when clicking on a nav link on mobile
|
||||
const navLinks = navContainer.querySelectorAll('.nav-link');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
if (window.innerWidth < 768) { // Mobile breakpoint
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handler for the logo to navigate home
|
||||
const logo = document.querySelector('.logo');
|
||||
if (logo) {
|
||||
logo.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showOnly('welcome');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to create the guest navigation
|
||||
function createGuestNav() {
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'dashboard-nav';
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'Main navigation');
|
||||
|
||||
const navList = document.createElement('ul');
|
||||
navList.className = 'nav-list';
|
||||
|
||||
const links = [
|
||||
{ id: 'nav-login', target: 'login', text: 'Login / Register' },
|
||||
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
|
||||
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' }
|
||||
];
|
||||
|
||||
// Create and append links
|
||||
links.forEach((link) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'nav-item';
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.id = link.id;
|
||||
a.href = `#${link.target}`;
|
||||
a.className = 'nav-link';
|
||||
a.setAttribute('data-target', link.target);
|
||||
a.textContent = link.text;
|
||||
|
||||
// Add click handler for navigation
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget.getAttribute('data-target');
|
||||
if (target) {
|
||||
window.location.hash = target;
|
||||
if (window.router && typeof window.router.showOnly === 'function') {
|
||||
window.router.showOnly(target);
|
||||
}
|
||||
// Close menu on mobile after clicking a link
|
||||
if (window.innerWidth < 768) {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
li.appendChild(a);
|
||||
navList.appendChild(li);
|
||||
});
|
||||
|
||||
nav.appendChild(navList);
|
||||
return nav;
|
||||
}
|
||||
|
||||
function createUserNav() {
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'dashboard-nav';
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'User navigation');
|
||||
|
||||
const navList = document.createElement('ul');
|
||||
navList.className = 'nav-list';
|
||||
|
||||
const links = [
|
||||
{ id: 'user-stream', target: 'your-stream', text: 'Your Stream' },
|
||||
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
|
||||
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' },
|
||||
{ id: 'user-logout', target: 'logout', text: 'Logout' }
|
||||
];
|
||||
|
||||
// Create and append links
|
||||
links.forEach((link, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'nav-item';
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.id = link.id;
|
||||
a.href = '#';
|
||||
a.className = 'nav-link';
|
||||
|
||||
// Special handling for logout
|
||||
if (link.target === 'logout') {
|
||||
a.href = '#';
|
||||
a.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
// Use the handleLogout function from dashboard.js if available
|
||||
if (typeof handleLogout === 'function') {
|
||||
await handleLogout();
|
||||
} else {
|
||||
// Fallback in case handleLogout is not available
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('uid');
|
||||
localStorage.removeItem('uid_time');
|
||||
localStorage.removeItem('confirmed_uid');
|
||||
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
window.location.href = '/';
|
||||
}
|
||||
window.location.href = '#';
|
||||
// Force reload to reset the app state
|
||||
window.location.reload();
|
||||
});
|
||||
} else {
|
||||
a.setAttribute('data-target', link.target);
|
||||
}
|
||||
|
||||
a.textContent = link.text;
|
||||
|
||||
li.appendChild(a);
|
||||
navList.appendChild(li);
|
||||
});
|
||||
|
||||
nav.appendChild(navList);
|
||||
return nav;
|
||||
}
|
||||
|
||||
// Set up menu toggle functionality
|
||||
function setupMenuToggle() {
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const navWrapper = document.querySelector('.nav-wrapper');
|
||||
|
||||
if (!menuToggle || !navWrapper) return;
|
||||
|
||||
menuToggle.addEventListener('click', toggleMenu);
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (isMenuOpen && !navWrapper.contains(e.target) && !menuToggle.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isMenuOpen) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when resizing to desktop
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle mobile menu
|
||||
function toggleMenu(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const navWrapper = document.querySelector('.nav-wrapper');
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
|
||||
if (!navWrapper || !menuToggle) return;
|
||||
|
||||
isMenuOpen = !isMenuOpen;
|
||||
|
||||
if (isMenuOpen) {
|
||||
// Open menu
|
||||
navWrapper.classList.add('active');
|
||||
menuToggle.setAttribute('aria-expanded', 'true');
|
||||
menuToggle.innerHTML = '✕';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus the first link in the menu for better keyboard navigation
|
||||
const firstLink = navWrapper.querySelector('a');
|
||||
if (firstLink) firstLink.focus();
|
||||
|
||||
// Add click outside handler
|
||||
document._handleClickOutside = (e) => {
|
||||
if (!navWrapper.contains(e.target) && e.target !== menuToggle) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', document._handleClickOutside);
|
||||
|
||||
// Add escape key handler
|
||||
document._handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', document._handleEscape);
|
||||
} else {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu function
|
||||
function closeMenu() {
|
||||
const navWrapper = document.querySelector('.nav-wrapper');
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
|
||||
if (!navWrapper || !menuToggle) return;
|
||||
|
||||
isMenuOpen = false;
|
||||
navWrapper.classList.remove('active');
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
menuToggle.innerHTML = '☰';
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Remove event listeners
|
||||
if (document._handleClickOutside) {
|
||||
document.removeEventListener('click', document._handleClickOutside);
|
||||
delete document._handleClickOutside;
|
||||
}
|
||||
|
||||
if (document._handleEscape) {
|
||||
document.removeEventListener('keydown', document._handleEscape);
|
||||
delete document._handleEscape;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize menu toggle on page load
|
||||
function initializeMenuToggle() {
|
||||
console.log('Initializing menu toggle...');
|
||||
const menuToggle = document.getElementById('menu-toggle');
|
||||
|
||||
if (!menuToggle) {
|
||||
console.error('Main menu toggle button not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Menu toggle button found:', menuToggle);
|
||||
|
||||
// Remove any existing click listeners
|
||||
const newToggle = menuToggle.cloneNode(true);
|
||||
if (menuToggle.parentNode) {
|
||||
menuToggle.parentNode.replaceChild(newToggle, menuToggle);
|
||||
console.log('Replaced menu toggle button');
|
||||
} else {
|
||||
console.error('Menu toggle has no parent node!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add click handler to the new toggle
|
||||
newToggle.addEventListener('click', function(event) {
|
||||
console.log('Menu toggle clicked!', event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleMenu(event);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Also handle the header menu toggle if it exists
|
||||
const headerMenuToggle = document.getElementById('header-menu-toggle');
|
||||
if (headerMenuToggle) {
|
||||
console.log('Header menu toggle found, syncing with main menu');
|
||||
headerMenuToggle.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
newToggle.click(); // Trigger the main menu toggle
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('DOM fully loaded and parsed');
|
||||
|
||||
// Initialize navigation based on authentication state
|
||||
// This will be set by the main app after checking auth status
|
||||
if (window.initializeNavigation) {
|
||||
window.initializeNavigation();
|
||||
}
|
||||
|
||||
// Initialize menu toggle
|
||||
initializeMenuToggle();
|
||||
|
||||
// Also try to initialize after a short delay in case the DOM changes
|
||||
setTimeout(initializeMenuToggle, 500);
|
||||
});
|
||||
|
||||
// Navigation injection function
|
||||
export function injectNavigation(isAuthenticated = false) {
|
||||
console.log('Injecting navigation, isAuthenticated:', isAuthenticated);
|
||||
const container = document.getElementById('main-navigation');
|
||||
const navWrapper = document.querySelector('.nav-wrapper');
|
||||
|
||||
if (!container || !navWrapper) {
|
||||
console.error('Navigation elements not found. Looking for #main-navigation and .nav-wrapper');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store scroll position
|
||||
const scrollPosition = window.scrollY;
|
||||
|
||||
// Clear existing navigation
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create the appropriate navigation based on authentication status
|
||||
const nav = isAuthenticated ? createUserNav() : createGuestNav();
|
||||
|
||||
// Append the navigation to the container
|
||||
container.appendChild(nav);
|
||||
|
||||
// Set up menu toggle functionality
|
||||
setupMenuToggle();
|
||||
|
||||
// Set up navigation links
|
||||
setupMenuLinks();
|
||||
|
||||
// Show the appropriate page based on URL
|
||||
if (window.location.hash === '#streams' || window.location.pathname === '/streams') {
|
||||
showOnly('stream-page');
|
||||
if (typeof window.maybeLoadStreamsOnShow === 'function') {
|
||||
window.maybeLoadStreamsOnShow();
|
||||
}
|
||||
} else if (!window.location.hash || window.location.hash === '#') {
|
||||
// Show welcome page by default if no hash
|
||||
showOnly('welcome-page');
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollPosition);
|
||||
|
||||
return nav;
|
||||
} catch (error) {
|
||||
console.error('Error injecting navigation:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up menu links with click handlers
|
||||
function setupMenuLinks() {
|
||||
// Handle navigation link clicks
|
||||
document.addEventListener('click', function(e) {
|
||||
// Check if click is on a nav link or its children
|
||||
let link = e.target.closest('.nav-link');
|
||||
if (!link) return;
|
||||
|
||||
const target = link.getAttribute('data-target');
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
console.log('Navigation link clicked:', target);
|
||||
showOnly(target);
|
||||
closeMenu();
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.nav-link').forEach(l => {
|
||||
l.classList.remove('active');
|
||||
});
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make the function available globally for debugging
|
||||
window.injectNavigation = injectNavigation;
|
@ -47,9 +47,22 @@ export async function initMagicLogin() {
|
||||
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);
|
||||
import('./toast.js').then(({ showToast }) => {
|
||||
showToast('✅ Login successful!');
|
||||
// Update UI state after login
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const registerPage = document.getElementById('register-page');
|
||||
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
if (userDashboard) userDashboard.style.display = 'block';
|
||||
if (registerPage) registerPage.style.display = 'none';
|
||||
|
||||
// Show the user's stream page
|
||||
if (window.showOnly) {
|
||||
window.showOnly('me-page');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(data.detail || 'Login failed.');
|
||||
|
305
static/mobile.css
Normal file
305
static/mobile.css
Normal file
@ -0,0 +1,305 @@
|
||||
/* Mobile-specific styles for screens up to 959px */
|
||||
@media (max-width: 959px) {
|
||||
/* Base layout adjustments */
|
||||
html {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 16px;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.dashboard-nav {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-nav a {
|
||||
padding: 0.5rem;
|
||||
margin: 0 0.25rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
main > section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
#quota-meter {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.quota-meter {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stream-item {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#burger-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
section#links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1e1e1e;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#burger-toggle:checked + #burger-label + section#links {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Make sure all interactive elements are touch-friendly */
|
||||
a, [role="button"], label, select, textarea {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dropzone p {
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Adjust header text for better mobile display */
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-nav {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dashboard-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav.dashboard-nav a {
|
||||
all: unset;
|
||||
display: inline-block;
|
||||
background-color: #1e1e1e;
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-nav a:active {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Stream page specific styles */
|
||||
#stream-page {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#stream-page h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#stream-page article {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#stream-list {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#stream-list li {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-player {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stream-player h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#stream-list > li {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* User upload area */
|
||||
#user-upload-area {
|
||||
margin: 1rem 0;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 2px dashed #666;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#user-upload-area p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Stream player adjustments */
|
||||
.stream-player {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.stream-player h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
font-size: 0.9rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Adjust audio element for mobile */
|
||||
audio {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
width: 90%;
|
||||
max-width: 100%;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
transform: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
@ -11,9 +11,40 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const Router = {
|
||||
sections: Array.from(document.querySelectorAll("main > section")),
|
||||
showOnly(id) {
|
||||
// Define which sections are part of the 'Your Stream' section
|
||||
const yourStreamSections = ['me-page', 'register-page', 'quota-meter'];
|
||||
const isYourStreamSection = yourStreamSections.includes(id);
|
||||
|
||||
// Handle the quota meter visibility - only show with 'me-page'
|
||||
const quotaMeter = document.getElementById('quota-meter');
|
||||
if (quotaMeter) {
|
||||
quotaMeter.hidden = id !== 'me-page';
|
||||
quotaMeter.tabIndex = id === 'me-page' ? 0 : -1;
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const isLoggedIn = !!getCookie('uid');
|
||||
|
||||
// Handle all sections
|
||||
this.sections.forEach(sec => {
|
||||
sec.hidden = sec.id !== id;
|
||||
sec.tabIndex = -1;
|
||||
// Skip quota meter as it's already handled
|
||||
if (sec.id === 'quota-meter') return;
|
||||
|
||||
// Special handling for register page - only show to guests
|
||||
if (sec.id === 'register-page') {
|
||||
sec.hidden = isLoggedIn || id !== 'register-page';
|
||||
sec.tabIndex = (!isLoggedIn && id === 'register-page') ? 0 : -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the section if it matches the target ID
|
||||
// OR if it's a 'Your Stream' section and we're in a 'Your Stream' context
|
||||
const isSectionInYourStream = yourStreamSections.includes(sec.id);
|
||||
const shouldShow = (sec.id === id) ||
|
||||
(isYourStreamSection && isSectionInYourStream);
|
||||
|
||||
sec.hidden = !shouldShow;
|
||||
sec.tabIndex = shouldShow ? 0 : -1;
|
||||
});
|
||||
// Show user-upload-area only when me-page is shown and user is logged in
|
||||
const userUpload = document.getElementById("user-upload-area");
|
||||
|
@ -1,10 +1,12 @@
|
||||
// static/streams-ui.js — public streams loader and profile-link handling
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
console.log('[streams-ui] Module loaded');
|
||||
|
||||
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
|
||||
|
||||
|
||||
export function initStreamsUI() {
|
||||
console.log('[streams-ui] Initializing streams UI');
|
||||
initStreamLinks();
|
||||
window.addEventListener('popstate', () => {
|
||||
highlightActiveProfileLink();
|
||||
@ -24,145 +26,314 @@ function maybeLoadStreamsOnShow() {
|
||||
}
|
||||
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
|
||||
|
||||
// Global variables for audio control
|
||||
let currentlyPlayingAudio = null;
|
||||
let currentlyPlayingButton = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initStreamsUI);
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('[streams-ui] DOM content loaded, initializing streams UI');
|
||||
initStreamsUI();
|
||||
|
||||
// Also try to load streams immediately in case the page is already loaded
|
||||
setTimeout(() => {
|
||||
console.log('[streams-ui] Attempting initial stream load');
|
||||
loadAndRenderStreams();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
function loadAndRenderStreams() {
|
||||
console.log('[streams-ui] loadAndRenderStreams called');
|
||||
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>';
|
||||
|
||||
// Clear any existing error messages or retry buttons
|
||||
ul.innerHTML = '<li>Loading public streams...</li>';
|
||||
|
||||
// Add a timestamp to prevent caching issues
|
||||
const timestamp = new Date().getTime();
|
||||
|
||||
// Use the same protocol as the current page to avoid mixed content issues
|
||||
const baseUrl = window.location.origin;
|
||||
const sseUrl = `${baseUrl}/streams-sse?t=${timestamp}`;
|
||||
|
||||
console.log(`[streams-ui] Connecting to ${sseUrl}`);
|
||||
|
||||
let gotAny = false;
|
||||
let streams = [];
|
||||
// Close previous EventSource if any
|
||||
let connectionTimeout = null;
|
||||
|
||||
// Close previous connection and clear any pending timeouts
|
||||
if (window._streamsSSE) {
|
||||
window._streamsSSE.close();
|
||||
console.log('[streams-ui] Aborting previous connection');
|
||||
if (window._streamsSSE.abort) {
|
||||
window._streamsSSE.abort();
|
||||
}
|
||||
window._streamsSSE = null;
|
||||
}
|
||||
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>';
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
|
||||
console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`);
|
||||
|
||||
// Use fetch with ReadableStream for better CORS handling
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
// Store the controller for cleanup
|
||||
window._streamsSSE = controller;
|
||||
|
||||
// Set a connection timeout
|
||||
connectionTimeout = setTimeout(() => {
|
||||
if (!gotAny) {
|
||||
console.log('[streams-ui] Connection timeout reached, forcing retry...');
|
||||
controller.abort();
|
||||
loadAndRenderStreams();
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
console.log('[streams-ui] Making fetch request to:', sseUrl);
|
||||
|
||||
console.log('[streams-ui] Making fetch request to:', sseUrl);
|
||||
|
||||
console.log('[streams-ui] Creating fetch request with URL:', sseUrl);
|
||||
|
||||
// Make the fetch request
|
||||
fetch(sseUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
signal: signal,
|
||||
// Add mode and redirect options for better error handling
|
||||
mode: 'cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
.then(response => {
|
||||
console.log('[streams-ui] Fetch response received, status:', response.status, response.statusText);
|
||||
console.log('[streams-ui] Response URL:', response.url);
|
||||
console.log('[streams-ui] Response type:', response.type);
|
||||
console.log('[streams-ui] Response redirected:', response.redirected);
|
||||
console.log('[streams-ui] Response headers:');
|
||||
response.headers.forEach((value, key) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get the response text for error details
|
||||
return response.text().then(text => {
|
||||
console.error('[streams-ui] Error response body:', text);
|
||||
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
|
||||
error.response = { status: response.status, statusText: response.statusText, body: text };
|
||||
throw error;
|
||||
}).catch(textError => {
|
||||
console.error('[streams-ui] Could not read error response body:', textError);
|
||||
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
|
||||
error.response = { status: response.status, statusText: response.statusText };
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const error = new Error('Response body is null or undefined');
|
||||
console.error('[streams-ui] No response body:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('[streams-ui] Response body is available, content-type:', response.headers.get('content-type'));
|
||||
|
||||
// Get the readable stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// Process the stream
|
||||
function processStream({ done, value }) {
|
||||
if (done) {
|
||||
console.log('[streams-ui] Stream completed');
|
||||
// Process any remaining data in the buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer);
|
||||
processSSEEvent(data);
|
||||
} catch (e) {
|
||||
console.error('[streams-ui] Error parsing final data:', e);
|
||||
}
|
||||
}
|
||||
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);
|
||||
// Decode the chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Wait for DOM update
|
||||
requestAnimationFrame(() => {
|
||||
const playPauseButton = document.getElementById(`play-pause-${uid}`);
|
||||
const audio = document.getElementById(`audio-${uid}`);
|
||||
// Process complete events in the buffer
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() || ''; // Keep incomplete event in buffer
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.trim()) continue;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Extract data field from SSE format
|
||||
const dataMatch = event.match(/^data: (\{.*\})$/m);
|
||||
if (dataMatch && dataMatch[1]) {
|
||||
try {
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
processSSEEvent(data);
|
||||
} catch (e) {
|
||||
console.error('[streams-ui] Error parsing event data:', e, 'Event:', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read the next chunk
|
||||
return reader.read().then(processStream);
|
||||
}
|
||||
|
||||
// Start reading the stream
|
||||
return reader.read().then(processStream);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[streams-ui] Fetch request failed:', error);
|
||||
|
||||
// Log additional error details
|
||||
if (error.name === 'TypeError') {
|
||||
console.error('[streams-ui] This is likely a network error or CORS issue');
|
||||
if (error.message.includes('fetch')) {
|
||||
console.error('[streams-ui] The fetch request was blocked or failed to reach the server');
|
||||
}
|
||||
if (error.message.includes('CORS')) {
|
||||
console.error('[streams-ui] CORS error detected. Check server CORS configuration');
|
||||
}
|
||||
}
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[streams-ui] Request was aborted');
|
||||
} else {
|
||||
console.error('[streams-ui] Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
constructor: error.constructor.name,
|
||||
errorCode: error.code,
|
||||
errorNumber: error.errno,
|
||||
response: error.response
|
||||
});
|
||||
|
||||
// Show a user-friendly error message
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (ul) {
|
||||
ul.innerHTML = `
|
||||
<li class="error">
|
||||
<p>Error loading streams. Please try again later.</p>
|
||||
<p><small>Technical details: ${error.name}: ${error.message}</small></p>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
handleSSEError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Function to process SSE events
|
||||
function processSSEEvent(data) {
|
||||
console.log('[streams-ui] Received SSE event:', data);
|
||||
|
||||
if (data.end) {
|
||||
console.log('[streams-ui] Received end event, total streams:', streams.length);
|
||||
|
||||
if (streams.length === 0) {
|
||||
console.log('[streams-ui] No streams found, showing empty state');
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort streams by mtime in descending order (newest first)
|
||||
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
||||
console.log('[streams-ui] Sorted streams:', streams);
|
||||
|
||||
// Clear the list
|
||||
ul.innerHTML = '';
|
||||
|
||||
// Render each stream in sorted order
|
||||
streams.forEach((stream, index) => {
|
||||
const uid = stream.uid || `stream-${index}`;
|
||||
const sizeMb = stream.size ? (stream.size / (1024 * 1024)).toFixed(1) : '?';
|
||||
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
|
||||
|
||||
console.log(`[streams-ui] Rendering stream ${index + 1}/${streams.length}:`, { uid, sizeMb, mtime });
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'stream-item';
|
||||
|
||||
try {
|
||||
li.innerHTML = `
|
||||
<article class="stream-player" data-uid="${escapeHtml(uid)}">
|
||||
<h3>${escapeHtml(uid)}</h3>
|
||||
<div class="audio-controls">
|
||||
<button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button>
|
||||
</div>
|
||||
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
|
||||
</article>
|
||||
`;
|
||||
ul.appendChild(li);
|
||||
console.log(`[streams-ui] Successfully rendered stream: ${uid}`);
|
||||
} catch (error) {
|
||||
console.error(`[streams-ui] Error rendering stream ${uid}:`, error);
|
||||
const errorLi = document.createElement('li');
|
||||
errorLi.textContent = `Error loading stream: ${uid}`;
|
||||
errorLi.style.color = 'red';
|
||||
ul.appendChild(errorLi);
|
||||
}
|
||||
});
|
||||
|
||||
highlightActiveProfileLink();
|
||||
ul.appendChild(li);
|
||||
highlightActiveProfileLink();
|
||||
} catch (e) {
|
||||
// Remove Loading... even if JSON parse fails, to avoid stuck UI
|
||||
if (!gotAny) {
|
||||
ul.innerHTML = '';
|
||||
gotAny = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add stream to our collection
|
||||
streams.push(data);
|
||||
|
||||
// If this is the first stream, clear the loading message
|
||||
if (!gotAny) {
|
||||
ul.innerHTML = '';
|
||||
gotAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle SSE errors
|
||||
function handleSSEError(error) {
|
||||
console.error('[streams-ui] SSE error:', error);
|
||||
|
||||
// Only show error if we haven't already loaded any streams
|
||||
if (streams.length === 0) {
|
||||
const errorMsg = 'Error connecting to stream server. Please try again.';
|
||||
|
||||
ul.innerHTML = `
|
||||
<li>${errorMsg}</li>
|
||||
<li><button id="reload-streams" onclick="loadAndRenderStreams()" class="retry-button">🔄 Retry</button></li>
|
||||
`;
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('❌ ' + errorMsg);
|
||||
}
|
||||
console.error('[streams-ui] SSE parse error', e, event.data);
|
||||
|
||||
// Auto-retry after 5 seconds
|
||||
setTimeout(() => {
|
||||
loadAndRenderStreams();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// Error and open handlers are now part of the fetch implementation
|
||||
// Message handling is now part of the fetch implementation
|
||||
// Error handling is now part of the fetch implementation
|
||||
}
|
||||
|
||||
export function renderStreamList(streams) {
|
||||
@ -208,7 +379,6 @@ export function highlightActiveProfileLink() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function initStreamLinks() {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
@ -232,3 +402,387 @@ export function initStreamLinks() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to safely escape HTML
|
||||
function escapeHtml(unsafe) {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Function to update play/pause button state
|
||||
function updatePlayPauseButton(button, isPlaying) {
|
||||
if (!button) return;
|
||||
button.textContent = isPlaying ? '⏸️' : '▶️';
|
||||
button.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
|
||||
}
|
||||
|
||||
// Audio context for Web Audio API
|
||||
let audioContext = null;
|
||||
let audioSource = null;
|
||||
let audioBuffer = null;
|
||||
let isPlaying = false;
|
||||
let currentUid = null;
|
||||
let currentlyPlayingButton = null; // Controls the currently active play/pause button
|
||||
let startTime = 0;
|
||||
let pauseTime = 0;
|
||||
let audioStartTime = 0;
|
||||
let audioElement = null; // HTML5 Audio element for Opus playback
|
||||
|
||||
// Initialize audio context
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// Stop current playback completely
|
||||
function stopPlayback() {
|
||||
console.log('[streams-ui] Stopping playback');
|
||||
|
||||
// Stop Web Audio API if active
|
||||
if (audioSource) {
|
||||
try {
|
||||
// Don't try to stop if already stopped
|
||||
if (audioSource.context && audioSource.context.state !== 'closed') {
|
||||
audioSource.stop();
|
||||
audioSource.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors when stopping already stopped sources
|
||||
if (!e.message.includes('has already been stopped') &&
|
||||
!e.message.includes('has already finished playing')) {
|
||||
console.warn('Error stopping audio source:', e);
|
||||
}
|
||||
}
|
||||
audioSource = null;
|
||||
}
|
||||
|
||||
// Stop HTML5 Audio element if active
|
||||
if (audioElement) {
|
||||
try {
|
||||
// Remove all event listeners first
|
||||
if (audioElement._eventHandlers) {
|
||||
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
|
||||
if (onPlay) audioElement.removeEventListener('play', onPlay);
|
||||
if (onPause) audioElement.removeEventListener('pause', onPause);
|
||||
if (onEnded) audioElement.removeEventListener('ended', onEnded);
|
||||
if (onError) audioElement.removeEventListener('error', onError);
|
||||
}
|
||||
|
||||
// Pause and reset the audio element
|
||||
audioElement.pause();
|
||||
audioElement.removeAttribute('src');
|
||||
audioElement.load();
|
||||
|
||||
// Clear references
|
||||
if (audioElement._eventHandlers) {
|
||||
delete audioElement._eventHandlers;
|
||||
}
|
||||
|
||||
// Nullify the element to allow garbage collection
|
||||
audioElement = null;
|
||||
} catch (e) {
|
||||
console.warn('Error cleaning up audio element:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
audioBuffer = null;
|
||||
isPlaying = false;
|
||||
startTime = 0;
|
||||
pauseTime = 0;
|
||||
audioStartTime = 0;
|
||||
|
||||
// Update UI
|
||||
if (currentlyPlayingButton) {
|
||||
updatePlayPauseButton(currentlyPlayingButton, false);
|
||||
currentlyPlayingButton = null;
|
||||
}
|
||||
|
||||
// Clear current playing reference
|
||||
currentlyPlayingAudio = null;
|
||||
}
|
||||
|
||||
// Load and play audio using HTML5 Audio element for Opus
|
||||
async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
console.log(`[streams-ui] loadAndPlayAudio called for UID: ${uid}`);
|
||||
|
||||
// If trying to play the currently paused audio, just resume it
|
||||
if (audioElement && currentUid === uid) {
|
||||
console.log('[streams-ui] Resuming existing audio');
|
||||
try {
|
||||
await audioElement.play();
|
||||
isPlaying = true;
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Error resuming audio:', error);
|
||||
// Fall through to reload if resume fails
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any current playback
|
||||
stopPlayback();
|
||||
|
||||
// Update UI
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
currentlyPlayingButton = playPauseBtn;
|
||||
currentUid = uid;
|
||||
|
||||
try {
|
||||
console.log(`[streams-ui] Creating new audio element for ${uid}`);
|
||||
|
||||
// Create a new audio element with the correct MIME type
|
||||
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
|
||||
console.log(`[streams-ui] Loading audio from: ${audioUrl}`);
|
||||
|
||||
// Create a new audio element with a small delay to prevent race conditions
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
audioElement = new Audio(audioUrl);
|
||||
audioElement.preload = 'auto';
|
||||
audioElement.crossOrigin = 'anonymous'; // Important for CORS
|
||||
|
||||
// Set up event handlers with proper binding
|
||||
const onPlay = () => {
|
||||
console.log('[streams-ui] Audio play event');
|
||||
isPlaying = true;
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
console.log('[streams-ui] Audio pause event');
|
||||
isPlaying = false;
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
console.log('[streams-ui] Audio ended event');
|
||||
isPlaying = false;
|
||||
cleanupAudio();
|
||||
};
|
||||
|
||||
const onError = (e) => {
|
||||
// Ignore errors from previous audio elements that were cleaned up
|
||||
if (!audioElement || audioElement.readyState === 0) {
|
||||
console.log('[streams-ui] Ignoring error from cleaned up audio element');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[streams-ui] Audio error:', e);
|
||||
console.error('Error details:', audioElement.error);
|
||||
isPlaying = false;
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
|
||||
// Don't show error to user for aborted requests
|
||||
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
|
||||
console.log('[streams-ui] Playback was aborted as expected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show error to user for other errors
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Error playing audio. The format may not be supported.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
audioElement.addEventListener('play', onPlay, { once: true });
|
||||
audioElement.addEventListener('pause', onPause);
|
||||
audioElement.addEventListener('ended', onEnded, { once: true });
|
||||
audioElement.addEventListener('error', onError);
|
||||
|
||||
// Store references for cleanup
|
||||
audioElement._eventHandlers = { onPlay, onPause, onEnded, onError };
|
||||
|
||||
// Start playback with error handling
|
||||
console.log('[streams-ui] Starting audio playback');
|
||||
try {
|
||||
const playPromise = audioElement.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise.catch(error => {
|
||||
// Ignore abort errors when switching between streams
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('[streams-ui] Play failed:', error);
|
||||
throw error;
|
||||
}
|
||||
console.log('[streams-ui] Play was aborted as expected');
|
||||
});
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
} catch (error) {
|
||||
// Only log unexpected errors
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('[streams-ui] Error during playback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[streams-ui] Error loading/playing audio:', error);
|
||||
if (playPauseBtn) {
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
}
|
||||
|
||||
// Only show error if it's not an abort error
|
||||
if (error.name !== 'AbortError' && typeof showToast === 'function') {
|
||||
showToast('Error playing audio. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle audio ended event
|
||||
function handleAudioEnded() {
|
||||
isPlaying = false;
|
||||
if (currentlyPlayingButton) {
|
||||
updatePlayPauseButton(currentlyPlayingButton, false);
|
||||
}
|
||||
cleanupAudio();
|
||||
}
|
||||
|
||||
// Clean up audio resources
|
||||
function cleanupAudio() {
|
||||
console.log('[streams-ui] Cleaning up audio resources');
|
||||
|
||||
// Clean up Web Audio API resources if they exist
|
||||
if (audioSource) {
|
||||
try {
|
||||
if (isPlaying) {
|
||||
audioSource.stop();
|
||||
}
|
||||
audioSource.disconnect();
|
||||
} catch (e) {
|
||||
console.warn('Error cleaning up audio source:', e);
|
||||
}
|
||||
audioSource = null;
|
||||
}
|
||||
|
||||
// Clean up HTML5 Audio element if it exists
|
||||
if (audioElement) {
|
||||
try {
|
||||
// Remove event listeners first
|
||||
if (audioElement._eventHandlers) {
|
||||
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
|
||||
if (onPlay) audioElement.removeEventListener('play', onPlay);
|
||||
if (onPause) audioElement.removeEventListener('pause', onPause);
|
||||
if (onEnded) audioElement.removeEventListener('ended', onEnded);
|
||||
if (onError) audioElement.removeEventListener('error', onError);
|
||||
}
|
||||
|
||||
// Pause and clean up the audio element
|
||||
audioElement.pause();
|
||||
audioElement.removeAttribute('src');
|
||||
audioElement.load();
|
||||
|
||||
// Force garbage collection by removing references
|
||||
if (audioElement._eventHandlers) {
|
||||
delete audioElement._eventHandlers;
|
||||
}
|
||||
|
||||
audioElement = null;
|
||||
} catch (e) {
|
||||
console.warn('Error cleaning up audio element:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isPlaying = false;
|
||||
currentUid = null;
|
||||
|
||||
// Update UI
|
||||
if (currentlyPlayingButton) {
|
||||
updatePlayPauseButton(currentlyPlayingButton, false);
|
||||
currentlyPlayingButton = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for play/pause buttons
|
||||
document.addEventListener('click', async (e) => {
|
||||
const playPauseBtn = e.target.closest('.play-pause-btn');
|
||||
if (!playPauseBtn) return;
|
||||
|
||||
// Prevent default to avoid any potential form submission or link following
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const uid = playPauseBtn.dataset.uid;
|
||||
if (!uid) {
|
||||
console.error('No UID found for play button');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[streams-ui] Play/pause clicked for UID: ${uid}, currentUid: ${currentUid}, isPlaying: ${isPlaying}`);
|
||||
|
||||
// If clicking the currently playing button, toggle pause/play
|
||||
if (currentUid === uid) {
|
||||
if (isPlaying) {
|
||||
console.log('[streams-ui] Pausing current audio');
|
||||
await audioElement.pause();
|
||||
isPlaying = false;
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
} else {
|
||||
console.log('[streams-ui] Resuming current audio');
|
||||
try {
|
||||
await audioElement.play();
|
||||
isPlaying = true;
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
} catch (error) {
|
||||
console.error('[streams-ui] Error resuming audio:', error);
|
||||
// If resume fails, try reloading the audio
|
||||
await loadAndPlayAudio(uid, playPauseBtn);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different stream is playing, stop it and start the new one
|
||||
console.log(`[streams-ui] Switching to new audio stream: ${uid}`);
|
||||
stopPlayback();
|
||||
await loadAndPlayAudio(uid, playPauseBtn);
|
||||
});
|
||||
|
||||
// Handle audio end event to update button state
|
||||
document.addEventListener('play', (e) => {
|
||||
if (e.target.tagName === 'AUDIO' && e.target !== currentlyPlayingAudio) {
|
||||
if (currentlyPlayingAudio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
}
|
||||
currentlyPlayingAudio = e.target;
|
||||
|
||||
// Update the play/pause button state
|
||||
const playerArticle = e.target.closest('.stream-player');
|
||||
if (playerArticle) {
|
||||
const playBtn = playerArticle.querySelector('.play-pause-btn');
|
||||
if (playBtn) {
|
||||
if (currentlyPlayingButton && currentlyPlayingButton !== playBtn) {
|
||||
updatePlayPauseButton(currentlyPlayingButton, false);
|
||||
}
|
||||
updatePlayPauseButton(playBtn, true);
|
||||
currentlyPlayingButton = playBtn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Handle audio pause event
|
||||
document.addEventListener('pause', (e) => {
|
||||
if (e.target.tagName === 'AUDIO' && e.target === currentlyPlayingAudio) {
|
||||
const playerArticle = e.target.closest('.stream-player');
|
||||
if (playerArticle) {
|
||||
const playBtn = playerArticle.querySelector('.play-pause-btn');
|
||||
if (playBtn) {
|
||||
updatePlayPauseButton(playBtn, false);
|
||||
}
|
||||
}
|
||||
currentlyPlayingAudio = null;
|
||||
currentlyPlayingButton = null;
|
||||
}
|
||||
}, true);
|
||||
|
682
static/style.css
682
static/style.css
@ -4,6 +4,93 @@ main {
|
||||
align-items: center; /* centers children horizontally */
|
||||
}
|
||||
|
||||
/* Global section styles */
|
||||
main > section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 0 1.5rem 0;
|
||||
padding: 0; /* Remove padding from section, will be handled by inner elements */
|
||||
box-sizing: border-box;
|
||||
background: var(--crt-screen, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
overflow: hidden; /* Ensures border-radius clips child elements */
|
||||
}
|
||||
|
||||
/* Ensure consistent background for all sections */
|
||||
#welcome-page,
|
||||
#register-page,
|
||||
#stream-page,
|
||||
#me-page,
|
||||
#terms-page,
|
||||
#privacy-page,
|
||||
#imprint-page {
|
||||
background: var(--crt-screen, #1a1a1a);
|
||||
}
|
||||
|
||||
/* Style articles within sections */
|
||||
section > article {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
box-shadow: none;
|
||||
color: #f0f0f0;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
padding: 2rem 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Center the register form */
|
||||
#register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#register-form p {
|
||||
margin: 0.5rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#register-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#register-form input[type="email"],
|
||||
#register-form input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#register-form button[type="submit"] {
|
||||
margin-top: 1rem;
|
||||
width: calc(250px + 1.6em);
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Style text inputs in register form */
|
||||
#register-page #register-form input[type="email"],
|
||||
#register-page #register-form input[type="text"] {
|
||||
width: calc(250px + 1.6em);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Add subtle dividers between sections */
|
||||
section + section,
|
||||
article + article {
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
nav#guest-dashboard.dashboard-nav {
|
||||
width: fit-content; /* optional: shrink to fit content */
|
||||
margin: 0 auto; /* fallback for block centering */
|
||||
@ -20,21 +107,29 @@ nav#guest-dashboard.dashboard-nav {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
min-width: 300px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--crt-screen);
|
||||
color: var(--crt-text);
|
||||
padding: 1em 2em;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
color: #fff;
|
||||
padding: 1.2em 2em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px var(--crt-shadow);
|
||||
margin-top: 0.5em;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0.8em;
|
||||
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);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
transform: translateZ(0);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
@ -137,53 +232,102 @@ audio {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Audio controls base styles */
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5em 0;
|
||||
padding: 1em;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Specific styles for play/pause button in me-page */
|
||||
#me-page .audio-controls button {
|
||||
border: 2px solid #444; /* Default border color */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#me-page .audio-controls button:hover {
|
||||
background: #222;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 1.2em;
|
||||
background: rgba(26, 26, 26, 0.9);
|
||||
border: 2px solid #444;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
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;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
margin: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Play/Pause button specific styles */
|
||||
.audio-controls button#play-pause,
|
||||
.audio-controls button#play-pause-devuser {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 3rem;
|
||||
background: rgba(34, 34, 34, 0.95);
|
||||
}
|
||||
|
||||
/* Hover and active states */
|
||||
.audio-controls button:hover {
|
||||
background-color: #f0f0f0;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
background: #222;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.audio-controls button:active {
|
||||
color: #000;
|
||||
transform: scale(0.9);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Ensure touch targets are large enough on mobile */
|
||||
@media (max-width: 959px) {
|
||||
.audio-controls {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
min-width: 64px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.audio-controls button#play-pause,
|
||||
.audio-controls button#play-pause-devuser {
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-controls button:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.audio-controls svg {
|
||||
fill: #333;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: currentColor;
|
||||
transition: all 0.2s ease;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.audio-controls button:hover svg {
|
||||
fill: #000;
|
||||
transform: scale(1.1);
|
||||
.audio-controls button#play-pause svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
/* Hide the native controls */
|
||||
@ -212,16 +356,6 @@ main > section {
|
||||
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;
|
||||
@ -262,7 +396,6 @@ button.audio-control {
|
||||
#register-form {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@ -367,13 +500,10 @@ button.audio-control:hover {
|
||||
|
||||
/* Stream player styling */
|
||||
.stream-player {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1em;
|
||||
background: transparent;
|
||||
border: none;
|
||||
position: relative;
|
||||
color: #d3d3d3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stream-player::before {
|
||||
@ -562,12 +692,87 @@ input[disabled], button[disabled] {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #fafafa;
|
||||
/* Base document styles */
|
||||
html {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
color: #333;
|
||||
padding: 0;
|
||||
background-color: #111;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(188, 183, 107, 0.1) 0, /* Olive color */
|
||||
rgba(188, 183, 107, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 20px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(188, 183, 107, 0.1) 0, /* Olive color */
|
||||
rgba(188, 183, 107, 0.1) 1px,
|
||||
transparent 1px,
|
||||
transparent 20px
|
||||
);
|
||||
background-size: 40px 40px;
|
||||
background-repeat: repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #f0f0f0;
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Ensure main content stretches to fill available space */
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main > section {
|
||||
width: 100%;
|
||||
max-width: inherit;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--crt-screen, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
section > article {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Fallback for browsers that don't support flexbox */
|
||||
@supports not (display: flex) {
|
||||
html {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
header h1 {
|
||||
@ -585,7 +790,6 @@ header p {
|
||||
|
||||
header, footer {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
footer p {
|
||||
@ -761,15 +965,10 @@ a.button:hover {
|
||||
background: #256b45;
|
||||
}
|
||||
|
||||
section article {
|
||||
max-width: 600px;
|
||||
margin: 2em auto;
|
||||
padding: 1.5em;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
/* Stream page specific styles */
|
||||
/* Article styles moved to desktop.css */
|
||||
|
||||
/* Specific styles for stream player */
|
||||
section article.stream-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
@ -779,9 +978,7 @@ section article.stream-page {
|
||||
.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);
|
||||
@ -797,34 +994,31 @@ section article.stream-page {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
/* Stream audio controls (specific to stream items) */
|
||||
.stream-audio .audio-controls {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
.stream-audio .audio-controls button {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.audio-controls button:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.audio-controls button:active {
|
||||
color: #000;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
/* Play button styles are now consolidated above */
|
||||
|
||||
.stream-info {
|
||||
margin: 0;
|
||||
@ -832,40 +1026,215 @@ section article.stream-page {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
ul#stream-list,
|
||||
ul#me-files {
|
||||
padding-left: 0;
|
||||
/* Stream list styles */
|
||||
#stream-page article {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* User upload area styles */
|
||||
#user-upload-area {
|
||||
width: 100%;
|
||||
margin: 1.5rem auto;
|
||||
padding: 1.5rem;
|
||||
background: var(--crt-screen);
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#user-upload-area:hover {
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
|
||||
border: 1px solid #ff6600;
|
||||
}
|
||||
|
||||
#user-upload-area p {
|
||||
margin: 0;
|
||||
color: #ddd;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#stream-list {
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
margin-top: 1em;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul#stream-list li a,
|
||||
ul#me-files li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
#stream-list > li {
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#stream-list > li:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #ff6600;
|
||||
}
|
||||
|
||||
.stream-player {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.stream-player:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #ff6600;
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
|
||||
}
|
||||
|
||||
.stream-player h3 {
|
||||
margin: 0 0 1.25rem;
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
letter-spacing: 0.3px;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
margin: 1.25rem 0 0;
|
||||
color: #a0a0a0 !important;
|
||||
font-size: 0.9rem !important;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Stream list styles */
|
||||
#stream-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#stream-list li {
|
||||
background: transparent;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
#stream-list li:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
#stream-list a {
|
||||
color: #4dabf7;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
#stream-list a:hover {
|
||||
color: #74c0fc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#stream-list .stream-meta {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* User upload area */
|
||||
#user-upload-area {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--crt-screen, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Ensure consistent audio player container */
|
||||
.stream-audio {
|
||||
width: 100%;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
/* Style for the play button container */
|
||||
.audio-controls-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.3em auto;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 6px;
|
||||
background: #f0f0f0;
|
||||
font-size: 0.95em;
|
||||
max-width: 90%;
|
||||
gap: 1em;
|
||||
color: #333;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
ul#stream-list li a:hover,
|
||||
ul#me-files li:hover {
|
||||
background: #e5f5ec;
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767px) {
|
||||
#stream-page article {
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#stream-list {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stream-player {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
section article h2 {
|
||||
/* Section h2 headers */
|
||||
main > section > h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.6em;
|
||||
margin: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
color: #f0f0f0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Article styles consolidated above */
|
||||
|
||||
/* Stream page specific styles */
|
||||
#stream-page > article {
|
||||
max-width: 1200px; /* Wider for the stream list */
|
||||
padding: 2rem 1rem; /* Match padding of other sections */
|
||||
margin: 0 auto; /* Ensure centering */
|
||||
}
|
||||
|
||||
/* Full width for form elements */
|
||||
#register-page article,
|
||||
#me-page > article {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Add padding to the bottom of sections that only contain an h2 */
|
||||
main > section:has(> h2:only-child) {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Space between h2 and logout button is now handled inline */
|
||||
|
||||
section article a[href^="mailto"]::before {
|
||||
content: "✉️ ";
|
||||
margin-right: 0.3em;
|
||||
@ -942,18 +1311,8 @@ 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;
|
||||
/* Removed olive gradient overlay */
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav#guest-dashboard.dashboard-nav,
|
||||
@ -967,14 +1326,12 @@ nav#user-dashboard.dashboard-nav {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Dashboard nav base styles (moved to desktop.css and mobile.css) */
|
||||
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;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
nav.dashboard-nav a:hover {
|
||||
@ -1015,6 +1372,22 @@ footer p.footer-hint a:hover {
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
/* Set max-width for content sections */
|
||||
#register-page > article,
|
||||
#me-page > article,
|
||||
#user-upload-area {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#stream-page > article {
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
section#links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -1068,28 +1441,6 @@ footer p.footer-hint a:hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
#burger-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
section#links {
|
||||
display: none;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 3.2em;
|
||||
right: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
padding: 1em;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#burger-toggle:checked + #burger-label + section#links {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFadeIn {
|
||||
0% {
|
||||
@ -1112,3 +1463,38 @@ footer p.footer-hint a:hover {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logout button styles */
|
||||
.logout-btn {
|
||||
background: rgba(34, 34, 34, 0.95);
|
||||
color: #f0f0f0;
|
||||
border: 2px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 0.5em 1em;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #222;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.logout-btn:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
222
static/test-audio-player.html
Normal file
222
static/test-audio-player.html
Normal file
@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audio Player Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.test-case {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#log {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.audio-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
audio {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Audio Player Test</h1>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 1: Direct Audio Element</h2>
|
||||
<div class="audio-container">
|
||||
<audio id="direct-audio" controls>
|
||||
<source src="/audio/devuser/stream.opus" type="audio/ogg; codecs=opus">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="document.getElementById('direct-audio').play()">Play</button>
|
||||
<button onclick="document.getElementById('direct-audio').pause()">Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 2: Dynamic Audio Element</h2>
|
||||
<div id="dynamic-audio-container">
|
||||
<button onclick="setupDynamicAudio()">Initialize Dynamic Audio</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 3: Using loadProfileStream</h2>
|
||||
<div id="load-profile-container">
|
||||
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
|
||||
<div id="test3-status">Not started</div>
|
||||
<div class="audio-container">
|
||||
<audio id="profile-audio" controls></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Browser Audio Support</h2>
|
||||
<div id="codec-support">Testing codec support...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Console Log</h2>
|
||||
<div id="log"></div>
|
||||
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Logging function
|
||||
function log(message, type = 'info') {
|
||||
const logDiv = document.getElementById('log');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = type;
|
||||
entry.textContent = `[${new Date().toISOString()}] ${message}`;
|
||||
logDiv.appendChild(entry);
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
// Test 2: Dynamic Audio Element
|
||||
function setupDynamicAudio() {
|
||||
log('Setting up dynamic audio element...');
|
||||
const container = document.getElementById('dynamic-audio-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
const audio = document.createElement('audio');
|
||||
audio.controls = true;
|
||||
audio.preload = 'auto';
|
||||
audio.crossOrigin = 'anonymous';
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.src = '/audio/devuser/stream.opus';
|
||||
source.type = 'audio/ogg; codecs=opus';
|
||||
|
||||
audio.appendChild(source);
|
||||
container.appendChild(audio);
|
||||
container.appendChild(document.createElement('br'));
|
||||
|
||||
const playBtn = document.createElement('button');
|
||||
playBtn.textContent = 'Play';
|
||||
playBtn.onclick = () => {
|
||||
audio.play().catch(e => log(`Play error: ${e}`, 'error'));
|
||||
};
|
||||
container.appendChild(playBtn);
|
||||
|
||||
const pauseBtn = document.createElement('button');
|
||||
pauseBtn.textContent = 'Pause';
|
||||
pauseBtn.onclick = () => audio.pause();
|
||||
container.appendChild(pauseBtn);
|
||||
|
||||
log('Dynamic audio element created successfully');
|
||||
} catch (e) {
|
||||
log(`Error creating dynamic audio: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: loadProfileStream
|
||||
async function testLoadProfileStream() {
|
||||
const status = document.getElementById('test3-status');
|
||||
status.textContent = 'Loading...';
|
||||
status.className = '';
|
||||
|
||||
try {
|
||||
// Import the loadProfileStream function from app.js
|
||||
const { loadProfileStream } = await import('./app.js');
|
||||
|
||||
if (typeof loadProfileStream !== 'function') {
|
||||
throw new Error('loadProfileStream function not found');
|
||||
}
|
||||
|
||||
// Call loadProfileStream with test user
|
||||
const audio = await loadProfileStream('devuser');
|
||||
|
||||
if (audio) {
|
||||
status.textContent = 'Audio loaded successfully!';
|
||||
status.className = 'success';
|
||||
log('Audio loaded successfully', 'success');
|
||||
|
||||
// Add the audio element to the page
|
||||
const audioContainer = document.querySelector('#load-profile-container .audio-container');
|
||||
audioContainer.innerHTML = '';
|
||||
audio.controls = true;
|
||||
audioContainer.appendChild(audio);
|
||||
} else {
|
||||
status.textContent = 'No audio available for test user';
|
||||
status.className = '';
|
||||
log('No audio available for test user', 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
status.className = 'error';
|
||||
log(`Error in loadProfileStream: ${e}`, 'error');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check browser audio support
|
||||
function checkAudioSupport() {
|
||||
const supportDiv = document.getElementById('codec-support');
|
||||
const audio = document.createElement('audio');
|
||||
|
||||
const codecs = {
|
||||
'audio/ogg; codecs=opus': 'Opus (OGG)',
|
||||
'audio/webm; codecs=opus': 'Opus (WebM)',
|
||||
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
|
||||
'audio/mpeg': 'MP3'
|
||||
};
|
||||
|
||||
let results = [];
|
||||
|
||||
for (const [type, name] of Object.entries(codecs)) {
|
||||
const canPlay = audio.canPlayType(type);
|
||||
results.push(`${name}: ${canPlay || 'Not supported'}`);
|
||||
}
|
||||
|
||||
supportDiv.innerHTML = results.join('<br>');
|
||||
}
|
||||
|
||||
// Initialize tests
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
log('Test page loaded');
|
||||
checkAudioSupport();
|
||||
|
||||
// Log audio element events for debugging
|
||||
const audioElements = document.getElementsByTagName('audio');
|
||||
Array.from(audioElements).forEach((audio, index) => {
|
||||
['play', 'pause', 'error', 'stalled', 'suspend', 'abort', 'emptied', 'ended'].forEach(event => {
|
||||
audio.addEventListener(event, (e) => {
|
||||
log(`Audio ${index + 1} ${event} event: ${e.type}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
192
static/test-audio.html
Normal file
192
static/test-audio.html
Normal file
@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audio Player Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.test-case {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#log {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Audio Player Test</h1>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 1: Basic Audio Element</h2>
|
||||
<audio id="test1" controls>
|
||||
<source src="/static/test-audio.opus" type="audio/ogg; codecs=opus">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div>
|
||||
<button onclick="document.getElementById('test1').play()">Play</button>
|
||||
<button onclick="document.getElementById('test1').pause()">Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 2: Dynamic Audio Element</h2>
|
||||
<div id="test2-container">
|
||||
<button onclick="setupTest2()">Initialize Audio</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Test 3: Using loadProfileStream</h2>
|
||||
<div id="test3-container">
|
||||
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
|
||||
<div id="test3-status">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Browser Audio Support</h2>
|
||||
<div id="codec-support">Testing codec support...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-case">
|
||||
<h2>Console Log</h2>
|
||||
<div id="log"></div>
|
||||
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Logging function
|
||||
function log(message, type = 'info') {
|
||||
const logDiv = document.getElementById('log');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = type;
|
||||
entry.textContent = `[${new Date().toISOString()}] ${message}`;
|
||||
logDiv.appendChild(entry);
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
// Test 2: Dynamic Audio Element
|
||||
function setupTest2() {
|
||||
log('Setting up dynamic audio element...');
|
||||
const container = document.getElementById('test2-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
const audio = document.createElement('audio');
|
||||
audio.controls = true;
|
||||
audio.preload = 'auto';
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.src = '/static/test-audio.opus';
|
||||
source.type = 'audio/ogg; codecs=opus';
|
||||
|
||||
audio.appendChild(source);
|
||||
container.appendChild(audio);
|
||||
container.appendChild(document.createElement('br'));
|
||||
|
||||
const playBtn = document.createElement('button');
|
||||
playBtn.textContent = 'Play';
|
||||
playBtn.onclick = () => audio.play().catch(e => log(`Play error: ${e}`, 'error'));
|
||||
container.appendChild(playBtn);
|
||||
|
||||
const pauseBtn = document.createElement('button');
|
||||
pauseBtn.textContent = 'Pause';
|
||||
pauseBtn.onclick = () => audio.pause();
|
||||
container.appendChild(pauseBtn);
|
||||
|
||||
log('Dynamic audio element created successfully');
|
||||
} catch (e) {
|
||||
log(`Error creating dynamic audio: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: loadProfileStream
|
||||
async function testLoadProfileStream() {
|
||||
const status = document.getElementById('test3-status');
|
||||
status.textContent = 'Loading...';
|
||||
status.className = '';
|
||||
|
||||
try {
|
||||
// Create a test user ID
|
||||
const testUid = 'test-user-' + Math.random().toString(36).substr(2, 8);
|
||||
log(`Testing with user: ${testUid}`);
|
||||
|
||||
// Call loadProfileStream
|
||||
const audio = await window.loadProfileStream(testUid);
|
||||
|
||||
if (audio) {
|
||||
status.textContent = 'Audio loaded successfully!';
|
||||
status.className = 'success';
|
||||
log('Audio loaded successfully', 'success');
|
||||
} else {
|
||||
status.textContent = 'No audio available for test user';
|
||||
status.className = '';
|
||||
log('No audio available for test user', 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
status.className = 'error';
|
||||
log(`Error in loadProfileStream: ${e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Check browser audio support
|
||||
function checkAudioSupport() {
|
||||
const supportDiv = document.getElementById('codec-support');
|
||||
const audio = document.createElement('audio');
|
||||
|
||||
const codecs = {
|
||||
'audio/ogg; codecs=opus': 'Opus (OGG)',
|
||||
'audio/webm; codecs=opus': 'Opus (WebM)',
|
||||
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
|
||||
'audio/mpeg': 'MP3'
|
||||
};
|
||||
|
||||
let results = [];
|
||||
|
||||
for (const [type, name] of Object.entries(codecs)) {
|
||||
const canPlay = audio.canPlayType(type);
|
||||
results.push(`${name}: ${canPlay || 'Not supported'}`);
|
||||
}
|
||||
|
||||
supportDiv.innerHTML = results.join('<br>');
|
||||
}
|
||||
|
||||
// Initialize tests
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
log('Test page loaded');
|
||||
checkAudioSupport();
|
||||
|
||||
// Expose loadProfileStream for testing
|
||||
if (!window.loadProfileStream) {
|
||||
log('Warning: loadProfileStream not found in global scope', 'warning');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
BIN
static/test-audio.opus
Normal file
BIN
static/test-audio.opus
Normal file
Binary file not shown.
125
static/upload.js
125
static/upload.js
@ -78,6 +78,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
dropzone.classList.remove("uploading");
|
||||
showToast("✅ Upload successful.");
|
||||
|
||||
// Refresh the audio player and file list
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (uid) {
|
||||
try {
|
||||
if (window.loadProfileStream) {
|
||||
await window.loadProfileStream(uid);
|
||||
}
|
||||
// Refresh the file list
|
||||
if (window.fetchAndDisplayFiles) {
|
||||
await window.fetchAndDisplayFiles(uid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh:', e);
|
||||
}
|
||||
}
|
||||
|
||||
playBeep(432, 0.25, "sine");
|
||||
} else {
|
||||
streamInfo.hidden = true;
|
||||
@ -95,8 +111,115 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Export the upload function for use in other modules
|
||||
// Function to fetch and display uploaded files
|
||||
async function fetchAndDisplayFiles(uid) {
|
||||
console.log('[DEBUG] fetchAndDisplayFiles called with uid:', uid);
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) {
|
||||
const errorMsg = 'File list element not found in DOM';
|
||||
console.error(errorMsg);
|
||||
return showErrorInUI(errorMsg);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
fileList.innerHTML = '<div style="padding: 10px; color: #888; font-style: italic;">Loading files...</div>';
|
||||
|
||||
try {
|
||||
console.log(`[DEBUG] Fetching files for user: ${uid}`);
|
||||
const response = await fetch(`/me/${uid}`);
|
||||
console.log('[DEBUG] Response status:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const errorMsg = `Failed to fetch files: ${response.status} ${response.statusText} - ${errorText}`;
|
||||
console.error(`[ERROR] ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[DEBUG] Received files data:', data);
|
||||
|
||||
if (!data.files) {
|
||||
throw new Error('Invalid response format: missing files array');
|
||||
}
|
||||
|
||||
if (data.files.length > 0) {
|
||||
// Sort files by name
|
||||
const sortedFiles = [...data.files].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
fileList.innerHTML = sortedFiles.map(file => {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
const displayName = file.original_name || file.name;
|
||||
const isRenamed = file.original_name && file.original_name !== file.name;
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #2a2a2a;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${displayName}">
|
||||
${displayName}
|
||||
${isRenamed ? `<div style="font-size: 0.8em; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="Stored as: ${file.name}">${file.name}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style="color: #888; white-space: nowrap; margin-left: 10px;">${sizeMB} MB</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
fileList.innerHTML = '<div style="padding: 5px 0; color: #888; font-style: italic;">No files uploaded yet</div>';
|
||||
}
|
||||
|
||||
// Update quota display if available
|
||||
if (data.quota !== 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;
|
||||
bar.value = data.quota;
|
||||
bar.max = 100;
|
||||
text.textContent = `${data.quota.toFixed(1)} MB used`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = `Error loading file list: ${error.message || 'Unknown error'}`;
|
||||
console.error('[ERROR]', errorMessage, error);
|
||||
showErrorInUI(errorMessage, fileList);
|
||||
}
|
||||
|
||||
// Helper function to show error messages in the UI
|
||||
function showErrorInUI(message, targetElement = null) {
|
||||
const errorHtml = `
|
||||
<div style="
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #2a0f0f;
|
||||
border-left: 3px solid #f55;
|
||||
color: #ff9999;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="font-weight: bold; color: #f55;">Error loading files</div>
|
||||
<div style="margin-top: 5px;">${message}</div>
|
||||
<div style="margin-top: 10px; font-size: 0.8em; color: #888;">
|
||||
Check browser console for details
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.innerHTML = errorHtml;
|
||||
} else {
|
||||
// If no target element, try to find it
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (fileList) fileList.innerHTML = errorHtml;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for use in other modules
|
||||
window.upload = upload;
|
||||
window.fetchAndDisplayFiles = fetchAndDisplayFiles;
|
||||
|
||||
if (dropzone && fileInput) {
|
||||
dropzone.addEventListener("click", () => {
|
||||
|
Reference in New Issue
Block a user