Compare commits

..

6 Commits

Author SHA1 Message Date
Glenn Thompson 5163a577b3 Rename recently-played.js to recently-played.js.original
Match naming convention of other original JS files now replaced by parenscript
2025-12-06 18:31:33 +03:00
Glenn Thompson d771bb41f4 Add spectrum analyzer theming and visualization styles
- Add 6 color themes: green, blue, purple, red, amber, rainbow
- Add 3 visualization styles: bars, wave, dots
- Add UI controls (dropdowns) to change theme and style
- Persist user preferences in localStorage
- Remove debug logging from parenscript serving and Icecast stats
- Fix dots visualization with proper Math.PI handling
2025-12-06 18:29:53 +03:00
Glenn Thompson 931a9a90d4 feat: Convert JavaScript to Parenscript with stream fixes and UX improvements
Major Changes:
- Convert all JavaScript files to Parenscript for better maintainability
- Move spectrum-analyzer to parenscript/ directory structure
- Add parenscript-utils.lisp for shared utilities
- Convert admin.js, player.js, front-page.js, auth-ui.js to Parenscript
- Convert profile.js, users.js, recently-played.js to Parenscript

Stream Reconnect Fixes (from merged PR):
- Add reset-spectrum-analyzer function to properly clean up Web Audio API
- Implement reconnect logic for pauses longer than 10 seconds
- Detect stale audio in 'playing' event and force stream reconnection
- Prevent 'Now Playing' updates while stream is paused
- Reduce reconnect delay to 200ms for faster response
- Add proper spectrum analyzer reset/reinit during reconnection

UX Improvements:
- Change live indicator from blink to smooth pulse (2s ease-in-out)
- Pulse animation like old PowerBook/MacBook sleep indicator
- Add MUTED indicator to spectrum analyzer when audio is muted
- Spectrum continues to flow even when muted (data still streaming)
- Red 'MUTED' text displayed in top-right corner of canvas

Technical Details:
- Parenscript files generate JavaScript via API endpoints
- All player modes updated: main player, front page, popout, frame player
- Improved audio context handling to only create once per element
- Added comprehensive error handling and logging
- Updated asteroid.asd to include parenscript module structure

Documentation:
- Updated all documentation dates to 2025-12-06
- Added PARENSCRIPT-EXPERIMENT.org documenting the conversion
- Updated PROJECT-HISTORY.org with Phase 9 (Visual Audio Features)
- Added comprehensive project statistics (408 commits, 9,300 LOC)

This conversion improves code maintainability by using Lisp throughout
the stack and makes it easier to share code between frontend and backend.
2025-12-06 18:00:20 +03:00
Glenn Thompson 4ec90c0f27 perf: Reduce reconnect delay from 500ms to 200ms for faster response
Optimized the timeout after stream reconnection to make the pause/unpause
experience more responsive. The 200ms delay is sufficient for the browser
to clear buffers and start the fresh stream while minimizing perceived lag.
2025-12-06 08:36:30 -05:00
Glenn Thompson 7c7b2c921e fix: Prevent stale audio playback after long pause and reorganize spectrum analyzer
- Detect pauses longer than 10 seconds across all player modes
- Intercept 'playing' event to stop stale buffered audio
- Force stream reconnect to get live audio after long pause
- Reset and reinitialize spectrum analyzer during reconnect
- Prevent 'Now Playing' updates while stream is paused
- Move spectrum-analyzer.lisp to parenscript/ directory
- Update all documentation dates to 2025-12-06
- Add comprehensive project statistics (408 commits, 9,300 LOC)
- Add Phase 9 (Visual Audio Features) to project history

Fixes issue where resuming playback after a long pause would play
old buffered audio instead of the current live stream. The fix uses
the 'playing' event to detect when stale audio starts and immediately
stops it, then reconnects to get fresh stream data.

All player modes updated: main player, front page, popout, and frame player.
2025-12-06 08:36:30 -05:00
Glenn Thompson 6e8260172f fix: Reduce Icecast burst size and prevent now-playing updates during pause
- Reduced Icecast burst-size from 64KB to 8KB to minimize buffer accumulation
- Fixed spectrum analyzer to only create MediaElementSource once
- Added resetSpectrumAnalyzer() function to allow reconnection
- Prevent now-playing info updates when stream is paused across all players:
  * Player page
  * Front page
  * Pop-out player
  * Frame player
  * Admin page
