diff --git a/asteroid.asd b/asteroid.asd index 3d0f83e..7a4d6ac 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -44,7 +44,8 @@ (:file "parenscript-utils") (:module :parenscript :components ((:file "auth-ui") - (:file "front-page"))) + (:file "front-page") + (:file "profile"))) (:file "stream-media") (:file "user-management") (:file "playlist-management") diff --git a/asteroid.lisp b/asteroid.lisp index b1fd818..5ef76e7 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -499,6 +499,18 @@ (format t "ERROR generating front-page.js: ~a~%" e) (format nil "// Error generating JavaScript: ~a~%" e)))) + ;; Serve ParenScript-compiled profile.js + ((string= path "js/profile.js") + (format t "~%=== SERVING PARENSCRIPT profile.js ===~%") + (setf (content-type *response*) "application/javascript") + (handler-case + (let ((js (generate-profile-js))) + (format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL")) + (if js js "// Error: No JavaScript generated")) + (error (e) + (format t "ERROR generating profile.js: ~a~%" e) + (format nil "// Error generating JavaScript: ~a~%" e)))) + ;; Serve regular static file (t (serve-file (merge-pathnames (format nil "static/~a" path) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp new file mode 100644 index 0000000..c5c3499 --- /dev/null +++ b/parenscript/profile.lisp @@ -0,0 +1,303 @@ +;;;; profile.lisp - ParenScript version of profile.js +;;;; User profile page with listening stats and history + +(in-package #:asteroid) + +(defparameter *profile-js* + (ps:ps* + '(progn + + ;; Global state + (defvar *current-user* nil) + (defvar *listening-data* nil) + + ;; Utility functions + (defun update-element (data-text value) + (let ((element (ps:chain document (query-selector (+ "[data-text=\"" data-text "\"]"))))) + (when (and element (not (= value undefined)) (not (= value null))) + (setf (ps:@ element text-content) value)))) + + (defun format-role (role) + (let ((role-map (ps:create + "admin" "πŸ‘‘ Admin" + "dj" "🎧 DJ" + "listener" "🎡 Listener"))) + (or (ps:getprop role-map role) role))) + + (defun format-date (date-string) + (let ((date (ps:new (-date date-string)))) + (ps:chain date (to-locale-date-string "en-US" + (ps:create :year "numeric" + :month "long" + :day "numeric"))))) + + (defun format-relative-time (date-string) + (let* ((date (ps:new (-date date-string))) + (now (ps:new (-date))) + (diff-ms (- now date)) + (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) + (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) + (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) + (cond + ((> diff-days 0) + (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) + ((> diff-hours 0) + (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) + ((> diff-minutes 0) + (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) + (t "Just now")))) + + (defun format-duration (seconds) + (let ((hours (ps:chain -math (floor (/ seconds 3600)))) + (minutes (ps:chain -math (floor (/ (rem seconds 3600) 60))))) + (if (> hours 0) + (+ hours "h " minutes "m") + (+ minutes "m")))) + + (defun show-message (message &optional (type "info")) + (let ((toast (ps:chain document (create-element "div"))) + (colors (ps:create + "info" "#007bff" + "success" "#28a745" + "error" "#dc3545" + "warning" "#ffc107"))) + (setf (ps:@ toast class-name) (+ "toast toast-" type)) + (setf (ps:@ toast text-content) message) + (setf (ps:@ toast style css-text) + "position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 4px; color: white; font-weight: bold; z-index: 1000; opacity: 0; transition: opacity 0.3s ease;") + (setf (ps:@ toast style background-color) (or (ps:getprop colors type) (ps:getprop colors "info"))) + + (ps:chain document body (append-child toast)) + + (set-timeout (lambda () (setf (ps:@ toast style opacity) "1")) 100) + (set-timeout (lambda () + (setf (ps:@ toast style opacity) "0") + (set-timeout (lambda () (ps:chain document body (remove-child toast))) 300)) + 3000))) + + (defun show-error (message) + (show-message message "error")) + + ;; Profile data loading + (defun update-profile-display (user) + (update-element "username" (or (ps:@ user username) "Unknown User")) + (update-element "user-role" (format-role (or (ps:@ user role) "listener"))) + (update-element "join-date" (format-date (or (ps:@ user created_at) (ps:new (-date))))) + (update-element "last-active" (format-relative-time (or (ps:@ user last_active) (ps:new (-date))))) + + (let ((admin-link (ps:chain document (query-selector "[data-show-if-admin]")))) + (when admin-link + (setf (ps:@ admin-link style display) + (if (= (ps:@ user role) "admin") "inline" "none"))))) + + (defun load-listening-stats () + (ps:chain + (fetch "/api/asteroid/user/listening-stats") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (when (= (ps:@ data status) "success") + (let ((stats (ps:@ data stats))) + (update-element "total-listen-time" (format-duration (or (ps:@ stats total_listen_time) 0))) + (update-element "tracks-played" (or (ps:@ stats tracks_played) 0)) + (update-element "session-count" (or (ps:@ stats session_count) 0)) + (update-element "favorite-genre" (or (ps:@ stats favorite_genre) "Unknown"))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading listening stats:" error)) + (update-element "total-listen-time" "0h 0m") + (update-element "tracks-played" "0") + (update-element "session-count" "0") + (update-element "favorite-genre" "Unknown"))))) + + (defun load-recent-tracks () + (ps:chain + (fetch "/api/asteroid/user/recent-tracks?limit=3") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (ps:chain data tracks + (for-each (lambda (track index) + (let ((track-num (+ index 1))) + (update-element (+ "recent-track-" track-num "-title") + (or (ps:@ track title) "Unknown Track")) + (update-element (+ "recent-track-" track-num "-artist") + (or (ps:@ track artist) "Unknown Artist")) + (update-element (+ "recent-track-" track-num "-duration") + (format-duration (or (ps:@ track duration) 0))) + (update-element (+ "recent-track-" track-num "-played-at") + (format-relative-time (ps:@ track played_at))))))) + (loop for i from 1 to 3 + do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]")) + (track-item-el (ps:chain document (query-selector track-item-selector))) + (track-item (when track-item-el (ps:chain track-item-el (closest ".track-item"))))) + (when (and track-item + (or (not (ps:@ data tracks)) + (not (ps:getprop (ps:@ data tracks) (- i 1))))) + (setf (ps:@ track-item style display) "none")))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading recent tracks:" error)))))) + + (defun load-top-artists () + (ps:chain + (fetch "/api/asteroid/user/top-artists?limit=5") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (and (= (ps:@ data status) "success") + (ps:@ data artists) + (> (ps:@ data artists length) 0)) + (ps:chain data artists + (for-each (lambda (artist index) + (let ((artist-num (+ index 1))) + (update-element (+ "top-artist-" artist-num) + (or (ps:@ artist name) "Unknown Artist")) + (update-element (+ "top-artist-" artist-num "-plays") + (+ (or (ps:@ artist play_count) 0) " plays")))))) + (loop for i from 1 to 5 + do (let* ((artist-item-selector (+ "[data-text=\"top-artist-" i "\"]")) + (artist-item-el (ps:chain document (query-selector artist-item-selector))) + (artist-item (when artist-item-el (ps:chain artist-item-el (closest ".artist-item"))))) + (when (and artist-item + (or (not (ps:@ data artists)) + (not (ps:getprop (ps:@ data artists) (- i 1))))) + (setf (ps:@ artist-item style display) "none")))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading top artists:" error)))))) + + (defun load-profile-data () + (ps:chain console (log "Loading profile data...")) + + (ps:chain + (fetch "/api/asteroid/user/profile") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (setf *current-user* (ps:@ data user)) + (update-profile-display (ps:@ data user))) + (progn + (ps:chain console (error "Failed to load profile:" (ps:@ data message))) + (show-error "Failed to load profile data")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading profile:" error)) + (show-error "Error loading profile data")))) + + (load-listening-stats) + (load-recent-tracks) + (load-top-artists)) + + ;; Action functions + (defun load-more-recent-tracks () + (ps:chain console (log "Loading more recent tracks...")) + (show-message "Loading more tracks..." "info")) + + (defun edit-profile () + (ps:chain console (log "Edit profile clicked")) + (show-message "Profile editing coming soon!" "info")) + + (defun export-listening-data () + (ps:chain console (log "Exporting listening data...")) + (show-message "Preparing data export..." "info") + + (ps:chain + (fetch "/api/asteroid/user/export-data" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (blob)))) + (then (lambda (blob) + (let* ((url (ps:chain window -u-r-l (create-object-u-r-l blob))) + (a (ps:chain document (create-element "a")))) + (setf (ps:@ a style display) "none") + (setf (ps:@ a href) url) + (setf (ps:@ a download) (+ "asteroid-listening-data-" + (or (ps:@ *current-user* username) "user") + ".json")) + (ps:chain document body (append-child a)) + (ps:chain a (click)) + (ps:chain window -u-r-l (revoke-object-u-r-l url)) + (show-message "Data exported successfully!" "success")))) + (catch (lambda (error) + (ps:chain console (error "Error exporting data:" error)) + (show-message "Failed to export data" "error"))))) + + (defun clear-listening-history () + (when (not (confirm "Are you sure you want to clear your listening history? This action cannot be undone.")) + (return)) + + (ps:chain console (log "Clearing listening history...")) + (show-message "Clearing listening history..." "info") + + (ps:chain + (fetch "/api/asteroid/user/clear-history" (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (data) + (if (= (ps:@ data status) "success") + (progn + (show-message "Listening history cleared successfully!" "success") + (set-timeout (lambda () (ps:chain location (reload))) 1500)) + (show-message (+ "Failed to clear history: " (ps:@ data message)) "error")))) + (catch (lambda (error) + (ps:chain console (error "Error clearing history:" error)) + (show-message "Failed to clear history" "error"))))) + + ;; Password change + (defun change-password (event) + (ps:chain event (prevent-default)) + + (let ((current-password (ps:@ (ps:chain document (get-element-by-id "current-password")) value)) + (new-password (ps:@ (ps:chain document (get-element-by-id "new-password")) value)) + (confirm-password (ps:@ (ps:chain document (get-element-by-id "confirm-password")) value)) + (message-div (ps:chain document (get-element-by-id "password-message")))) + + ;; Client-side validation + (cond + ((< (ps:@ new-password length) 8) + (setf (ps:@ message-div text-content) "New password must be at least 8 characters") + (setf (ps:@ message-div class-name) "message error") + (return false)) + ((not (= new-password confirm-password)) + (setf (ps:@ message-div text-content) "New passwords do not match") + (setf (ps:@ message-div class-name) "message error") + (return false))) + + ;; Send request to API + (let ((form-data (ps:new (-form-data)))) + (ps:chain form-data (append "current-password" current-password)) + (ps:chain form-data (append "new-password" new-password)) + + (ps:chain + (fetch "/api/asteroid/user/change-password" + (ps:create :method "POST" :body form-data)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (data) + (if (or (= (ps:@ data status) "success") + (and (ps:@ data data) (= (ps:@ data data status) "success"))) + (progn + (setf (ps:@ message-div text-content) "Password changed successfully!") + (setf (ps:@ message-div class-name) "message success") + (ps:chain (ps:chain document (get-element-by-id "change-password-form")) (reset))) + (progn + (setf (ps:@ message-div text-content) + (or (ps:@ data message) + (ps:@ data data message) + "Failed to change password")) + (setf (ps:@ message-div class-name) "message error"))))) + (catch (lambda (error) + (ps:chain console (error "Error changing password:" error)) + (setf (ps:@ message-div text-content) "Error changing password") + (setf (ps:@ message-div class-name) "message error"))))) + + false)) + + ;; Initialize on page load + (ps:chain window + (add-event-listener + "DOMContentLoaded" + load-profile-data)))) + "Compiled JavaScript for profile page - generated at load time") + +(defun generate-profile-js () + "Return the pre-compiled JavaScript for profile page" + *profile-js*) diff --git a/static/js/player.js.original b/static/js/player.js.original new file mode 100644 index 0000000..f0ae480 --- /dev/null +++ b/static/js/player.js.original @@ -0,0 +1,610 @@ +// Web Player JavaScript +let tracks = []; +let currentTrack = null; +let currentTrackIndex = -1; +let playQueue = []; +let isShuffled = false; +let isRepeating = false; +let audioPlayer = null; + +// Pagination variables for track library +let libraryCurrentPage = 1; +let libraryTracksPerPage = 20; +let filteredLibraryTracks = []; + +document.addEventListener('DOMContentLoaded', function() { + audioPlayer = document.getElementById('audio-player'); + redirectWhenFrame(); + loadTracks(); + loadPlaylists(); + setupEventListeners(); + updatePlayerDisplay(); + updateVolume(); + + // Setup live stream with reduced buffering + const liveAudio = document.getElementById('live-stream-audio'); + if (liveAudio) { + // Reduce buffer to minimize delay + liveAudio.preload = 'none'; + } + // Restore user quality preference + const selector = document.getElementById('live-stream-quality'); + const streamQuality = localStorage.getItem('stream-quality') || 'aac'; + if (selector && selector.value !== streamQuality) { + selector.value = streamQuality; + selector.dispatchEvent(new Event('change')); + } +}); + +function redirectWhenFrame () { + const path = window.location.pathname; + const isFramesetPage = window.parent !== window.self; + const isContentFrame = path.includes('player-content'); + + if (isFramesetPage && !isContentFrame) { + window.location.href = '/asteroid/player-content'; + } + if (!isFramesetPage && isContentFrame) { + window.location.href = '/asteroid/player'; + } +} + +function setupEventListeners() { + // Search + document.getElementById('search-tracks').addEventListener('input', filterTracks); + + // Player controls + document.getElementById('play-pause-btn').addEventListener('click', togglePlayPause); + document.getElementById('prev-btn').addEventListener('click', playPrevious); + document.getElementById('next-btn').addEventListener('click', playNext); + document.getElementById('shuffle-btn').addEventListener('click', toggleShuffle); + document.getElementById('repeat-btn').addEventListener('click', toggleRepeat); + + // Volume control + document.getElementById('volume-slider').addEventListener('input', updateVolume); + + // Audio player events + if (audioPlayer) { + audioPlayer.addEventListener('loadedmetadata', updateTimeDisplay); + audioPlayer.addEventListener('timeupdate', updateTimeDisplay); + audioPlayer.addEventListener('ended', handleTrackEnd); + audioPlayer.addEventListener('play', () => updatePlayButton('⏸️ Pause')); + audioPlayer.addEventListener('pause', () => updatePlayButton('▢️ Play')); + } + + // Playlist controls + document.getElementById('create-playlist').addEventListener('click', createPlaylist); + document.getElementById('clear-queue').addEventListener('click', clearQueue); + document.getElementById('save-queue').addEventListener('click', saveQueueAsPlaylist); +} + +async function loadTracks() { + try { + const response = await fetch('/api/asteroid/tracks'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const result = await response.json(); + // Handle RADIANCE API wrapper format + const data = result.data || result; + + if (data.status === 'success') { + tracks = data.tracks || []; + displayTracks(tracks); + } else { + console.error('Error loading tracks:', data.error); + document.getElementById('track-list').innerHTML = '
Error loading tracks
'; + } + } catch (error) { + console.error('Error loading tracks:', error); + document.getElementById('track-list').innerHTML = '
Error loading tracks
'; + } +} + +function displayTracks(trackList) { + filteredLibraryTracks = trackList; + libraryCurrentPage = 1; + renderLibraryPage(); +} + +function renderLibraryPage() { + const container = document.getElementById('track-list'); + const paginationControls = document.getElementById('library-pagination-controls'); + + if (filteredLibraryTracks.length === 0) { + container.innerHTML = '
No tracks found
'; + paginationControls.style.display = 'none'; + return; + } + + // Calculate pagination + const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage); + const startIndex = (libraryCurrentPage - 1) * libraryTracksPerPage; + const endIndex = startIndex + libraryTracksPerPage; + const tracksToShow = filteredLibraryTracks.slice(startIndex, endIndex); + + // Render tracks for current page + const tracksHtml = tracksToShow.map((track, pageIndex) => { + // Find the actual index in the full tracks array + const actualIndex = tracks.findIndex(t => t.id === track.id); + return ` +
+
+
${track.title[0] || 'Unknown Title'}
+
${track.artist[0] || 'Unknown Artist'} β€’ ${track.album[0] || 'Unknown Album'}
+
+
+ + +
+
+ `}).join(''); + + container.innerHTML = tracksHtml; + + // Update pagination controls + document.getElementById('library-page-info').textContent = `Page ${libraryCurrentPage} of ${totalPages} (${filteredLibraryTracks.length} tracks)`; + paginationControls.style.display = totalPages > 1 ? 'block' : 'none'; +} + +// Library pagination functions +function libraryGoToPage(page) { + const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage); + if (page >= 1 && page <= totalPages) { + libraryCurrentPage = page; + renderLibraryPage(); + } +} + +function libraryPreviousPage() { + if (libraryCurrentPage > 1) { + libraryCurrentPage--; + renderLibraryPage(); + } +} + +function libraryNextPage() { + const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage); + if (libraryCurrentPage < totalPages) { + libraryCurrentPage++; + renderLibraryPage(); + } +} + +function libraryGoToLastPage() { + const totalPages = Math.ceil(filteredLibraryTracks.length / libraryTracksPerPage); + libraryCurrentPage = totalPages; + renderLibraryPage(); +} + +function changeLibraryTracksPerPage() { + libraryTracksPerPage = parseInt(document.getElementById('library-tracks-per-page').value); + libraryCurrentPage = 1; + renderLibraryPage(); +} + +function filterTracks() { + const query = document.getElementById('search-tracks').value.toLowerCase(); + const filtered = tracks.filter(track => + (track.title[0] || '').toLowerCase().includes(query) || + (track.artist[0] || '').toLowerCase().includes(query) || + (track.album[0] || '').toLowerCase().includes(query) + ); + displayTracks(filtered); +} + +function playTrack(index) { + if (index < 0 || index >= tracks.length) return; + + currentTrack = tracks[index]; + currentTrackIndex = index; + + // Load track into audio player + audioPlayer.src = `/asteroid/tracks/${currentTrack.id}/stream`; + audioPlayer.load(); + audioPlayer.play().catch(error => { + console.error('Playback error:', error); + alert('Error playing track. The track may not be available.'); + }); + + updatePlayerDisplay(); + + // Update server-side player state + fetch(`/api/asteroid/player/play?track-id=${currentTrack.id}`, { method: 'POST' }) + .catch(error => console.error('API update error:', error)); +} + +function togglePlayPause() { + if (!currentTrack) { + alert('Please select a track to play'); + return; + } + + if (audioPlayer.paused) { + audioPlayer.play(); + } else { + audioPlayer.pause(); + } +} + +function playPrevious() { + if (playQueue.length > 0) { + // Play from queue + const prevIndex = Math.max(0, currentTrackIndex - 1); + playTrack(prevIndex); + } else { + // Play previous track in library + const prevIndex = currentTrackIndex > 0 ? currentTrackIndex - 1 : tracks.length - 1; + playTrack(prevIndex); + } +} + +function playNext() { + if (playQueue.length > 0) { + // Play from queue + const nextTrack = playQueue.shift(); + playTrack(tracks.findIndex(t => t.id === nextTrack.id)); + updateQueueDisplay(); + } else { + // Play next track in library + const nextIndex = isShuffled ? + Math.floor(Math.random() * tracks.length) : + (currentTrackIndex + 1) % tracks.length; + playTrack(nextIndex); + } +} + +function handleTrackEnd() { + if (isRepeating) { + audioPlayer.currentTime = 0; + audioPlayer.play(); + } else { + playNext(); + } +} + +function toggleShuffle() { + isShuffled = !isShuffled; + const btn = document.getElementById('shuffle-btn'); + btn.textContent = isShuffled ? 'πŸ”€ Shuffle ON' : 'πŸ”€ Shuffle'; + btn.classList.toggle('active', isShuffled); +} + +function toggleRepeat() { + isRepeating = !isRepeating; + const btn = document.getElementById('repeat-btn'); + btn.textContent = isRepeating ? 'πŸ” Repeat ON' : 'πŸ” Repeat'; + btn.classList.toggle('active', isRepeating); +} + +function updateVolume() { + const volume = document.getElementById('volume-slider').value / 100; + if (audioPlayer) { + audioPlayer.volume = volume; + } +} + +function updateTimeDisplay() { + const current = formatTime(audioPlayer.currentTime); + const total = formatTime(audioPlayer.duration); + document.getElementById('current-time').textContent = current; + document.getElementById('total-time').textContent = total; +} + +function formatTime(seconds) { + if (isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function updatePlayButton(text) { + document.getElementById('play-pause-btn').textContent = text; +} + +function updatePlayerDisplay() { + if (currentTrack) { + document.getElementById('current-title').textContent = currentTrack.title[0] || 'Unknown Title'; + document.getElementById('current-artist').textContent = currentTrack.artist[0] || 'Unknown Artist'; + document.getElementById('current-album').textContent = currentTrack.album[0] || 'Unknown Album'; + } +} + +function addToQueue(index) { + if (index < 0 || index >= tracks.length) return; + + playQueue.push(tracks[index]); + updateQueueDisplay(); +} + +function updateQueueDisplay() { + const container = document.getElementById('play-queue'); + + if (playQueue.length === 0) { + container.innerHTML = '
Queue is empty
'; + return; + } + + const queueHtml = playQueue.map((track, index) => ` +
+
+
${track.title[0] || 'Unknown Title'}
+
${track.artist[0] || 'Unknown Artist'}
+
+ +
+ `).join(''); + + container.innerHTML = queueHtml; +} + +function removeFromQueue(index) { + playQueue.splice(index, 1); + updateQueueDisplay(); +} + +function clearQueue() { + playQueue = []; + updateQueueDisplay(); +} + +async function createPlaylist() { + const name = document.getElementById('new-playlist-name').value.trim(); + if (!name) { + alert('Please enter a playlist name'); + return; + } + + try { + const formData = new FormData(); + formData.append('name', name); + formData.append('description', ''); + + const response = await fetch('/api/asteroid/playlists/create', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.status === 'success') { + alert(`Playlist "${name}" created successfully!`); + document.getElementById('new-playlist-name').value = ''; + + // Wait a moment then reload playlists + await new Promise(resolve => setTimeout(resolve, 500)); + loadPlaylists(); + } else { + alert('Error creating playlist: ' + result.message); + } + } catch (error) { + console.error('Error creating playlist:', error); + alert('Error creating playlist: ' + error.message); + } +} + +async function saveQueueAsPlaylist() { + if (playQueue.length === 0) { + alert('Queue is empty'); + return; + } + + const name = prompt('Enter playlist name:'); + if (!name) return; + + try { + // First create the playlist + const formData = new FormData(); + formData.append('name', name); + formData.append('description', `Created from queue with ${playQueue.length} tracks`); + + const createResponse = await fetch('/api/asteroid/playlists/create', { + method: 'POST', + body: formData + }); + + const createResult = await createResponse.json(); + + if (createResult.status === 'success') { + // Wait a moment for database to update + await new Promise(resolve => setTimeout(resolve, 500)); + + // Get the new playlist ID by fetching playlists + const playlistsResponse = await fetch('/api/asteroid/playlists'); + const playlistsResult = await playlistsResponse.json(); + + if (playlistsResult.status === 'success' && playlistsResult.playlists.length > 0) { + // Find the playlist with matching name (most recent) + const newPlaylist = playlistsResult.playlists.find(p => p.name === name) || + playlistsResult.playlists[playlistsResult.playlists.length - 1]; + + // Add all tracks from queue to playlist + let addedCount = 0; + for (const track of playQueue) { + const trackId = track.id || (Array.isArray(track.id) ? track.id[0] : null); + + if (trackId) { + const addFormData = new FormData(); + addFormData.append('playlist-id', newPlaylist.id); + addFormData.append('track-id', trackId); + + const addResponse = await fetch('/api/asteroid/playlists/add-track', { + method: 'POST', + body: addFormData + }); + + const addResult = await addResponse.json(); + + if (addResult.status === 'success') { + addedCount++; + } + } else { + console.error('Track has no valid ID:', track); + } + } + + alert(`Playlist "${name}" created with ${addedCount} tracks!`); + loadPlaylists(); + } else { + alert('Playlist created but could not add tracks. Error: ' + (playlistsResult.message || 'Unknown')); + } + } else { + alert('Error creating playlist: ' + createResult.message); + } + } catch (error) { + console.error('Error saving queue as playlist:', error); + alert('Error saving queue as playlist: ' + error.message); + } +} + +async function loadPlaylists() { + try { + const response = await fetch('/api/asteroid/playlists'); + const result = await response.json(); + + if (result.data && result.data.status === 'success') { + displayPlaylists(result.data.playlists || []); + } else if (result.status === 'success') { + displayPlaylists(result.playlists || []); + } else { + displayPlaylists([]); + } + } catch (error) { + console.error('Error loading playlists:', error); + displayPlaylists([]); + } +} + +function displayPlaylists(playlists) { + const container = document.getElementById('playlists-container'); + + if (!playlists || playlists.length === 0) { + container.innerHTML = '
No playlists created yet.
'; + return; + } + + const playlistsHtml = playlists.map(playlist => ` +
+
+
${playlist.name}
+
${playlist['track-count']} tracks
+
+
+ +
+
+ `).join(''); + + container.innerHTML = playlistsHtml; +} + +async function loadPlaylist(playlistId) { + try { + const response = await fetch(`/api/asteroid/playlists/get?playlist-id=${playlistId}`); + const result = await response.json(); + + if (result.status === 'success' && result.playlist) { + const playlist = result.playlist; + + // Clear current queue + playQueue = []; + + // Add all playlist tracks to queue + if (playlist.tracks && playlist.tracks.length > 0) { + playlist.tracks.forEach(track => { + // Find the full track object from our tracks array + const fullTrack = tracks.find(t => t.id === track.id); + if (fullTrack) { + playQueue.push(fullTrack); + } + }); + + updateQueueDisplay(); + alert(`Loaded ${playQueue.length} tracks from "${playlist.name}" into queue!`); + + // Optionally start playing the first track + if (playQueue.length > 0) { + const firstTrack = playQueue.shift(); + const trackIndex = tracks.findIndex(t => t.id === firstTrack.id); + if (trackIndex >= 0) { + playTrack(trackIndex); + } + } + } else { + alert(`Playlist "${playlist.name}" is empty`); + } + } else { + alert('Error loading playlist: ' + (result.message || 'Unknown error')); + } + } catch (error) { + console.error('Error loading playlist:', error); + alert('Error loading playlist: ' + error.message); + } +} + +// Stream quality configuration (same as front page) +function getLiveStreamConfig(streamBaseUrl, quality) { + const config = { + aac: { + url: `${streamBaseUrl}/asteroid.aac`, + type: 'audio/aac', + mount: 'asteroid.aac' + }, + mp3: { + url: `${streamBaseUrl}/asteroid.mp3`, + type: 'audio/mpeg', + mount: 'asteroid.mp3' + }, + low: { + url: `${streamBaseUrl}/asteroid-low.mp3`, + type: 'audio/mpeg', + mount: 'asteroid-low.mp3' + } + }; + + return config[quality]; +}; + +// Change live stream quality +function changeLiveStreamQuality() { + const streamBaseUrl = document.getElementById('stream-base-url'); + const selector = document.getElementById('live-stream-quality'); + const config = getLiveStreamConfig(streamBaseUrl.value, selector.value); + + // Update audio player + const audioElement = document.getElementById('live-stream-audio'); + const sourceElement = document.getElementById('live-stream-source'); + + const wasPlaying = !audioElement.paused; + + sourceElement.src = config.url; + sourceElement.type = config.type; + audioElement.load(); + + // Resume playback if it was playing + if (wasPlaying) { + audioElement.play().catch(e => console.log('Autoplay prevented:', e)); + } +} + +// Live stream informatio update +async function updateNowPlaying() { + try { + const response = await fetch('/api/asteroid/partial/now-playing') + const contentType = response.headers.get("content-type") + if (!contentType.includes('text/html')) { + throw new Error('Error connecting to stream') + } + + const data = await response.text() + document.getElementById('now-playing').innerHTML = data + + } catch(error) { + console.log('Could not fetch stream status:', error); + } +} + +// Initial update after 1 second +setTimeout(updateNowPlaying, 1000); +// Update live stream info every 10 seconds +setInterval(updateNowPlaying, 10000); diff --git a/static/js/profile.js b/static/js/profile.js.original similarity index 100% rename from static/js/profile.js rename to static/js/profile.js.original