Implement DJ Console Phase 1 — dual-deck library mixing

New files:
- dj-session.lisp: DJ session state, deck management, crossfader
  (constant-power curve), auto-playlist pause/resume, ICY metadata
  auto-detect, library search, watchdog timer
- parenscript/dj-console.lisp: UI polling (500ms), deck controls,
  crossfader, library search with load-to-deck, session management
- template/dj-console.ctml: Dark hacker-themed dual-deck interface
  with progress bars, transport controls, crossfader slider, metadata
  override, and library search table

Modified files:
- asteroid.lisp: 14 DJ API endpoints (session, deck, crossfader,
  library search), define-page dj-console, dj-console.js serving
- asteroid.asd: Add dj-session and dj-console components
- cl-streamer/harmony-backend.lisp: Export update-all-mounts-metadata,
  volume-ramp, pipeline-harmony-server for DJ deck control
- navbar-admin.ctml: DJ Console link (role-gated to :dj/:admin)

API endpoints all require :dj role. Session lifecycle:
  GO LIVE -> pause auto-playlist -> mix -> END SESSION -> resume

External audio input stubbed for Phase 2.
This commit is contained in:
Glenn Thompson 2026-03-05 21:22:09 +03:00
parent 3ddd86f8ab
commit e712009d79
7 changed files with 1337 additions and 2 deletions

View File

@ -64,12 +64,14 @@
(:file "player")
(:file "stream-player")
(:file "frameset-utils")
(:file "spectrum-analyzer")))
(:file "spectrum-analyzer")
(:file "dj-console")))
(:file "stream-media")
(:file "user-management")
(:file "playlist-management")
(:file "stream-control")
(:file "stream-harmony")
(:file "dj-session")
(:file "playlist-scheduler")
(:file "listener-stats")
(:file "user-profile")

View File

@ -558,6 +558,138 @@
(api-output `(("status" . "success")
("message" . "Streaming pipeline restarted")))))
;;; ---- DJ Console API Endpoints ----
(define-api asteroid/dj/session/start () ()
"Start a new DJ session. Pauses the auto-playlist."
(require-role :dj)
(with-error-handling
(let* ((user (get-current-user))
(username (if user (dm:field user "username") "unknown")))
(start-dj-session username)
(api-output `(("status" . "success")
("message" . ,(format nil "DJ session started by ~A" username)))))))
(define-api asteroid/dj/session/end () ()
"End the current DJ session. Resumes auto-playlist."
(require-role :dj)
(with-error-handling
(end-dj-session)
(api-output `(("status" . "success")
("message" . "DJ session ended")))))
(define-api asteroid/dj/session/status () ()
"Get full DJ session status (polled by UI)."
(require-role :dj)
(with-error-handling
(let ((status (dj-session-status)))
(api-output (or status `(("active" . nil)))))))
(define-api asteroid/dj/session/metadata (text) ()
"Set custom ICY metadata text for the DJ session. Empty string clears override."
(require-role :dj)
(with-error-handling
(set-dj-metadata (if (or (null text) (string= text "")) nil text))
(api-output `(("status" . "success")
("message" . "Metadata updated")))))
(define-api asteroid/dj/deck/load (deck track-id) ()
"Load a track onto a deck by track ID."
(require-role :dj)
(with-error-handling
(let* ((deck-id (parse-deck-id deck))
(id (parse-integer track-id :junk-allowed t))
(track (when id (dm:get-one "tracks" (db:query (:= '_id id)))))
(file-path (when track (dm:field track "file-path"))))
(unless file-path
(error "Track not found: ~A" track-id))
(unless (probe-file file-path)
(error "Audio file not found on disk: ~A" file-path))
(let ((info (load-deck deck-id file-path)))
(api-output `(("status" . "success")
("deck" . ,deck)
("trackInfo" . (("artist" . ,(or (getf info :artist) ""))
("title" . ,(or (getf info :title) ""))
("album" . ,(or (getf info :album) ""))
("displayTitle" . ,(or (getf info :display-title) ""))))))))))
(define-api asteroid/dj/deck/load-path (deck path) ()
"Load a track onto a deck by file path (for loading from playlists)."
(require-role :dj)
(with-error-handling
(let ((deck-id (parse-deck-id deck)))
(unless (probe-file path)
(error "Audio file not found: ~A" path))
(let ((info (load-deck deck-id path)))
(api-output `(("status" . "success")
("deck" . ,deck)
("trackInfo" . (("artist" . ,(or (getf info :artist) ""))
("title" . ,(or (getf info :title) ""))
("album" . ,(or (getf info :album) ""))
("displayTitle" . ,(or (getf info :display-title) ""))))))))))
(define-api asteroid/dj/deck/play (deck) ()
"Start or resume playback on a deck."
(require-role :dj)
(with-error-handling
(play-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A playing" deck))))))
(define-api asteroid/dj/deck/pause (deck) ()
"Pause a playing deck."
(require-role :dj)
(with-error-handling
(pause-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A paused" deck))))))
(define-api asteroid/dj/deck/stop (deck) ()
"Stop and unload a deck."
(require-role :dj)
(with-error-handling
(stop-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A stopped" deck))))))
(define-api asteroid/dj/deck/seek (deck position) ()
"Seek to a position (seconds) on a deck."
(require-role :dj)
(with-error-handling
(let ((pos (float (read-from-string position))))
(seek-deck (parse-deck-id deck) pos)
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A seeked to ~,1Fs" deck pos)))))))
(define-api asteroid/dj/deck/volume (deck volume) ()
"Set per-deck volume (0.0-1.0)."
(require-role :dj)
(with-error-handling
(let ((vol (float (read-from-string volume))))
(set-deck-volume (parse-deck-id deck) vol)
(api-output `(("status" . "success")
("volume" . ,vol))))))
(define-api asteroid/dj/crossfader (position) ()
"Set crossfader position (0.0-1.0)."
(require-role :dj)
(with-error-handling
(let ((pos (float (read-from-string position))))
(set-crossfader pos)
(api-output `(("status" . "success")
("position" . ,pos))))))
(define-api asteroid/dj/library/search (q &optional (limit "50") (offset "0")) ()
"Search the music library for tracks to load onto a deck."
(require-role :dj)
(with-error-handling
(let ((results (search-library-tracks q
:limit (parse-integer limit :junk-allowed t)
:offset (parse-integer offset :junk-allowed t))))
(api-output `(("status" . "success")
("results" . ,results)
("count" . ,(length results)))))))
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
(dm:get-one "tracks" (db:query (:= '_id track-id))))
@ -909,6 +1041,16 @@
(format t "ERROR generating frameset-utils.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve ParenScript-compiled dj-console.js
((string= path "js/dj-console.js")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-dj-console-js)))
(if js js "// Error: No JavaScript generated"))
(error (e)
(format t "ERROR generating dj-console.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve regular static file
(t
(serve-file (merge-pathnames (format nil "static/~a" path)
@ -943,6 +1085,21 @@
:stream-base-url *stream-base-url*
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*))))
;; DJ Console page (requires DJ or admin role)
(define-page dj-console #@"/dj" ()
"DJ Console - Live mixing interface"
(require-role :dj)
(let* ((user (get-current-user))
(username (if user (dm:field user "username") "unknown")))
(clip:process-to-string
(load-template "dj-console")
:navbar-exclude '("dj")
:title "🎛️ ASTEROID RADIO - DJ Console"
:username username
:stream-base-url *stream-base-url*
:dj-active (if (dj-session-active-p) "true" "false")
:dj-owner (when *dj-session* (session-owner *dj-session*)))))
;; User Management page (requires authentication)
(define-page users-management #@"/admin/user" ()
"User Management dashboard"

View File

@ -23,7 +23,11 @@
#:pipeline-on-playlist-change
;; Metadata helpers
#:read-audio-metadata
#:format-display-title))
#:format-display-title
#:update-all-mounts-metadata
;; DJ support
#:pipeline-harmony-server
#:volume-ramp))
(in-package #:cl-streamer/harmony)

501
dj-session.lisp Normal file
View File

@ -0,0 +1,501 @@
;;;; dj-session.lisp - Live DJ mixing console for Asteroid Radio
;;;; Provides dual-deck library mixing with crossfader, per-deck volume,
;;;; and external audio input, all feeding into the existing Harmony mixer.
;;;; See docs/DJ-CONSOLE.org for the full design document.
(in-package :asteroid)
;;; ---- DJ Deck ----
(defclass dj-deck ()
((name :initarg :name :accessor deck-name
:documentation "Deck identifier: :a or :b")
(file-path :initform nil :accessor deck-file-path
:documentation "Path of loaded track, or NIL if empty")
(voice :initform nil :accessor deck-voice
:documentation "Harmony voice object, or NIL")
(volume :initform 1.0 :accessor deck-volume
:documentation "Per-deck volume 0.0-1.0 (before crossfader)")
(state :initform :empty :accessor deck-state
:documentation "One of :empty :loaded :playing :paused")
(track-info :initform nil :accessor deck-track-info
:documentation "Plist: (:artist :title :album :file :display-title)")))
(defmethod print-object ((deck dj-deck) stream)
(print-unreadable-object (deck stream :type t)
(format stream "~A ~A" (deck-name deck) (deck-state deck))))
;;; ---- DJ Session ----
(defclass dj-session ()
((owner :initarg :owner :accessor session-owner
:documentation "Username of the DJ who owns this session")
(started-at :initarg :started-at :accessor session-started-at
:initform (get-universal-time))
(deck-a :initform (make-instance 'dj-deck :name :a) :accessor session-deck-a)
(deck-b :initform (make-instance 'dj-deck :name :b) :accessor session-deck-b)
(crossfader :initform 0.5 :accessor session-crossfader
:documentation "0.0 = all A, 1.0 = all B")
(external-input :initform nil :accessor session-external-input
:documentation "External audio voice, or NIL")
(external-volume :initform 1.0 :accessor session-external-volume)
(metadata-override :initform nil :accessor session-metadata-override
:documentation "Custom ICY metadata text, or NIL for auto-detect")
(last-poll :initform (get-universal-time) :accessor session-last-poll
:documentation "Timestamp of last UI poll, for watchdog")
(saved-playlist-state :initform nil :accessor session-saved-playlist-state
:documentation "Saved auto-playlist state for resume on session end")))
(defvar *dj-session* nil
"The currently active DJ session, or NIL if no DJ is live.")
(defvar *dj-session-lock* (bt:make-lock "dj-session-lock")
"Lock for DJ session state changes.")
(defvar *dj-watchdog-interval* 60
"Seconds of no UI polling before a DJ session is auto-ended.")
;;; ---- Crossfader Math ----
(defun crossfader-volumes (position)
"Return (values vol-a vol-b) for crossfader position 0.0-1.0.
Uses constant-power (equal-power) curve so perceived volume
stays consistent across the sweep."
(let* ((pos (max 0.0 (min 1.0 (float position))))
(angle (* pos (/ pi 2.0))))
(values (cos angle) ;; Deck A: 1.0 at pos=0.0, 0.0 at pos=1.0
(sin angle)))) ;; Deck B: 0.0 at pos=0.0, 1.0 at pos=1.0
(defun effective-deck-volume (deck crossfader-component)
"Calculate the effective volume for a deck: deck-volume * crossfader-component."
(* (deck-volume deck) crossfader-component))
(defun apply-crossfader (session)
"Apply the current crossfader position to both deck voices."
(multiple-value-bind (vol-a vol-b)
(crossfader-volumes (session-crossfader session))
(let ((deck-a (session-deck-a session))
(deck-b (session-deck-b session)))
(when (and (deck-voice deck-a) (eq (deck-state deck-a) :playing))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck-a))
(float (effective-deck-volume deck-a vol-a))))
(when (and (deck-voice deck-b) (eq (deck-state deck-b) :playing))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck-b))
(float (effective-deck-volume deck-b vol-b)))))))
;;; ---- Auto-Playlist Pause / Resume ----
(defun pause-auto-playlist ()
"Pause the auto-playlist by skipping and clearing the queue.
The play-list thread will exit when it has no more tracks.
Returns saved state for restoration."
(when *harmony-pipeline*
(let ((state (list :playlist-path (when *current-playlist-path*
(namestring *current-playlist-path*))
:current-track (cl-streamer/harmony:pipeline-current-track
*harmony-pipeline*))))
;; Skip current track to stop playback, then clear queue
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*)
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
(log:info "Auto-playlist paused for DJ session")
state)))
(defun resume-auto-playlist (saved-state)
"Resume the auto-playlist from saved state after a DJ session ends."
(when saved-state
(let ((playlist-path (getf saved-state :playlist-path)))
(if playlist-path
(progn
(log:info "Resuming auto-playlist: ~A" (file-namestring playlist-path))
(harmony-load-playlist playlist-path))
;; No saved playlist - try loading the current scheduled one
(let ((scheduled (get-current-scheduled-playlist)))
(when scheduled
(let ((path (merge-pathnames scheduled (get-playlists-directory))))
(when (probe-file path)
(log:info "Resuming with scheduled playlist: ~A" scheduled)
(harmony-load-playlist path)))))))))
;;; ---- Session Lifecycle ----
(defun start-dj-session (username)
"Start a new DJ session. Pauses the auto-playlist and creates the session.
Returns the session, or signals an error if one is already active."
(bt:with-lock-held (*dj-session-lock*)
(when *dj-session*
(error "DJ session already active (owned by ~A)" (session-owner *dj-session*)))
(let ((saved-state (pause-auto-playlist)))
(setf *dj-session*
(make-instance 'dj-session
:owner username
:started-at (get-universal-time)))
(setf (session-saved-playlist-state *dj-session*) saved-state)
;; Update ICY metadata to show DJ is live
(update-dj-metadata)
(log:info "DJ session started by ~A" username)
*dj-session*)))
(defun end-dj-session (&key (fade-duration 3.0))
"End the current DJ session. Fades out active decks and resumes auto-playlist."
(bt:with-lock-held (*dj-session-lock*)
(unless *dj-session*
(return-from end-dj-session nil))
(let ((session *dj-session*)
(owner (session-owner *dj-session*)))
;; Fade out and stop both decks
(fade-and-stop-deck (session-deck-a session) fade-duration)
(fade-and-stop-deck (session-deck-b session) fade-duration)
;; Disconnect external input if connected
(when (session-external-input session)
(disconnect-external-input-internal session))
;; Resume auto-playlist
(resume-auto-playlist (session-saved-playlist-state session))
;; Clear session
(setf *dj-session* nil)
(log:info "DJ session ended (was owned by ~A)" owner)
t)))
(defun dj-session-active-p ()
"Return T if a DJ session is currently active."
(not (null *dj-session*)))
;;; ---- Deck Control ----
(defun get-deck (session deck-id)
"Return the deck object for DECK-ID (:a or :b)."
(ecase deck-id
(:a (session-deck-a session))
(:b (session-deck-b session))))
(defun parse-deck-id (deck-string)
"Parse a deck identifier string (\"a\" or \"b\") to a keyword."
(cond
((string-equal deck-string "a") :a)
((string-equal deck-string "b") :b)
(t (error "Invalid deck identifier: ~A (expected 'a' or 'b')" deck-string))))
(defun load-deck (deck-id file-path)
"Load a track onto a deck. Stops any currently playing track on that deck.
Reads metadata from the file and prepares the deck for playback."
(unless *dj-session*
(error "No active DJ session"))
(let* ((deck (get-deck *dj-session* deck-id))
(pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
;; Stop current track if playing
(when (member (deck-state deck) '(:playing :paused))
(stop-deck-internal deck))
;; Read metadata
(let* ((tags (cl-streamer/harmony:read-audio-metadata file-path))
(display-title (cl-streamer/harmony:format-display-title file-path))
(track-info (list :file file-path
:display-title display-title
:artist (getf tags :artist)
:title (getf tags :title)
:album (getf tags :album))))
(setf (deck-file-path deck) file-path
(deck-track-info deck) track-info
(deck-state deck) :loaded)
(log:info "Deck ~A loaded: ~A" deck-id display-title)
track-info)))
(defun play-deck (deck-id)
"Start or resume playback on a deck."
(unless *dj-session*
(error "No active DJ session"))
(let* ((session *dj-session*)
(deck (get-deck session deck-id))
(pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(ecase (deck-state deck)
(:empty
(error "Deck ~A has no track loaded" deck-id))
(:loaded
;; Create a new voice and start playing
(let ((voice (org.shirakumo.fraf.harmony:play
(sb-ext:parse-native-namestring (deck-file-path deck))
:mixer :music
:on-end :disconnect)))
(setf (deck-voice deck) voice
(deck-state deck) :playing)
;; Apply current crossfader volumes
(apply-crossfader session)
;; Update ICY metadata
(update-dj-metadata)
(log:info "Deck ~A playing: ~A" deck-id
(getf (deck-track-info deck) :display-title))))
(:paused
;; Resume - re-apply crossfader to restore volume
(when (deck-voice deck)
(setf (deck-state deck) :playing)
(apply-crossfader session)
(update-dj-metadata)
(log:info "Deck ~A resumed" deck-id)))
(:playing
;; Already playing, no-op
nil))))
(defun pause-deck (deck-id)
"Pause playback on a deck. Mutes the voice but keeps the position."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(when (and (eq (deck-state deck) :playing)
(deck-voice deck))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck)) 0.0)
(setf (deck-state deck) :paused)
(update-dj-metadata)
(log:info "Deck ~A paused" deck-id))))
(defun stop-deck (deck-id)
"Stop playback and unload the track from a deck."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(stop-deck-internal deck)
(update-dj-metadata)
(log:info "Deck ~A stopped" deck-id)))
(defun stop-deck-internal (deck)
"Internal: stop a deck's voice and reset state."
(when (deck-voice deck)
(let* ((pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(handler-case
(org.shirakumo.fraf.harmony:stop (deck-voice deck))
(error (e)
(log:debug "Error stopping deck voice: ~A" e)))))
(setf (deck-voice deck) nil
(deck-file-path deck) nil
(deck-track-info deck) nil
(deck-state deck) :empty))
(defun fade-and-stop-deck (deck duration)
"Fade a deck to silence and stop it. Runs in current thread."
(when (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(handler-case
(progn
(cl-streamer/harmony:volume-ramp (deck-voice deck) 0.0 duration)
(stop-deck-internal deck))
(error (e)
(log:debug "Error fading deck: ~A" e)
(stop-deck-internal deck)))))
(defun seek-deck (deck-id position-seconds)
"Seek to a position (in seconds) on a deck."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(when (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(let ((frame (round (* position-seconds
(org.shirakumo.fraf.mixed:samplerate (deck-voice deck))))))
(org.shirakumo.fraf.mixed:seek (deck-voice deck) frame)
(log:info "Deck ~A seeked to ~,1Fs" deck-id position-seconds)))))
;;; ---- Crossfader Control ----
(defun set-crossfader (position)
"Set the crossfader position (0.0-1.0) and update deck volumes."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-crossfader *dj-session*)
(max 0.0 (min 1.0 (float position))))
(apply-crossfader *dj-session*)
(update-dj-metadata))
(defun set-deck-volume (deck-id volume)
"Set the per-deck volume (0.0-1.0) and reapply crossfader."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(setf (deck-volume deck) (max 0.0 (min 1.0 (float volume))))
(apply-crossfader *dj-session*)))
;;; ---- External Audio Input ----
(defun connect-external-input (source-type &key device)
"Connect an external audio source to the mixer.
SOURCE-TYPE is :pulse, :alsa, or :jack.
DEVICE is the device name/path (source-specific).
Note: This is a Phase 2 feature."
(declare (ignore source-type device))
(unless *dj-session*
(error "No active DJ session"))
;; TODO: Phase 2 - implement local audio capture via cl-mixed
(log:warn "External audio input not yet implemented")
(error "External audio input is not yet available (Phase 2)"))
(defun disconnect-external-input ()
"Disconnect the external audio input."
(unless *dj-session*
(error "No active DJ session"))
(disconnect-external-input-internal *dj-session*))
(defun disconnect-external-input-internal (session)
"Internal: disconnect external input voice."
(when (session-external-input session)
(handler-case
(let* ((pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(org.shirakumo.fraf.harmony:stop (session-external-input session)))
(error (e)
(log:debug "Error stopping external input: ~A" e)))
(setf (session-external-input session) nil)
(log:info "External audio input disconnected")))
(defun set-external-volume (volume)
"Set the external input volume (0.0-1.0)."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-external-volume *dj-session*)
(max 0.0 (min 1.0 (float volume))))
(when (session-external-input *dj-session*)
(setf (org.shirakumo.fraf.mixed:volume (session-external-input *dj-session*))
(float (session-external-volume *dj-session*)))))
;;; ---- ICY Metadata ----
(defun set-dj-metadata (text)
"Set custom ICY metadata for the DJ session.
Pass NIL to return to auto-detect mode."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-metadata-override *dj-session*) text)
(update-dj-metadata))
(defun update-dj-metadata ()
"Update ICY metadata based on DJ session state.
Uses custom override if set, otherwise auto-detects from louder deck."
(when (and *dj-session* *harmony-pipeline*)
(let ((title (or (session-metadata-override *dj-session*)
(auto-detect-dj-metadata *dj-session*))))
(when title
(cl-streamer/harmony:update-all-mounts-metadata *harmony-pipeline* title)))))
(defun auto-detect-dj-metadata (session)
"Determine ICY metadata from the louder deck.
Returns a display string like 'DJ fade - Artist - Title'."
(let* ((crossfader (session-crossfader session))
(deck-a (session-deck-a session))
(deck-b (session-deck-b session))
(owner (session-owner session))
;; Pick the deck that's louder based on crossfader position
(active-deck (cond
((and (eq (deck-state deck-a) :playing)
(eq (deck-state deck-b) :playing))
(if (<= crossfader 0.5) deck-a deck-b))
((eq (deck-state deck-a) :playing) deck-a)
((eq (deck-state deck-b) :playing) deck-b)
(t nil)))
(track-title (when (and active-deck (deck-track-info active-deck))
(getf (deck-track-info active-deck) :display-title))))
(if track-title
(format nil "DJ ~A - ~A" owner track-title)
(format nil "DJ ~A - Live" owner))))
;;; ---- Deck Position / Status ----
(defun deck-position-info (deck)
"Return position info for a deck as a plist.
Includes :position (seconds), :duration (seconds), :remaining (seconds)."
(if (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(handler-case
(let* ((pos (org.shirakumo.fraf.mixed:frame-position (deck-voice deck)))
(total (org.shirakumo.fraf.mixed:frame-count (deck-voice deck)))
(sr (org.shirakumo.fraf.mixed:samplerate (deck-voice deck)))
(position-secs (when (and pos sr (> sr 0))
(float (/ pos sr))))
(duration-secs (when (and total sr (> sr 0))
(float (/ total sr))))
(remaining (when (and position-secs duration-secs)
(- duration-secs position-secs))))
(list :position (or position-secs 0.0)
:duration (or duration-secs 0.0)
:remaining (or remaining 0.0)))
(error ()
(list :position 0.0 :duration 0.0 :remaining 0.0)))
(list :position 0.0 :duration 0.0 :remaining 0.0)))
(defun deck-status-alist (deck)
"Return an alist representing a deck's full state for JSON serialization."
(let ((pos-info (deck-position-info deck)))
`(("name" . ,(string-downcase (symbol-name (deck-name deck))))
("state" . ,(string-downcase (symbol-name (deck-state deck))))
("volume" . ,(deck-volume deck))
("position" . ,(getf pos-info :position))
("duration" . ,(getf pos-info :duration))
("remaining" . ,(getf pos-info :remaining))
("trackInfo" . ,(when (deck-track-info deck)
`(("artist" . ,(or (getf (deck-track-info deck) :artist) ""))
("title" . ,(or (getf (deck-track-info deck) :title) ""))
("album" . ,(or (getf (deck-track-info deck) :album) ""))
("displayTitle" . ,(or (getf (deck-track-info deck) :display-title) ""))))))))
;;; ---- Session Status (for UI polling) ----
(defun dj-session-status ()
"Return the full DJ session status as an alist for JSON serialization.
Returns NIL if no session is active."
(when *dj-session*
;; Update last-poll timestamp for watchdog
(setf (session-last-poll *dj-session*) (get-universal-time))
`(("active" . t)
("owner" . ,(session-owner *dj-session*))
("startedAt" . ,(session-started-at *dj-session*))
("duration" . ,(- (get-universal-time) (session-started-at *dj-session*)))
("deckA" . ,(deck-status-alist (session-deck-a *dj-session*)))
("deckB" . ,(deck-status-alist (session-deck-b *dj-session*)))
("crossfader" . ,(session-crossfader *dj-session*))
("metadataOverride" . ,(session-metadata-override *dj-session*))
("externalInput" . ,(not (null (session-external-input *dj-session*))))
("externalVolume" . ,(session-external-volume *dj-session*)))))
;;; ---- Library Search ----
(defun search-library-tracks (query &key (limit 50) (offset 0))
"Search the track database for tracks matching QUERY.
Returns a list of alists with track info."
(handler-case
(with-db
(let ((results (postmodern:query
(:limit
(:offset
(:order-by
(:select '_id 'title 'artist 'album 'file-path
:from 'tracks
:where (:or (:ilike 'title (format nil "%~A%" query))
(:ilike 'artist (format nil "%~A%" query))
(:ilike 'album (format nil "%~A%" query))))
'artist 'title)
offset)
limit)
:rows)))
(mapcar (lambda (row)
`(("id" . ,(first row))
("title" . ,(or (second row) ""))
("artist" . ,(or (third row) ""))
("album" . ,(or (fourth row) ""))
("filePath" . ,(or (fifth row) ""))))
results)))
(error (e)
(log:warn "Library search error: ~A" e)
nil)))
;;; ---- Watchdog ----
(defun dj-watchdog-check ()
"Check if the DJ session has gone stale (no UI polling).
Called periodically by cl-cron. Auto-ends stale sessions."
(when *dj-session*
(let ((elapsed (- (get-universal-time) (session-last-poll *dj-session*))))
(when (> elapsed *dj-watchdog-interval*)
(log:warn "DJ session stale (~As since last poll), auto-ending"
elapsed)
(end-dj-session :fade-duration 5.0)))))