- After pause >10s, reconnect stream and reinitialize spectrum analyzer
- Preserves spectrum analyzer functionality after pause/unpause
- Eliminates stuttering and buffer accumulation issues
2025-12-06 08:36:30 -05:00
11 changed files with 324 additions and 52 deletions

View File

@ -558,11 +558,9 @@
(cond
;; Serve ParenScript-compiled auth-ui.js
((string= path "js/auth-ui.js")
(format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-auth-ui-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 auth-ui.js: ~a~%" e)
@ -570,11 +568,9 @@
;; Serve ParenScript-compiled front-page.js
((string= path "js/front-page.js")
(format t "~%=== SERVING PARENSCRIPT front-page.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-front-page-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 front-page.js: ~a~%" e)
@ -582,11 +578,9 @@
;; 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)
@ -594,11 +588,9 @@
;; Serve ParenScript-compiled users.js
((string= path "js/users.js")
(format t "~%=== SERVING PARENSCRIPT users.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-users-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 users.js: ~a~%" e)
@ -606,11 +598,9 @@
;; Serve ParenScript-compiled admin.js
((string= path "js/admin.js")
(format t "~%=== SERVING PARENSCRIPT admin.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-admin-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 admin.js: ~a~%" e)
@ -618,11 +608,9 @@
;; Serve ParenScript-compiled player.js
((string= path "js/player.js")
(format t "~%=== SERVING PARENSCRIPT player.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-player-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 player.js: ~a~%" e)
@ -630,11 +618,9 @@
;; Serve ParenScript-compiled recently-played.js
((string= path "js/recently-played.js")
(format t "~%=== SERVING PARENSCRIPT recently-played.js ===~%")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-recently-played-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 recently-played.js: ~a~%" e)

View File

@ -10,7 +10,6 @@
(response (drakma:http-request icecast-url
:want-stream nil
:basic-authorization '("admin" "asteroid_admin_2024"))))
(format t "DEBUG: Fetching Icecast stats from ~a~%" icecast-url)
(when response
(let ((xml-string (if (stringp response)
response
@ -34,7 +33,6 @@
(aref groups 0)
"Unknown")))
"Unknown")))
(format t "DEBUG: Parsed title=~a, total-listeners=~a~%" title total-listeners)
;; Track recently played if title changed
(when (and title

View File

@ -367,6 +367,11 @@
;; Live stream info update
(defun update-live-stream-info ()
;; Don't update if stream is paused
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
(when (and live-audio (ps:@ live-audio paused))
(return)))
(ps:chain
(fetch "/api/asteroid/partial/now-playing-inline")
(then (lambda (response)

View File

@ -188,7 +188,56 @@
(ps:chain audio-element (load))
(ps:chain (ps:chain audio-element (play))
(catch (lambda (err)
(ps:chain console (log "Reload failed:" err))))))))))
(ps:chain console (log "Reload failed:" err))))))))
(let ((pause-timestamp nil)
(is-reconnecting false)
(needs-reconnect false)
(pause-reconnect-threshold 10000))
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(setf pause-timestamp (ps:chain |Date| (now)))
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
(ps:chain audio-element
(add-event-listener "play"
(lambda ()
(when (and (not is-reconnecting)
pause-timestamp
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
(setf needs-reconnect true)
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
(setf pause-timestamp nil))))
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(when (and needs-reconnect (not is-reconnecting))
(setf is-reconnecting true)
(setf needs-reconnect false)
(ps:chain console (log "Reconnecting stream after long pause to clear stale buffers..."))
(ps:chain audio-element (pause))
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
(ps:chain audio-element (load))
(set-timeout
(lambda ()
(ps:chain audio-element (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err)))))
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain window (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
(setf is-reconnecting false))
200))))))))
;; Check frameset preference
(let ((path (ps:@ window location pathname))

View File

@ -34,11 +34,62 @@
(update-player-display)
(update-volume)
;; Setup live stream with reduced buffering
;; Setup live stream with reduced buffering and reconnect logic
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
(when live-audio
;; Reduce buffer to minimize delay
(setf (ps:@ live-audio preload) "none")))
(setf (ps:@ live-audio preload) "none")
;; Add reconnect logic for long pauses
(let ((pause-timestamp nil)
(is-reconnecting false)
(needs-reconnect false)
(pause-reconnect-threshold 10000))
(ps:chain live-audio
(add-event-listener "pause"
(lambda ()
(setf pause-timestamp (ps:chain |Date| (now)))
(ps:chain console (log "Live stream paused at:" pause-timestamp)))))
(ps:chain live-audio
(add-event-listener "play"
(lambda ()
(when (and (not is-reconnecting)
pause-timestamp
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
(setf needs-reconnect true)
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
(setf pause-timestamp nil))))
(ps:chain live-audio
(add-event-listener "playing"
(lambda ()
(when (and needs-reconnect (not is-reconnecting))
(setf is-reconnecting true)
(setf needs-reconnect false)
(ps:chain console (log "Reconnecting live stream after long pause to clear stale buffers..."))
(ps:chain live-audio (pause))
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
(ps:chain live-audio (load))
(set-timeout
(lambda ()
(ps:chain live-audio (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err)))))
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain window (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
(setf is-reconnecting false))
200)))))
)))
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))

