Files
oib a9a1c22fee Fix audio player synchronization between streams and personal pages
- Add global audio manager to coordinate playback between different players
- Integrate synchronization into streams-ui.js (streams page player)
- Integrate synchronization into app.js (personal stream player)
- Remove simultaneous playback issues - only one audio plays at a time
- Clean transitions when switching between streams and personal audio

Fixes issue where starting audio on one page didn't stop audio on the other page.
2025-07-27 09:13:55 +02:00

1258 lines
40 KiB
JavaScript

// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
import { playBeep } from "./sound.js";
import { showToast } from "./toast.js";
import { injectNavigation } from "./inject-nav.js";
import { globalAudioManager } from './global-audio-manager.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}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Log debug messages to server
export function logToServer(msg) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/log", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ msg }));
}
// Handle magic link login redirect
function handleMagicLoginRedirect() {
const params = new URLSearchParams(window.location.search);
if (params.get('login') === 'success' && params.get('confirmed_uid')) {
const username = params.get('confirmed_uid');
console.log('Magic link login detected for user:', username);
// Update authentication state
localStorage.setItem('uid', username);
localStorage.setItem('confirmed_uid', username);
localStorage.setItem('uid_time', Date.now().toString());
document.cookie = `uid=${encodeURIComponent(username)}; path=/; SameSite=Lax`;
// Update UI state
document.body.classList.add('authenticated');
document.body.classList.remove('guest');
// Update local storage and cookies
localStorage.setItem('isAuthenticated', 'true');
document.cookie = `isAuthenticated=true; path=/; SameSite=Lax`;
// Update URL and history without reloading
window.history.replaceState({}, document.title, window.location.pathname);
// Update navigation
if (typeof injectNavigation === 'function') {
console.log('Updating navigation after magic link login');
injectNavigation(true);
} else {
console.warn('injectNavigation function not available after magic link login');
}
// Navigate to user's profile page
if (window.showOnly) {
console.log('Navigating to me-page');
window.showOnly('me-page');
} else if (window.location.hash !== '#me') {
window.location.hash = '#me';
}
// Auth state will be updated by the polling mechanism
}
}
// 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();
});
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;
}
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'
}
});
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.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);
});
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.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 if available
const streamInfo = document.getElementById("stream-info");
if (streamInfo) streamInfo.hidden = false;
} 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;
}
// Update button state
updatePlayPauseButton(audio, document.querySelector('.play-pause-btn'));
return audio;
}
// 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;
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() {
// Get all navigation links
const navLinks = document.querySelectorAll('nav a, .dashboard-nav a');
// Handle navigation link clicks
const handleNavClick = (e) => {
const link = e.target.closest('a');
if (!link) return;
// Check both href and data-target attributes
const target = link.getAttribute('data-target');
const href = link.getAttribute('href');
// Let the browser handle external links
if (href && (href.startsWith('http') || href.startsWith('mailto:'))) {
return;
}
// If no data-target and no hash in href, let browser handle it
if (!target && (!href || !href.startsWith('#'))) {
return;
}
// Prefer data-target over href
let sectionId = target || (href ? href.substring(1) : '');
// Special case for the 'me' route which maps to 'me-page'
if (sectionId === 'me') {
sectionId = 'me-page';
}
// Skip if no valid section ID
if (!sectionId) {
console.warn('No valid section ID in navigation link:', link);
return;
}
// Let the router handle the navigation
e.preventDefault();
e.stopPropagation();
// Update body class to reflect the current section
document.body.className = ''; // Clear existing classes
document.body.classList.add(`page-${sectionId.replace('-page', '')}`);
// Update URL hash - the router will handle the rest
window.location.hash = sectionId;
// Close mobile menu if open
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
};
// Add click event listeners to all navigation links
navLinks.forEach(link => {
link.addEventListener('click', handleNavClick);
});
// Handle initial page load and hash changes
const handleHashChange = () => {
let hash = window.location.hash.substring(1);
// Map URL hashes to section IDs if they don't match exactly
const sectionMap = {
'welcome': 'welcome-page',
'streams': 'stream-page',
'account': 'register-page',
'login': 'login-page',
'me': 'me-page'
};
// Use mapped section ID or the hash as is
const sectionId = sectionMap[hash] || hash || 'welcome-page';
const targetSection = document.getElementById(sectionId);
if (targetSection) {
// Hide all sections
document.querySelectorAll('main > section').forEach(section => {
section.hidden = section.id !== sectionId;
});
// Show target section
targetSection.hidden = false;
targetSection.scrollIntoView({ behavior: 'smooth' });
// Update active state of navigation links
navLinks.forEach(link => {
const linkHref = link.getAttribute('href');
// Match both the exact hash and the mapped section ID
link.classList.toggle('active',
linkHref === `#${hash}` ||
linkHref === `#${sectionId}` ||
link.getAttribute('data-target') === sectionId ||
link.getAttribute('data-target') === hash
);
});
// Special handling for streams page
if (sectionId === 'stream-page' && typeof window.maybeLoadStreamsOnShow === 'function') {
window.maybeLoadStreamsOnShow();
}
} else {
console.warn(`Section with ID '${sectionId}' not found`);
}
};
// Listen for hash changes
window.addEventListener('hashchange', handleHashChange);
// Handle initial page load
handleHashChange();
}
function initProfilePlayer() {
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
showProfilePlayerFromUrl();
}
// Track previous authentication state
let wasAuthenticated = null;
// Debug flag - set to false to disable auth state change logs
const DEBUG_AUTH_STATE = false;
// Track all intervals and timeouts
const activeIntervals = new Map();
const activeTimeouts = new Map();
// Store original timer functions
const originalSetInterval = window.setInterval;
const originalClearInterval = window.clearInterval;
const originalSetTimeout = window.setTimeout;
const originalClearTimeout = window.clearTimeout;
// Override setInterval to track all intervals
window.setInterval = (callback, delay, ...args) => {
const id = originalSetInterval((...args) => {
trackFunctionCall('setInterval callback', { id, delay, callback: callback.toString() });
return callback(...args);
}, delay, ...args);
activeIntervals.set(id, {
id,
delay,
callback: callback.toString(),
createdAt: Date.now(),
stack: new Error().stack
});
if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Created with delay ${delay}ms`, {
id,
delay,
callback: callback.toString(),
stack: new Error().stack
});
}
return id;
};
// Override clearInterval to track interval cleanup
window.clearInterval = (id) => {
if (activeIntervals.has(id)) {
activeIntervals.delete(id);
if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Cleared`);
}
} else if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Cleared (not tracked)`);
}
originalClearInterval(id);
};
// Override setTimeout to track timeouts (debug logging disabled)
window.setTimeout = (callback, delay, ...args) => {
const id = originalSetTimeout(callback, delay, ...args);
// Store minimal info without logging
activeTimeouts.set(id, {
id,
delay,
callback: callback.toString(),
createdAt: Date.now()
});
return id;
};
// Override clearTimeout to track timeout cleanup (debug logging disabled)
window.clearTimeout = (id) => {
if (activeTimeouts.has(id)) {
activeTimeouts.delete(id);
}
originalClearTimeout(id);
};
// Track auth check calls and cache state
let lastAuthCheckTime = 0;
let authCheckCounter = 0;
const AUTH_CHECK_DEBOUNCE = 1000; // 1 second
let authStateCache = {
timestamp: 0,
value: null,
ttl: 5000 // Cache TTL in milliseconds
};
// Override console.log to capture all logs
const originalConsoleLog = console.log;
const originalConsoleGroup = console.group;
const originalConsoleGroupEnd = console.groupEnd;
// Track all console logs
const consoleLogs = [];
const MAX_LOGS = 100;
console.log = function(...args) {
// Store the log
consoleLogs.push({
type: 'log',
timestamp: new Date().toISOString(),
args: [...args],
stack: new Error().stack
});
// Keep only the most recent logs
while (consoleLogs.length > MAX_LOGS) {
consoleLogs.shift();
}
// Filter out the auth state check messages
if (args[0] && typeof args[0] === 'string' && args[0].includes('Auth State Check')) {
return;
}
originalConsoleLog.apply(console, args);
};
// Track console groups
console.group = function(...args) {
consoleLogs.push({ type: 'group', timestamp: new Date().toISOString(), args });
originalConsoleGroup.apply(console, args);
};
console.groupEnd = function() {
consoleLogs.push({ type: 'groupEnd', timestamp: new Date().toISOString() });
originalConsoleGroupEnd.apply(console);
};
// Track all function calls that might trigger auth checks
const trackedFunctions = ['checkAuthState', 'setInterval', 'setTimeout', 'addEventListener', 'removeEventListener'];
const functionCalls = [];
const MAX_FUNCTION_CALLS = 100;
// Function to format stack trace for better readability
function formatStack(stack) {
if (!stack) return 'No stack trace';
// Remove the first line (the error message) and limit to 5 frames
const frames = stack.split('\n').slice(1).slice(0, 5);
return frames.join('\n');
}
// Function to dump debug info
window.dumpDebugInfo = () => {
console.group('Debug Info');
// Active Intervals
console.group('Active Intervals');
if (activeIntervals.size === 0) {
console.log('No active intervals');
} else {
activeIntervals.forEach((info, id) => {
console.group(`Interval ${id} (${info.delay}ms)`);
console.log('Created at:', new Date(info.createdAt).toISOString());
console.log('Callback:', info.callback.split('\n')[0]); // First line of callback
console.log('Stack trace:');
console.log(formatStack(info.stack));
console.groupEnd();
});
}
console.groupEnd();
// Active Timeouts
console.group('Active Timeouts');
if (activeTimeouts.size === 0) {
console.log('No active timeouts');
} else {
activeTimeouts.forEach((info, id) => {
console.group(`Timeout ${id} (${info.delay}ms)`);
console.log('Created at:', new Date(info.createdAt).toISOString());
console.log('Callback:', info.callback.split('\n')[0]); // First line of callback
console.log('Stack trace:');
console.log(formatStack(info.stack));
console.groupEnd();
});
}
console.groupEnd();
// Document state
console.group('Document State');
console.log('Visibility:', document.visibilityState);
console.log('Has Focus:', document.hasFocus());
console.log('URL:', window.location.href);
console.groupEnd();
// Recent logs
console.group('Recent Logs (10 most recent)');
if (consoleLogs.length === 0) {
console.log('No logs recorded');
} else {
consoleLogs.slice(-10).forEach((log, i) => {
console.group(`Log ${i + 1} (${log.timestamp})`);
console.log(...log.args);
if (log.stack) {
console.log('Stack trace:');
console.log(formatStack(log.stack));
}
console.groupEnd();
});
}
console.groupEnd();
// Auth state
console.group('Auth State');
console.log('Has auth cookie:', document.cookie.includes('sessionid='));
console.log('Has UID cookie:', document.cookie.includes('uid='));
console.log('Has localStorage auth:', localStorage.getItem('isAuthenticated') === 'true');
console.log('Has auth token:', !!localStorage.getItem('auth_token'));
console.groupEnd();
console.groupEnd(); // End main group
};
function trackFunctionCall(name, ...args) {
const callInfo = {
name,
time: Date.now(),
timestamp: new Date().toISOString(),
args: args.map(arg => {
try {
return JSON.stringify(arg);
} catch (e) {
return String(arg);
}
}),
stack: new Error().stack
};
functionCalls.push(callInfo);
if (functionCalls.length > MAX_FUNCTION_CALLS) {
functionCalls.shift();
}
if (name === 'checkAuthState') {
console.group(`[${functionCalls.length}] Auth check at ${callInfo.timestamp}`);
console.log('Call stack:', callInfo.stack);
console.log('Recent function calls:', functionCalls.slice(-5));
console.groupEnd();
}
}
// Override tracked functions
trackedFunctions.forEach(fnName => {
if (window[fnName]) {
const originalFn = window[fnName];
window[fnName] = function(...args) {
trackFunctionCall(fnName, ...args);
return originalFn.apply(this, args);
};
}
});
// Update the visibility of the account deletion section based on authentication state
function updateAccountDeletionVisibility(isAuthenticated) {
console.log('[ACCOUNT-DELETION] updateAccountDeletionVisibility called with isAuthenticated:', isAuthenticated);
// Find the account deletion section and its auth-only wrapper
const authOnlyWrapper = document.querySelector('#privacy-page .auth-only');
const accountDeletionSection = document.getElementById('account-deletion');
console.log('[ACCOUNT-DELETION] Elements found:', {
authOnlyWrapper: !!authOnlyWrapper,
accountDeletionSection: !!accountDeletionSection
});
// Function to show an element with all necessary styles
const showElement = (element) => {
if (!element) return;
console.log('[ACCOUNT-DELETION] Showing element:', element);
// Remove any hiding classes
element.classList.remove('hidden', 'auth-only-hidden');
// Set all possible visibility properties
element.style.display = 'block';
element.style.visibility = 'visible';
element.style.opacity = '1';
element.style.height = 'auto';
element.style.position = 'relative';
element.style.clip = 'auto';
element.style.overflow = 'visible';
// Add a class to mark as visible
element.classList.add('account-visible');
};
// Function to hide an element
const hideElement = (element) => {
if (!element) return;
console.log('[ACCOUNT-DELETION] Hiding element:', element);
// Set display to none to completely remove from layout
element.style.display = 'none';
// Remove any visibility-related classes
element.classList.remove('account-visible');
};
if (isAuthenticated) {
console.log('[ACCOUNT-DELETION] User is authenticated, checking if on privacy page');
// Get the current page state - only show on #privacy-page
const currentHash = window.location.hash;
const isPrivacyPage = currentHash === '#privacy-page';
console.log('[ACCOUNT-DELETION] Debug - Page State:', {
isAuthenticated,
currentHash,
isPrivacyPage,
documentTitle: document.title
});
if (isAuthenticated && isPrivacyPage) {
console.log('[ACCOUNT-DELETION] On privacy page, showing account deletion section');
// Show the auth wrapper and account deletion section
if (authOnlyWrapper) {
authOnlyWrapper.style.display = 'block';
authOnlyWrapper.style.visibility = 'visible';
}
if (accountDeletionSection) {
accountDeletionSection.style.display = 'block';
accountDeletionSection.style.visibility = 'visible';
}
} else {
console.log('[ACCOUNT-DELETION] Not on privacy page, hiding account deletion section');
// Hide the account deletion section
if (accountDeletionSection) {
accountDeletionSection.style.display = 'none';
accountDeletionSection.style.visibility = 'hidden';
}
// Only hide the auth wrapper if we're not on the privacy page
if (authOnlyWrapper && !isPrivacyPage) {
authOnlyWrapper.style.display = 'none';
authOnlyWrapper.style.visibility = 'hidden';
}
}
// Debug: Log the current state after updates
if (accountDeletionSection) {
console.log('[ACCOUNT-DELETION] Account deletion section state after show:', {
display: window.getComputedStyle(accountDeletionSection).display,
visibility: window.getComputedStyle(accountDeletionSection).visibility,
classes: accountDeletionSection.className,
parent: accountDeletionSection.parentElement ? {
tag: accountDeletionSection.parentElement.tagName,
classes: accountDeletionSection.parentElement.className,
display: window.getComputedStyle(accountDeletionSection.parentElement).display
} : 'no parent'
});
}
} else {
console.log('[ACCOUNT-DELETION] User is not authenticated, hiding account deletion section');
// Hide the account deletion section but keep the auth-only wrapper for other potential content
if (accountDeletionSection) {
hideElement(accountDeletionSection);
}
// Only hide the auth-only wrapper if it doesn't contain other important content
if (authOnlyWrapper) {
const hasOtherContent = Array.from(authOnlyWrapper.children).some(
child => child.id !== 'account-deletion' && child.offsetParent !== null
);
if (!hasOtherContent) {
hideElement(authOnlyWrapper);
}
}
}
// Log final state for debugging
console.log('[ACCOUNT-DELETION] Final state:', {
authOnlyWrapper: authOnlyWrapper ? {
display: window.getComputedStyle(authOnlyWrapper).display,
visibility: window.getComputedStyle(authOnlyWrapper).visibility,
classes: authOnlyWrapper.className
} : 'not found',
accountDeletionSection: accountDeletionSection ? {
display: window.getComputedStyle(accountDeletionSection).display,
visibility: window.getComputedStyle(accountDeletionSection).visibility,
classes: accountDeletionSection.className,
parent: accountDeletionSection.parentElement ? {
tag: accountDeletionSection.parentElement.tagName,
classes: accountDeletionSection.parentElement.className,
display: window.getComputedStyle(accountDeletionSection.parentElement).display
} : 'no parent'
} : 'not found'
});
}
// Check authentication state and update UI with caching and debouncing
function checkAuthState(force = false) {
const now = Date.now();
// Return cached value if still valid and not forcing a refresh
if (!force && now - authStateCache.timestamp < authStateCache.ttl && authStateCache.value !== null) {
return authStateCache.value;
}
// Debounce rapid calls
if (now - lastAuthCheckTime < AUTH_CHECK_DEBOUNCE && !force) {
return wasAuthenticated === true;
}
lastAuthCheckTime = now;
authCheckCounter++;
// Use a single check for authentication state
let isAuthenticated = false;
// Check the most likely indicators first for better performance
isAuthenticated =
document.cookie.includes('isAuthenticated=') ||
document.cookie.includes('uid=') ||
localStorage.getItem('isAuthenticated') === 'true' ||
!!localStorage.getItem('authToken');
// Update cache
authStateCache = {
timestamp: now,
value: isAuthenticated,
ttl: isAuthenticated ? 30000 : 5000 // Longer TTL for authenticated users
};
if (DEBUG_AUTH_STATE && isAuthenticated !== wasAuthenticated) {
console.log('Auth State Check:', {
isAuthenticated,
wasAuthenticated,
cacheHit: !force && now - authStateCache.timestamp < authStateCache.ttl,
cacheAge: now - authStateCache.timestamp
});
}
// Only update if authentication state has changed
if (isAuthenticated !== wasAuthenticated) {
if (DEBUG_AUTH_STATE) {
console.log('Auth state changed, updating navigation...');
}
// Update UI state
if (isAuthenticated) {
document.body.classList.add('authenticated');
document.body.classList.remove('guest');
} else {
document.body.classList.remove('authenticated');
document.body.classList.add('guest');
}
// Update navigation
if (typeof injectNavigation === 'function') {
injectNavigation(isAuthenticated);
} else if (DEBUG_AUTH_STATE) {
console.warn('injectNavigation function not found');
}
// Update account deletion section visibility
updateAccountDeletionVisibility(isAuthenticated);
// Update the tracked state
wasAuthenticated = isAuthenticated;
// Force reflow to ensure CSS updates
void document.body.offsetHeight;
}
return isAuthenticated;
}
// Periodically check authentication state with optimized polling
function setupAuthStatePolling() {
// Initial check with force to ensure we get the latest state
checkAuthState(true);
// Use a single interval for all checks
const checkAndUpdate = () => {
// Only force check if the page is visible
checkAuthState(!document.hidden);
};
// Check every 30 seconds (reduced from previous implementation)
const AUTH_CHECK_INTERVAL = 30000;
setInterval(checkAndUpdate, AUTH_CHECK_INTERVAL);
// Listen for storage events (like login/logout from other tabs)
const handleStorageEvent = (e) => {
if (['isAuthenticated', 'authToken', 'uid'].includes(e.key)) {
checkAuthState(true); // Force check on relevant storage changes
}
};
window.addEventListener('storage', handleStorageEvent);
// Check auth state when page becomes visible
const handleVisibilityChange = () => {
if (!document.hidden) {
checkAuthState(true);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Cleanup function
return () => {
window.removeEventListener('storage', handleStorageEvent);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}
// Function to handle page navigation
function handlePageNavigation() {
const isAuthenticated = checkAuthState();
updateAccountDeletionVisibility(isAuthenticated);
}
// Initialize the application when DOM is loaded
document.addEventListener("DOMContentLoaded", () => {
// Set up authentication state monitoring
setupAuthStatePolling();
// Handle magic link redirect if needed
handleMagicLoginRedirect();
// Initialize components
initNavigation();
// Register with global audio manager to handle stop requests from other players
globalAudioManager.addListener('personal', () => {
console.log('[app.js] Received stop request from global audio manager');
const audio = getOrCreateAudioElement();
if (audio && !audio.paused) {
audio.pause();
const playButton = document.querySelector('.play-pause-btn');
if (playButton) {
updatePlayPauseButton(audio, playButton);
}
}
});
// Initialize account deletion section visibility
handlePageNavigation();
// Listen for hash changes to update visibility when navigating
window.addEventListener('hashchange', handlePageNavigation);
// 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) {
// 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;
}
// Notify global audio manager that personal player is starting
const uid = localStorage.getItem('uid') || 'personal-stream';
globalAudioManager.startPlayback('personal', uid);
// 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();
// Notify global audio manager that personal player has stopped
globalAudioManager.stopPlayback('personal');
if (window.currentlyPlayingAudio === audio) {
window.currentlyPlayingAudio = null;
window.currentlyPlayingButton = null;
}
updatePlayPauseButton(audio, playPauseBtn);
}
} catch (e) {
console.error('Audio error:', e);
updatePlayPauseButton(audio, playPauseBtn);
}
});
// Set up delete account button if it exists
const deleteAccountBtn = document.getElementById('delete-account');
const deleteAccountFromPrivacyBtn = document.getElementById('delete-account-from-privacy');
const deleteAccount = async (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (!confirm('Are you sure you want to delete your account?\n\nThis action cannot be undone.')) {
return;
}
// Show loading state
const deleteBtn = e?.target.closest('button');
const originalText = deleteBtn?.textContent || 'Delete My Account';
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.textContent = 'Deleting...';
}
try {
// Get UID from localStorage
const uid = localStorage.getItem('uid');
if (!uid) {
throw new Error('User not authenticated. Please log in again.');
}
console.log('Sending delete account request for UID:', uid);
const response = await fetch('/api/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
uid: uid // Include UID in the request body
})
});
console.log('Received response status:', response.status, response.statusText);
// Try to parse response as JSON, but handle non-JSON responses
let data;
const text = await response.text();
try {
data = text ? JSON.parse(text) : {};
} catch (parseError) {
console.error('Failed to parse response as JSON:', parseError);
console.log('Raw response text:', text);
data = {};
}
if (response.ok) {
console.log('Account deletion successful');
showToast('✅ Account deleted successfully', 'success');
// Clear local storage and redirect to home page after a short delay
setTimeout(() => {
localStorage.clear();
window.location.href = '/';
}, 1000);
} else {
console.error('Delete account failed:', { status: response.status, data });
const errorMessage = data.detail || data.message ||
data.error ||
`Server returned ${response.status} ${response.statusText}`;
throw new Error(errorMessage);
}
} catch (error) {
console.error('Error in deleteAccount:', {
name: error.name,
message: error.message,
stack: error.stack,
error: error
});
// Try to extract a meaningful error message
let errorMessage = 'Failed to delete account';
if (error instanceof Error) {
errorMessage = error.message || error.toString();
} else if (typeof error === 'string') {
errorMessage = error;
} else if (error && typeof error === 'object') {
errorMessage = error.message || JSON.stringify(error);
}
showToast(`${errorMessage}`, 'error');
} finally {
// Restore button state
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.textContent = originalText;
}
}
};
// Add event listeners to both delete account buttons
if (deleteAccountBtn) {
deleteAccountBtn.addEventListener('click', deleteAccount);
}
if (deleteAccountFromPrivacyBtn) {
deleteAccountFromPrivacyBtn.addEventListener('click', deleteAccount);
}
}, 200); // End of setTimeout
});
// Logout function
async function logout(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
// If handleLogout is available in dashboard.js, use it for comprehensive logout
if (typeof handleLogout === 'function') {
try {
await handleLogout(event);
} catch (error) {
console.error('Error during logout:', error);
// Fall back to basic logout if handleLogout fails
basicLogout();
}
} else {
// Fallback to basic logout if handleLogout is not available
basicLogout();
}
}
// Basic client-side logout as fallback
function basicLogout() {
// Clear authentication state
document.body.classList.remove('authenticated');
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('uid');
localStorage.removeItem('confirmed_uid');
localStorage.removeItem('uid_time');
localStorage.removeItem('authToken');
// Clear all cookies with proper SameSite attribute
document.cookie.split(';').forEach(cookie => {
const [name] = cookie.trim().split('=');
if (name) {
document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=${window.location.hostname}; SameSite=Lax`;
}
});
// Stop any playing audio
stopMainAudio();
// Force a hard redirect to ensure all state is cleared
window.location.href = '/';
}
// Add click handler for logout button
document.addEventListener('click', (e) => {
if (e.target.id === 'logout-button' || e.target.closest('#logout-button')) {
e.preventDefault();
logout();
}
});
// Expose functions for global access
window.logToServer = logToServer;
window.getMainAudio = () => globalAudio;
window.stopMainAudio = () => {
if (globalAudio) {
globalAudio.pause();
audioPlaying = false;
updatePlayPauseButton();
}
};
window.loadProfileStream = loadProfileStream;