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:
parent
3ddd86f8ab
commit
e712009d79
|
|
@ -64,12 +64,14 @@
|
||||||
(:file "player")
|
(:file "player")
|
||||||
(:file "stream-player")
|
(:file "stream-player")
|
||||||
(:file "frameset-utils")
|
(:file "frameset-utils")
|
||||||
(:file "spectrum-analyzer")))
|
(:file "spectrum-analyzer")
|
||||||
|
(:file "dj-console")))
|
||||||
(:file "stream-media")
|
(:file "stream-media")
|
||||||
(:file "user-management")
|
(:file "user-management")
|
||||||
(:file "playlist-management")
|
(:file "playlist-management")
|
||||||
(:file "stream-control")
|
(:file "stream-control")
|
||||||
(:file "stream-harmony")
|
(:file "stream-harmony")
|
||||||
|
(:file "dj-session")
|
||||||
(:file "playlist-scheduler")
|
(:file "playlist-scheduler")
|
||||||
(:file "listener-stats")
|
(:file "listener-stats")
|
||||||
(:file "user-profile")
|
(:file "user-profile")
|
||||||
|
|
|
||||||
157
asteroid.lisp
157
asteroid.lisp
|
|
@ -558,6 +558,138 @@
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("message" . "Streaming pipeline restarted")))))
|
("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)
|
(defun get-track-by-id (track-id)
|
||||||
"Get a track by its ID - handles type mismatches"
|
"Get a track by its ID - handles type mismatches"
|
||||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||||
|
|
@ -909,6 +1041,16 @@
|
||||||
(format t "ERROR generating frameset-utils.js: ~a~%" e)
|
(format t "ERROR generating frameset-utils.js: ~a~%" e)
|
||||||
(format nil "// Error generating JavaScript: ~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
|
;; Serve regular static file
|
||||||
(t
|
(t
|
||||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||||
|
|
@ -943,6 +1085,21 @@
|
||||||
:stream-base-url *stream-base-url*
|
:stream-base-url *stream-base-url*
|
||||||
:default-stream-url (format nil "~a/asteroid.aac" *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)
|
;; User Management page (requires authentication)
|
||||||
(define-page users-management #@"/admin/user" ()
|
(define-page users-management #@"/admin/user" ()
|
||||||
"User Management dashboard"
|
"User Management dashboard"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@
|
||||||
#:pipeline-on-playlist-change
|
#:pipeline-on-playlist-change
|
||||||
;; Metadata helpers
|
;; Metadata helpers
|
||||||
#:read-audio-metadata
|
#: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)
|
(in-package #:cl-streamer/harmony)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)))))
|
||||||
|
|
@ -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*)
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -20,6 +20,13 @@
|
||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
</c:unless>
|
</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))'>
|
<c:unless test='(asteroid::member-string "admin" (** :navbar-exclude))'>
|
||||||
<a href="/asteroid/admin"
|
<a href="/asteroid/admin"
|
||||||
lquery='(attr :target (when framesetp "_self"))'>
|
lquery='(attr :target (when framesetp "_self"))'>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue