
- Add Alembic for database migrations - Implement user authentication system - Update frontend styles and components - Add new test audio functionality - Update stream management and UI
501 lines
17 KiB
JavaScript
501 lines
17 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";
|
|
|
|
// 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');
|
|
localStorage.setItem('uid', username);
|
|
localStorage.setItem('confirmed_uid', username);
|
|
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);
|
|
|
|
// Navigate to user's profile page
|
|
if (window.showOnly) {
|
|
window.showOnly('me-page');
|
|
} else if (window.location.hash !== '#me') {
|
|
window.location.hash = '#me';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
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) {
|
|
// 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);
|
|
}
|
|
} catch (e) {
|
|
console.error('Audio error:', e);
|
|
updatePlayPauseButton(audio, playPauseBtn);
|
|
}
|
|
});
|
|
|
|
// Set up delete account button if it exists
|
|
const deleteAccountBtn = document.getElementById('delete-account');
|
|
if (deleteAccountBtn) {
|
|
deleteAccountBtn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
|
|
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();
|
|
}
|
|
};
|
|
window.loadProfileStream = loadProfileStream;
|