348
parenscript/dj-console.lisp Normal file
View File

@ -0,0 +1,348 @@
;;;; dj-console.lisp - ParenScript for DJ Console interface
;;;; Handles session management, deck control, crossfader, library search,
;;;; and status polling.
(in-package #:asteroid)
(defparameter *dj-console-js*
(ps:ps*
'(progn
;; ---- State ----
(defvar *poll-timer* nil)
(defvar *session-active* false)
(defvar *search-debounce* nil)
;; ---- Utility ----
(defun format-time (seconds)
"Format seconds as M:SS"
(let* ((secs (ps:chain -math (floor seconds)))
(m (ps:chain -math (floor (/ secs 60))))
(s (mod secs 60)))
(+ m ":" (if (< s 10) (+ "0" s) s))))
(defun api-post (url params callback)
"POST to an API endpoint with form params"
(let ((xhr (ps:new (-x-m-l-http-request))))
(ps:chain xhr (open "POST" url true))
(ps:chain xhr (set-request-header "Content-Type" "application/x-www-form-urlencoded"))
(setf (ps:@ xhr onload)
(lambda ()
(let ((data (ps:chain -j-s-o-n (parse (ps:@ xhr response-text)))))
(when callback (funcall callback data)))))
(setf (ps:@ xhr onerror)
(lambda () (show-message "Network error" "error")))
(ps:chain xhr (send params))))
(defun api-get (url callback)
"GET from an API endpoint"
(let ((xhr (ps:new (-x-m-l-http-request))))
(ps:chain xhr (open "GET" url true))
(setf (ps:@ xhr onload)
(lambda ()
(let ((data (ps:chain -j-s-o-n (parse (ps:@ xhr response-text)))))
(when callback (funcall callback data)))))
(setf (ps:@ xhr onerror)
(lambda () (show-message "Network error" "error")))
(ps:chain xhr (send))))
(defun show-message (text msg-type)
"Show a status message"
(let ((el (ps:chain document (get-element-by-id "dj-message"))))
(setf (ps:@ el inner-text) text)
(setf (ps:@ el class-name) (+ "dj-message " msg-type))
(setf (ps:@ el style display) "block")
(set-timeout (lambda ()
(setf (ps:@ el style display) "none"))
4000)))
(defun encode-params (obj)
"Encode an object as URL form params"
(let ((parts (array)))
(ps:for-in (key obj)
(ps:chain parts (push (+ (encode-u-r-i-component key)
"="
(encode-u-r-i-component (ps:getprop obj key))))))
(ps:chain parts (join "&"))))
;; ---- Session Control ----
(defun start-session ()
(api-post "/api/asteroid/dj/session/start" ""
(lambda (data)
(if (= (ps:@ data status) "success")
(progn
(show-message "Session started - you are LIVE!" "success")
(set-session-active true))
(show-message (or (ps:@ data message) "Failed to start session") "error")))))
(defun end-session ()
(when (ps:chain window (confirm "End your DJ session? Auto-playlist will resume."))
(api-post "/api/asteroid/dj/session/end" ""
(lambda (data)
(if (= (ps:@ data status) "success")
(progn
(show-message "Session ended - auto-playlist resuming" "success")
(set-session-active false))
(show-message (or (ps:@ data message) "Failed to end session") "error"))))))
(defun set-session-active (active)
(setf *session-active* active)
(let ((controls (ps:chain document (get-element-by-id "dj-controls")))
(btn-live (ps:chain document (get-element-by-id "btn-go-live")))
(btn-end (ps:chain document (get-element-by-id "btn-end-session")))
(info (ps:chain document (get-element-by-id "session-info"))))
(if active
(progn
(setf (ps:@ controls class-name) "")
(setf (ps:@ btn-live style display) "none")
(setf (ps:@ btn-end style display) "inline-block")
(setf (ps:@ info inner-h-t-m-l)
(+ "<span class='live-indicator'></span>LIVE"))
;; Start polling
(when *poll-timer* (clear-interval *poll-timer*))
(setf *poll-timer* (set-interval poll-status 500)))
(progn
(setf (ps:@ controls class-name) "no-session-overlay")
(setf (ps:@ btn-live style display) "inline-block")
(setf (ps:@ btn-end style display) "none")
(setf (ps:@ info inner-text) "")
;; Stop polling
(when *poll-timer*
(clear-interval *poll-timer*)
(setf *poll-timer* nil))
;; Reset UI
(reset-deck-ui "a")
(reset-deck-ui "b")))))
;; ---- Status Polling ----
(defun poll-status ()
(api-get "/api/asteroid/dj/session/status"
(lambda (data)
(when (ps:@ data active)
(update-deck-ui "a" (ps:@ data deck-a))
(update-deck-ui "b" (ps:@ data deck-b))
;; Update crossfader if not being dragged
(let ((slider (ps:chain document (get-element-by-id "crossfader"))))
(unless (= (ps:@ document active-element) slider)
(setf (ps:@ slider value)
(ps:chain -math (round (* (ps:@ data crossfader) 100))))))))))
(defun update-deck-ui (deck-name deck-data)
"Update a deck's UI from status data"
(let ((state (ps:@ deck-data state))
(track (ps:@ deck-data track-info))
(position (ps:@ deck-data position))
(duration (ps:@ deck-data duration)))
;; State label
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-state")))
inner-text)
(ps:chain state (to-upper-case)))
;; Track info
(let ((info-el (ps:chain document (get-element-by-id (+ "deck-" deck-name "-info")))))
(if track
(setf (ps:@ info-el inner-h-t-m-l)
(+ "<div class='deck-artist'>"
(or (ps:@ track artist) "") "</div>"
"<div class='deck-title'>"
(or (ps:@ track title) "") "</div>"
"<div class='deck-album'>"
(or (ps:@ track album) "") "</div>"))
(setf (ps:@ info-el inner-h-t-m-l)
"<div class='deck-empty'>No track loaded</div>")))
;; Progress bar
(let ((pct (if (and duration (> duration 0))
(* (/ position duration) 100)
0)))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-progress")))
style width)
(+ pct "%")))
;; Time display
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-position")))
inner-text)
(format-time (or position 0)))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-duration")))
inner-text)
(format-time (or duration 0)))
;; Button states
(let ((can-play (or (= state "loaded") (= state "paused")))
(can-pause (= state "playing"))
(can-stop (or (= state "playing") (= state "paused") (= state "loaded"))))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-play")))
disabled)
(not can-play))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-pause")))
disabled)
(not can-pause))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-stop")))
disabled)
(not can-stop)))
;; Active indicator
(let ((container (ps:chain document (get-element-by-id (+ "deck-" deck-name "-container")))))
(if (= state "playing")
(ps:chain (ps:@ container class-list) (add "active"))
(ps:chain (ps:@ container class-list) (remove "active"))))))
(defun reset-deck-ui (deck-name)
"Reset a deck's UI to empty state"
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-state")))
inner-text) "EMPTY")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-info")))
inner-h-t-m-l) "<div class='deck-empty'>No track loaded</div>")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-progress")))
style width) "0%")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-position")))
inner-text) "0:00")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-duration")))
inner-text) "0:00")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-play")))
disabled) true)
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-pause")))
disabled) true)
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-stop")))
disabled) true))
;; ---- Deck Control ----
(defun play-deck (deck)
(api-post "/api/asteroid/dj/deck/play"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Play failed") "error")))))
(defun pause-deck (deck)
(api-post "/api/asteroid/dj/deck/pause"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Pause failed") "error")))))
(defun stop-deck (deck)
(api-post "/api/asteroid/dj/deck/stop"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Stop failed") "error")))))
(defun seek-deck (deck event)
"Seek on a deck by clicking the progress bar"
(let* ((bar (ps:@ event current-target))
(rect (ps:chain bar (get-bounding-client-rect)))
(x (- (ps:@ event client-x) (ps:@ rect left)))
(pct (/ x (ps:@ rect width)))
;; Get duration from the UI display (rough but works)
(duration-el (ps:chain document (get-element-by-id (+ "deck-" deck "-duration"))))
(parts (ps:chain (ps:@ duration-el inner-text) (split ":")))
(dur (+ (* (parse-int (aref parts 0) 10) 60) (parse-int (aref parts 1) 10)))
(seek-pos (* pct dur)))
(api-post "/api/asteroid/dj/deck/seek"
(encode-params (ps:create :deck deck :position seek-pos))
nil)))
;; ---- Crossfader ----
(defun set-crossfader (position)
(api-post "/api/asteroid/dj/crossfader"
(encode-params (ps:create :position position))
nil))
;; ---- Volume ----
(defun set-deck-volume (deck volume)
(api-post "/api/asteroid/dj/deck/volume"
(encode-params (ps:create :deck deck :volume volume))
nil))
;; ---- Metadata ----
(defun set-metadata ()
(let ((text (ps:@ (ps:chain document (get-element-by-id "metadata-input")) value)))
(api-post "/api/asteroid/dj/session/metadata"
(encode-params (ps:create :text text))
(lambda (data)
(when (= (ps:@ data status) "success")
(show-message "Metadata updated" "success"))))))
(defun clear-metadata ()
(setf (ps:@ (ps:chain document (get-element-by-id "metadata-input")) value) "")
(api-post "/api/asteroid/dj/session/metadata"
(encode-params (ps:create :text ""))
(lambda (data)
(when (= (ps:@ data status) "success")
(show-message "Metadata set to auto-detect" "success")))))
;; ---- Library Search ----
(defun search-library ()
(let ((query (ps:@ (ps:chain document (get-element-by-id "library-query")) value)))
(when (> (ps:@ query length) 0)
(api-get (+ "/api/asteroid/dj/library/search?q=" (encode-u-r-i-component query))
render-library-results))))
(defun render-library-results (data)
(let ((container (ps:chain document (get-element-by-id "library-results")))
(results (ps:@ data results)))
(if (and results (> (ps:@ results length) 0))
(let ((html "<table><tr><th>Artist</th><th>Title</th><th>Album</th><th>Load</th></tr>"))
(ps:chain results
(for-each
(lambda (track)
(setf html (+ html
"<tr>"
"<td>" (or (ps:@ track artist) "") "</td>"
"<td>" (or (ps:@ track title) "") "</td>"
"<td>" (or (ps:@ track album) "") "</td>"
"<td>"
"<button class='load-btn' onclick='loadTrack(\"a\", "
(ps:@ track id) ")'>A</button>"
"<button class='load-btn' onclick='loadTrack(\"b\", "
(ps:@ track id) ")'>B</button>"
"</td>"
"</tr>")))))
(setf html (+ html "</table>"))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l)
"<p style='color: #666; text-align: center;'>No results found</p>"))))
(defun load-track (deck track-id)
(api-post "/api/asteroid/dj/deck/load"
(encode-params (ps:create :deck deck :track-id track-id))
(lambda (data)
(if (= (ps:@ data status) "success")
(show-message (+ "Loaded onto Deck "
(ps:chain deck (to-upper-case)) ": "
(ps:@ data track-info display-title))
"success")
(show-message (or (ps:@ data message) "Load failed") "error")))))
;; ---- Expose functions to window ----
(setf (ps:@ window start-session) start-session)
(setf (ps:@ window end-session) end-session)
(setf (ps:@ window play-deck) play-deck)
(setf (ps:@ window pause-deck) pause-deck)
(setf (ps:@ window stop-deck) stop-deck)
(setf (ps:@ window seek-deck) seek-deck)
(setf (ps:@ window set-crossfader) set-crossfader)
(setf (ps:@ window set-deck-volume) set-deck-volume)
(setf (ps:@ window set-metadata) set-metadata)
(setf (ps:@ window clear-metadata) clear-metadata)
(setf (ps:@ window search-library) search-library)
(setf (ps:@ window load-track) load-track)
;; ---- Init ----
(defun init-dj-console ()
"Initialize the DJ console - check if a session is already active"
;; Check server-rendered state first, then poll for live status
(let ((active-val (ps:@ (ps:chain document (get-element-by-id "dj-active")) value)))
(if (= active-val "true")
(set-session-active true)
;; Also poll in case state changed between page render and load
(api-get "/api/asteroid/dj/session/status"
(lambda (data)
(when (ps:@ data active)
(set-session-active true)))))))
;; Run on page load
(ps:chain window (add-event-listener "load" init-dj-console))
))
"Compiled JavaScript for DJ console - generated at load time")
(defun generate-dj-console-js ()
"Return the pre-compiled JavaScript for DJ console"
*dj-console-js*)

316
template/dj-console.ctml Normal file
View File

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title data-text="title">Asteroid Radio - DJ Console</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script lquery='(text (asteroid::get-auth-state-js-var))'></script>
<style>
/* DJ Console Styles — layered on top of asteroid.css */
.dj-console { max-width: 1200px; margin: 0 auto; }
.dj-header {
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid #00ffff33; padding-bottom: 10px; margin-bottom: 20px;
}
.dj-header h1 { margin: 0; font-size: 1.8rem; }
.dj-session-info { color: #4488FF; font-size: 1.1rem; }
.session-controls { display: flex; gap: 10px; align-items: center; }
.btn-go-live {
background: #00aa44; color: #000; border: none; padding: 8px 20px;
font-family: VT323, monospace; font-size: 1.2rem; cursor: pointer;
text-transform: uppercase; letter-spacing: 2px;
}
.btn-go-live:hover { background: #00ff66; }
.btn-go-live:disabled { background: #333; color: #666; cursor: not-allowed; }
.btn-end-session {
background: #cc2200; color: #fff; border: none; padding: 8px 20px;
font-family: VT323, monospace; font-size: 1.2rem; cursor: pointer;
text-transform: uppercase; letter-spacing: 2px;
}
.btn-end-session:hover { background: #ff3300; }
.live-indicator {
display: inline-block; width: 12px; height: 12px; border-radius: 50%;
background: #ff0000; margin-right: 8px;
animation: pulse-live 1s ease-in-out infinite;
}
@keyframes pulse-live {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #ff0000; }
50% { opacity: 0.4; box-shadow: 0 0 12px #ff0000; }
}
/* Decks */
.decks-container {
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
margin-bottom: 20px;
}
.deck {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; position: relative;
}
.deck.active { border-color: #00ffff66; }
.deck-label {
font-size: 1.4rem; color: #00ffff; margin-bottom: 10px;
display: flex; justify-content: space-between; align-items: center;
}
.deck-state { font-size: 0.9rem; color: #4488FF; text-transform: uppercase; }
.deck-track-info {
min-height: 60px; margin-bottom: 10px;
border-left: 3px solid #00ffff33; padding-left: 10px;
}
.deck-artist { color: #00ffff; font-size: 1.2rem; }
.deck-title { color: #aaa; font-size: 1.1rem; }
.deck-album { color: #666; font-size: 0.9rem; }
.deck-empty { color: #444; font-style: italic; }
.deck-transport {
display: flex; gap: 8px; margin-bottom: 10px;
}
.deck-transport button {
background: #1a2332; color: #00ffff; border: 1px solid #00ffff44;
padding: 6px 14px; font-family: VT323, monospace; font-size: 1.1rem;
cursor: pointer;
}
.deck-transport button:hover { background: #00ffff22; }
.deck-transport button:disabled { color: #444; border-color: #333; cursor: not-allowed; }
.deck-progress {
width: 100%; height: 8px; background: #1a2332; margin-bottom: 5px;
cursor: pointer; position: relative;
}
.deck-progress-fill {
height: 100%; background: #00ffff; width: 0%; transition: width 0.2s linear;
}
.deck-time {
display: flex; justify-content: space-between; color: #666;
font-size: 0.9rem; margin-bottom: 10px;
}
.deck-volume {
display: flex; align-items: center; gap: 10px;
}
.deck-volume label { color: #666; font-size: 0.9rem; min-width: 30px; }
.deck-volume input[type="range"] {
-webkit-appearance: none; appearance: none; flex: 1;
height: 4px; background: #1a2332; outline: none;
}
.deck-volume input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; background: #00ffff; cursor: pointer;
}
/* Crossfader */
.crossfader-section {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; margin-bottom: 20px; text-align: center;
}
.crossfader-label {
display: flex; justify-content: space-between; color: #666;
margin-bottom: 5px; font-size: 0.9rem;
}
.crossfader-input {
-webkit-appearance: none; appearance: none; width: 100%;
height: 6px; background: #1a2332; outline: none;
}
.crossfader-input::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 20px; height: 20px; background: #4488FF; cursor: pointer;
}
/* Metadata Override */
.metadata-section {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; margin-bottom: 20px;
display: flex; gap: 10px; align-items: center;
}
.metadata-section label { color: #666; white-space: nowrap; }
.metadata-section input[type="text"] {
flex: 1; background: #1a2332; border: 1px solid #00ffff33;
color: #00ffff; padding: 6px 10px; font-family: VT323, monospace;
font-size: 1rem;
}
.metadata-section button {
background: #1a2332; color: #4488FF; border: 1px solid #4488FF44;
padding: 6px 14px; font-family: VT323, monospace; font-size: 1rem;
cursor: pointer;
}
.metadata-section button:hover { background: #4488FF22; }
/* Library Search */
.library-section {
background: #0d1117; border: 1px solid #00ffff22; padding: 15px;
}
.library-section h2 { margin-top: 0; font-size: 1.3rem; color: #00ffff; }
.library-search {
display: flex; gap: 10px; margin-bottom: 15px;
}
.library-search input[type="text"] {
flex: 1; background: #1a2332; border: 1px solid #00ffff33;
color: #00ffff; padding: 8px 12px; font-family: VT323, monospace;
font-size: 1.1rem;
}
.library-search button {
background: #1a2332; color: #00ffff; border: 1px solid #00ffff44;
padding: 8px 16px; font-family: VT323, monospace; font-size: 1.1rem;
cursor: pointer;
}
.library-search button:hover { background: #00ffff22; }
.library-results { max-height: 400px; overflow-y: auto; }
.library-results table { width: 100%; border-collapse: collapse; }
.library-results th {
text-align: left; color: #666; border-bottom: 1px solid #00ffff22;
padding: 5px 8px; font-size: 0.9rem;
}
.library-results td {
padding: 5px 8px; border-bottom: 1px solid #0d1117;
font-size: 1rem; color: #aaa;
}
.library-results tr:hover td { background: #1a233244; }
.library-results .load-btn {
background: none; border: 1px solid #00ffff44; color: #00ffff;
padding: 2px 8px; font-family: VT323, monospace; font-size: 0.9rem;
cursor: pointer; margin: 0 2px;
}
.library-results .load-btn:hover { background: #00ffff22; }
.no-session-overlay {
position: relative; pointer-events: auto;
}
.no-session-overlay .decks-container,
.no-session-overlay .crossfader-section,
.no-session-overlay .metadata-section,
.no-session-overlay .library-section {
opacity: 0.3; pointer-events: none;
}
.dj-message {
padding: 10px; margin-bottom: 15px; border: 1px solid;
display: none;
}
.dj-message.error { border-color: #cc2200; color: #ff4444; }
.dj-message.success { border-color: #00aa44; color: #00ff66; }
</style>
</head>
<body>
<div class="container dj-console">
<div class="dj-header">
<h1>🎛️ DJ CONSOLE</h1>
<div class="session-controls">
<span id="session-info" class="dj-session-info"></span>
<button id="btn-go-live" class="btn-go-live" onclick="startSession()">GO LIVE</button>
<button id="btn-end-session" class="btn-end-session" onclick="endSession()" style="display:none">END SESSION</button>
</div>
</div>
<c:h>(asteroid::load-template "partial/navbar-admin")</c:h>
<div id="dj-message" class="dj-message"></div>
<div id="dj-controls" class="no-session-overlay">
<!-- Decks -->
<div class="decks-container">
<!-- Deck A -->
<div class="deck" id="deck-a-container">
<div class="deck-label">
<span>◉ DECK A</span>
<span class="deck-state" id="deck-a-state">EMPTY</span>
</div>
<div class="deck-track-info" id="deck-a-info">
<div class="deck-empty">No track loaded</div>
</div>
<div class="deck-transport">
<button onclick="playDeck('a')" id="deck-a-play" disabled>▶ PLAY</button>
<button onclick="pauseDeck('a')" id="deck-a-pause" disabled>⏸ PAUSE</button>
<button onclick="stopDeck('a')" id="deck-a-stop" disabled>■ STOP</button>
</div>
<div class="deck-progress" id="deck-a-progress-bar" onclick="seekDeck('a', event)">
<div class="deck-progress-fill" id="deck-a-progress"></div>
</div>
<div class="deck-time">
<span id="deck-a-position">0:00</span>
<span id="deck-a-duration">0:00</span>
</div>
<div class="deck-volume">
<label>VOL</label>
<input type="range" min="0" max="100" value="100"
oninput="setDeckVolume('a', this.value / 100)"
id="deck-a-volume">
</div>
</div>
<!-- Deck B -->
<div class="deck" id="deck-b-container">
<div class="deck-label">
<span>◎ DECK B</span>
<span class="deck-state" id="deck-b-state">EMPTY</span>
</div>
<div class="deck-track-info" id="deck-b-info">
<div class="deck-empty">No track loaded</div>
</div>
<div class="deck-transport">
<button onclick="playDeck('b')" id="deck-b-play" disabled>▶ PLAY</button>
<button onclick="pauseDeck('b')" id="deck-b-pause" disabled>⏸ PAUSE</button>
<button onclick="stopDeck('b')" id="deck-b-stop" disabled>■ STOP</button>
</div>
<div class="deck-progress" id="deck-b-progress-bar" onclick="seekDeck('b', event)">
<div class="deck-progress-fill" id="deck-b-progress"></div>
</div>
<div class="deck-time">
<span id="deck-b-position">0:00</span>
<span id="deck-b-duration">0:00</span>
</div>
<div class="deck-volume">
<label>VOL</label>
<input type="range" min="0" max="100" value="100"
oninput="setDeckVolume('b', this.value / 100)"
id="deck-b-volume">
</div>
</div>
</div>
<!-- Crossfader -->
<div class="crossfader-section">
<div class="crossfader-label">
<span>◉ DECK A</span>
<span>CROSSFADER</span>
<span>DECK B ◎</span>
</div>
<input type="range" class="crossfader-input" id="crossfader"
min="0" max="100" value="50"
oninput="setCrossfader(this.value / 100)">
</div>
<!-- Metadata Override -->
<div class="metadata-section">
<label>ICY METADATA:</label>
<input type="text" id="metadata-input" placeholder="Auto-detect from active deck">
<button onclick="setMetadata()">SET</button>
<button onclick="clearMetadata()">AUTO</button>
</div>
<!-- Library Search -->
<div class="library-section">
<h2>📚 LIBRARY</h2>
<div class="library-search">
<input type="text" id="library-query" placeholder="Search artist, title, album..."
onkeyup="if(event.key==='Enter')searchLibrary()">
<button onclick="searchLibrary()">SEARCH</button>
</div>
<div class="library-results" id="library-results">
<p style="color: #444; text-align: center;">Search the library to load tracks onto a deck</p>
</div>
</div>
</div>
</div>
<input type="hidden" id="dj-active" lquery='(val (** :dj-active))'>
<input type="hidden" id="dj-owner" lquery='(val (** :dj-owner))'>
<input type="hidden" id="dj-username" lquery='(val (** :username))'>
<script src="/asteroid/static/js/dj-console.js"></script>
</body>
</html>

View File

@ -20,6 +20,13 @@
Profile
</a>
</c:unless>
<c:when test='(and (not (asteroid::member-string "dj" (** :navbar-exclude)))
(asteroid::user-has-role-p current-user :dj))'>
<a href="/asteroid/dj"
lquery='(attr :target (when framesetp "_self"))'>
🎛️ DJ
</a>
</c:when>
<c:unless test='(asteroid::member-string "admin" (** :navbar-exclude))'>
<a href="/asteroid/admin"
lquery='(attr :target (when framesetp "_self"))'>