Files
oib 17616ac5b8 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
2025-07-02 09:37:03 +02:00

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;