feat: Convert profile.js to ParenScript
Successfully converted profile.js with all functionality: - Profile data loading (username, role, join date, last active) - Listening statistics display - Recent tracks display - Top artists display - Password change form - Export listening data - Clear listening history - Toast notifications Generated JavaScript working correctly after fixing modulo operator. Key learning: Use 'rem' instead of '%' for modulo in ParenScript. Files: - parenscript/profile.lisp - ParenScript source - asteroid.asd - Added profile to parenscript module - asteroid.lisp - Added profile.js to static route interception - static/js/profile.js - Removed from git (backed up as .original) - static/js/player.js - Restored (skipped for now, too complex) Three files successfully converted to ParenScript\!
This commit is contained in:
parent
3d3ef1818f
commit
df688fd705
|
|
@ -42,7 +42,8 @@
|
||||||
(:file "parenscript-utils")
|
(:file "parenscript-utils")
|
||||||
(:module :parenscript
|
(:module :parenscript
|
||||||
:components ((:file "auth-ui")
|
:components ((:file "auth-ui")
|
||||||
(:file "front-page")))
|
(:file "front-page")
|
||||||
|
(:file "profile")))
|
||||||
(:file "stream-media")
|
(:file "stream-media")
|
||||||
(:file "user-management")
|
(:file "user-management")
|
||||||
(:file "playlist-management")
|
(:file "playlist-management")
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,18 @@
|
||||||
(format t "ERROR generating front-page.js: ~a~%" e)
|
(format t "ERROR generating front-page.js: ~a~%" e)
|
||||||
(format nil "// Error generating JavaScript: ~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
|
;; Serve regular static file
|
||||||
(t
|
(t
|
||||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||||
|
|
|
||||||
|
|
@ -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*)
|
||||||
|
|
@ -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 = '<div class="error">Error loading tracks</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tracks:', error);
|
||||||
|
document.getElementById('track-list').innerHTML = '<div class="error">Error loading tracks</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="no-tracks">No tracks found</div>';
|
||||||
|
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 `
|
||||||
|
<div class="track-item" data-track-id="${track.id}" data-index="${actualIndex}">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||||
|
<div class="track-meta">${track.artist[0] || 'Unknown Artist'} • ${track.album[0] || 'Unknown Album'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="track-actions">
|
||||||
|
<button onclick="playTrack(${actualIndex})" class="btn btn-sm btn-success">▶️</button>
|
||||||
|
<button onclick="addToQueue(${actualIndex})" class="btn btn-sm btn-info">➕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}).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 = '<div class="empty-queue">Queue is empty</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueHtml = playQueue.map((track, index) => `
|
||||||
|
<div class="queue-item">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||||
|
<div class="track-meta">${track.artist[0] || 'Unknown Artist'}</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="removeFromQueue(${index})" class="btn btn-sm btn-danger">✖️</button>
|
||||||
|
</div>
|
||||||
|
`).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 = '<div class="no-playlists">No playlists created yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistsHtml = playlists.map(playlist => `
|
||||||
|
<div class="playlist-item">
|
||||||
|
<div class="playlist-info">
|
||||||
|
<div class="playlist-name">${playlist.name}</div>
|
||||||
|
<div class="playlist-meta">${playlist['track-count']} tracks</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-actions">
|
||||||
|
<button onclick="loadPlaylist(${playlist.id})" class="btn btn-sm btn-info">📂 Load</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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);
|
||||||
Loading…
Reference in New Issue