View File

@ -12,6 +12,30 @@
(defvar *canvas* nil)
(defvar *canvas-ctx* nil)
(defvar *animation-id* nil)
(defvar *media-source* nil)
(defvar *current-audio-element* nil)
(defvar *current-theme* "green")
(defvar *current-style* "bars")
;; Color themes for spectrum analyzer
(defvar *themes*
(ps:create
"green" (ps:create "top" "#00ff00" "mid" "#00aa00" "bottom" "#005500")
"blue" (ps:create "top" "#00ffff" "mid" "#0088ff" "bottom" "#0044aa")
"purple" (ps:create "top" "#ff00ff" "mid" "#aa00aa" "bottom" "#550055")
"red" (ps:create "top" "#ff0000" "mid" "#aa0000" "bottom" "#550000")
"amber" (ps:create "top" "#ffaa00" "mid" "#ff6600" "bottom" "#aa3300")
"rainbow" (ps:create "top" "#ff00ff" "mid" "#00ffff" "bottom" "#00ff00")))
(defun reset-spectrum-analyzer ()
"Reset the spectrum analyzer to allow reconnection after audio element reload"
(when *animation-id*
(cancel-animation-frame *animation-id*)
(setf *animation-id* nil))
(setf *audio-context* nil)
(setf *analyser* nil)
(setf *media-source* nil)
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
(defun init-spectrum-analyzer ()
"Initialize the spectrum analyzer"
@ -37,7 +61,12 @@
(:catch (e)
(ps:chain console (log "Cross-frame access error:" e)))))
(when (and audio-element canvas-element (not *audio-context*))
(when (and audio-element canvas-element)
;; Store current audio element
(setf *current-audio-element* audio-element)
;; Only create audio context and media source once
(when (not *audio-context*)
;; Create Audio Context
(setf *audio-context* (ps:new (or (ps:@ window |AudioContext|)
(ps:@ window |webkitAudioContext|))))
@ -47,17 +76,20 @@
(setf (ps:@ *analyser* |fftSize|) 256)
(setf (ps:@ *analyser* |smoothingTimeConstant|) 0.8)
;; Connect audio source to analyser
(let ((source (ps:chain *audio-context* (create-media-element-source audio-element))))
(ps:chain source (connect *analyser*))
(ps:chain *analyser* (connect (ps:@ *audio-context* destination))))
;; Connect audio source to analyser (can only be done once per element)
(setf *media-source* (ps:chain *audio-context* (create-media-element-source audio-element)))
(ps:chain *media-source* (connect *analyser*))
(ps:chain *analyser* (connect (ps:@ *audio-context* destination)))
(ps:chain console (log "Spectrum analyzer audio context created")))
;; Setup canvas
(setf *canvas* canvas-element)
(setf *canvas-ctx* (ps:chain *canvas* (get-context "2d")))
;; Start visualization
(draw-spectrum))))
;; Start visualization if not already running
(when (not *animation-id*)
(draw-spectrum)))))
(defun draw-spectrum ()
"Draw the spectrum analyzer visualization"
@ -69,7 +101,8 @@
(height (ps:@ *canvas* height))
(bar-width (/ width buffer-length))
(bar-height 0)
(x 0))
(x 0)
(is-muted (and *current-audio-element* (ps:@ *current-audio-element* muted))))
(ps:chain *analyser* (get-byte-frequency-data data-array))
@ -77,21 +110,68 @@
(setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(0, 0, 0, 0.2)")
(ps:chain *canvas-ctx* (fill-rect 0 0 width height))
;; Draw bars
;; Get current theme colors
(let ((theme (ps:getprop *themes* *current-theme*)))
(cond
;; Bar graph style
((= *current-style* "bars")
(setf x 0)
(dotimes (i buffer-length)
(setf bar-height (/ (* (aref data-array i) height) 256))
;; Create gradient for each bar
;; Create gradient for each bar using theme colors
(let ((gradient (ps:chain *canvas-ctx*
(create-linear-gradient 0 (- height bar-height) 0 height))))
(ps:chain gradient (add-color-stop 0 "#00ff00"))
(ps:chain gradient (add-color-stop 0.5 "#00aa00"))
(ps:chain gradient (add-color-stop 1 "#005500"))
(ps:chain gradient (add-color-stop 0 (ps:@ theme top)))
(ps:chain gradient (add-color-stop 0.5 (ps:@ theme mid)))
(ps:chain gradient (add-color-stop 1 (ps:@ theme bottom)))
(setf (ps:@ *canvas-ctx* |fillStyle|) gradient)
(ps:chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height))
(incf x bar-width)))))
(incf x bar-width))))
;; Wave/line style
((= *current-style* "wave")
(setf x 0)
(ps:chain *canvas-ctx* (begin-path))
(setf (ps:@ *canvas-ctx* |lineWidth|) 2)
(setf (ps:@ *canvas-ctx* |strokeStyle|) (ps:@ theme top))
(dotimes (i buffer-length)
(setf bar-height (/ (* (aref data-array i) height) 256))
(let ((y (- height bar-height)))
(if (= i 0)
(ps:chain *canvas-ctx* (move-to x y))
(ps:chain *canvas-ctx* (line-to x y)))
(incf x bar-width)))
(ps:chain *canvas-ctx* (stroke)))
;; Dots/particles style
((= *current-style* "dots")
(setf x 0)
(setf (ps:@ *canvas-ctx* |fillStyle|) (ps:@ theme top))
(dotimes (i buffer-length)
(let* ((value (aref data-array i))
(normalized-height (/ (* value height) 256))
(y (- height normalized-height))
(dot-radius (ps:max 2 (/ normalized-height 20))))
(when (> value 0)
(ps:chain *canvas-ctx* (begin-path))
(ps:chain *canvas-ctx* (arc x y dot-radius 0 6.283185307179586))
(ps:chain *canvas-ctx* (fill)))
(incf x bar-width))))))
;; Draw MUTED indicator if audio is muted
(when is-muted
(setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(255, 0, 0, 0.8)")
(setf (ps:@ *canvas-ctx* font) "bold 20px monospace")
(setf (ps:@ *canvas-ctx* |textAlign|) "right")
(setf (ps:@ *canvas-ctx* |textBaseline|) "top")
(ps:chain *canvas-ctx* (fill-text "MUTED" (- width 10) 10)))))
(defun stop-spectrum-analyzer ()
"Stop the spectrum analyzer"
@ -99,9 +179,47 @@
(cancel-animation-frame *animation-id*)
(setf *animation-id* nil)))
(defun set-spectrum-theme (theme-name)
"Change the spectrum analyzer color theme"
(when (ps:getprop *themes* theme-name)
(setf *current-theme* theme-name)
(ps:chain local-storage (set-item "spectrum-theme" theme-name))
(ps:chain console (log (+ "Spectrum theme changed to: " theme-name)))))
(defun get-available-themes ()
"Return array of available theme names"
(ps:chain |Object| (keys *themes*)))
(defun set-spectrum-style (style-name)
"Change the spectrum analyzer visualization style"
(when (or (= style-name "bars") (= style-name "wave") (= style-name "dots"))
(setf *current-style* style-name)
(ps:chain local-storage (set-item "spectrum-style" style-name))
(ps:chain console (log (+ "Spectrum style changed to: " style-name)))))
(defun get-available-styles ()
"Return array of available visualization styles"
(array "bars" "wave" "dots"))
;; Initialize when audio starts playing
(ps:chain document (add-event-listener "DOMContentLoaded"
(lambda ()
;; Load saved theme and style preferences
(let ((saved-theme (ps:chain local-storage (get-item "spectrum-theme")))
(saved-style (ps:chain local-storage (get-item "spectrum-style"))))
(when (and saved-theme (ps:getprop *themes* saved-theme))
(setf *current-theme* saved-theme))
(when (and saved-style (or (= saved-style "bars") (= saved-style "wave") (= saved-style "dots")))
(setf *current-style* saved-style))
;; Update UI selectors if they exist
(let ((theme-selector (ps:chain document (get-element-by-id "spectrum-theme-selector")))
(style-selector (ps:chain document (get-element-by-id "spectrum-style-selector"))))
(when theme-selector
(setf (ps:@ theme-selector value) *current-theme*))
(when style-selector
(setf (ps:@ style-selector value) *current-style*))))
(let ((audio-element (or (ps:chain document (get-element-by-id "live-audio"))
(ps:chain document (get-element-by-id "persistent-audio")))))

View File

@ -1161,10 +1161,12 @@
:opacity 1
:background-color #(color-accented-black)))
;; Live stream blink animation
;; Live stream pulse animation (like old MacBook sleep indicator)
(.live-stream-indicator
:animation "asteroid-blink 1s steps(5, start) infinite")
:animation "asteroid-pulse 2s ease-in-out infinite")
(:keyframes asteroid-blink
(to :visibility "hidden"))
(:keyframes asteroid-pulse
(0% :opacity 1)
(50% :opacity 0.3)
(100% :opacity 1))
) ;; End of let block

View File

@ -26,6 +26,27 @@
<!-- Spectrum Analyzer Canvas -->
<div style="text-align: center; margin: 15px 0;">
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
<div style="margin-top: 8px; font-size: 0.9em;">
<label style="margin-right: 10px;">
Style:
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="bars">Bars</option>
<option value="wave">Wave</option>
<option value="dots">Dots</option>
</select>
</label>
<label>
Theme:
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
<option value="amber">Amber</option>
<option value="rainbow">Rainbow</option>
</select>
</label>
</div>
</div>
<nav class="nav">

View File

@ -26,6 +26,27 @@
<!-- Spectrum Analyzer Canvas -->
<div style="text-align: center; margin: 15px 0;">
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
<div style="margin-top: 8px; font-size: 0.9em;">
<label style="margin-right: 10px;">
Style:
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="bars">Bars</option>
<option value="wave">Wave</option>
<option value="dots">Dots</option>
</select>
</label>
<label>
Theme:
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
<option value="amber">Amber</option>
<option value="rainbow">Rainbow</option>
</select>
</label>
</div>
</div>
<nav class="nav">

View File

@ -20,6 +20,27 @@
<!-- Spectrum Analyzer Canvas -->
<div style="text-align: center; margin: 15px 0;">
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
<div style="margin-top: 8px; font-size: 0.9em;">
<label style="margin-right: 10px;">
Style:
<select id="spectrum-style-selector" onchange="setSpectrumStyle(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="bars">Bars</option>
<option value="wave">Wave</option>
<option value="dots">Dots</option>
</select>
</label>
<label>
Theme:
<select id="spectrum-theme-selector" onchange="setSpectrumTheme(this.value)" style="padding: 3px; background: #000; color: #00ff00; border: 1px solid #00ff00;">
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
<option value="amber">Amber</option>
<option value="rainbow">Rainbow</option>
</select>
</label>
</div>
</div>
<div class="nav">