mirror of
https://github.com/heyarne/airsonic-ui.git
synced 2026-05-07 02:33:39 +02:00
Merge branch 'master' of github.com:heyarne/airsonic-ui
This commit is contained in:
commit
534bb9c5b5
29 changed files with 1773 additions and 869 deletions
|
|
@ -6,8 +6,6 @@
|
|||
[airsonic-ui.audio.playlist :as playlist]
|
||||
[goog.functions :refer [throttle]]))
|
||||
|
||||
;; TODO: Manage buffering
|
||||
|
||||
(defonce audio (atom nil))
|
||||
|
||||
(defn normalize-time-ranges [time-ranges]
|
||||
|
|
@ -28,7 +26,6 @@
|
|||
|
||||
; explanation of these events: https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Cross-browser_audio_basics
|
||||
|
||||
|
||||
(defn attach-listeners! [el]
|
||||
(let [emit-audio-update (throttle #(rf/dispatch [:audio/update (->status el)]) 16)]
|
||||
(doseq [event ["loadstart" "progress" "play" "timeupdate" "pause" "volumechange"]]
|
||||
|
|
@ -56,7 +53,8 @@
|
|||
(fn [_]
|
||||
(when-let [audio @audio]
|
||||
(.pause audio)
|
||||
(set! (.-currentTime audio) 0))))
|
||||
(set! (.-currentTime audio) 0)
|
||||
(set! (.-src audio) ""))))
|
||||
|
||||
(rf/reg-fx
|
||||
:audio/toggle-play-pause
|
||||
|
|
@ -102,25 +100,26 @@
|
|||
|
||||
(rf/reg-sub :audio/summary summary)
|
||||
|
||||
(defn playlist
|
||||
"Lists the complete playlist"
|
||||
(defn current-playlist
|
||||
"Lists the complete current-queue"
|
||||
[summary _]
|
||||
(:playlist summary))
|
||||
(:current-playlist summary))
|
||||
|
||||
(rf/reg-sub
|
||||
:audio/playlist
|
||||
:audio/current-playlist
|
||||
:<- [:audio/summary]
|
||||
playlist)
|
||||
current-playlist)
|
||||
|
||||
(defn current-song
|
||||
"Gives us information about the currently played song as presented by
|
||||
the airsonic api"
|
||||
[playlist _]
|
||||
(playlist/peek playlist))
|
||||
(when-not (empty? playlist)
|
||||
(playlist/current-song playlist)))
|
||||
|
||||
(rf/reg-sub
|
||||
:audio/current-song
|
||||
:<- [:audio/playlist]
|
||||
:<- [:audio/current-playlist]
|
||||
current-song)
|
||||
|
||||
(defn playback-status
|
||||
|
|
|
|||
|
|
@ -1,139 +1,196 @@
|
|||
(ns airsonic-ui.audio.playlist
|
||||
"Implements playlist queues that support different kinds of repetition and
|
||||
song ordering."
|
||||
(:refer-clojure :exclude [peek])
|
||||
(:require [airsonic-ui.helpers :refer [find-where]]))
|
||||
song ordering.")
|
||||
|
||||
(defrecord Playlist [queue playback-mode repeat-mode]
|
||||
;; Turns out we can nicely implement this by thinly wrapping a sequence of items
|
||||
;; We re-use the core ClojureScript protocols internally but provide a nice and
|
||||
;; explicit API to consume
|
||||
|
||||
(defprotocol IPlaylist
|
||||
(current-song [this])
|
||||
(next-song [this])
|
||||
(previous-song [this])
|
||||
|
||||
(set-current-song [this song-idx]
|
||||
"Advances the queue to the song given by song-idx")
|
||||
(set-playback-mode [this playback-mode]
|
||||
"Changes the playback mode of a playlist and re-shuffles it if necessary")
|
||||
(set-repeat-mode [this repeat-mode]
|
||||
"Allows you to change how the next and previous song are selected")
|
||||
|
||||
(enqueue-last
|
||||
[this song source]
|
||||
[this song]
|
||||
"Registers a song to be played last, optionally remembering the source route")
|
||||
(enqueue-next
|
||||
[this song source]
|
||||
[this song]
|
||||
"Registers a song to be played next, optionally remembering the source route")
|
||||
|
||||
(move-song [this from-idx to-idx]
|
||||
"Allows you to move a song in a playlist")
|
||||
(remove-song [this song-idx]
|
||||
"Removes a song from the playlist"))
|
||||
|
||||
;; helpers to manage creating playlists
|
||||
|
||||
(defn- mark-original-order
|
||||
"This function is used if we switch from linear to shuffled; it allows us to
|
||||
restore the order of the queue when it was created."
|
||||
[items]
|
||||
(->> (sort-by (comp :playlist/linear-order meta) items)
|
||||
(map-indexed (fn [idx item]
|
||||
(vary-meta item assoc :playlist/linear-order idx)))))
|
||||
|
||||
(defn- linear-queue
|
||||
[items]
|
||||
(->> (mark-original-order items)
|
||||
(map-indexed vector)
|
||||
(into (sorted-map))))
|
||||
|
||||
(defn- shuffled-queue
|
||||
[items]
|
||||
(let [shuffled-indices (shuffle (range (count items)))]
|
||||
(->> (mark-original-order items)
|
||||
(map vector shuffled-indices)
|
||||
(into (sorted-map)))))
|
||||
|
||||
;; the exported interface:
|
||||
|
||||
(defrecord Playlist [items current-idx playback-mode repeat-mode]
|
||||
cljs.core/ICounted
|
||||
(-count [this]
|
||||
(count (:queue this))))
|
||||
(-count [_]
|
||||
(count items))
|
||||
|
||||
cljs.core/ISequential
|
||||
cljs.core/ISeqable
|
||||
(-seq [_] items)
|
||||
|
||||
IPlaylist
|
||||
(current-song [_]
|
||||
(get items current-idx))
|
||||
|
||||
(next-song [this]
|
||||
(update this :current-idx
|
||||
(fn [current-idx]
|
||||
(cond
|
||||
(= repeat-mode :repeat-single) current-idx
|
||||
|
||||
(or (= repeat-mode :repeat-all)
|
||||
(< current-idx (dec (count this))))
|
||||
(mod (inc current-idx) (count this))))))
|
||||
|
||||
(previous-song [this]
|
||||
(update this :current-idx
|
||||
(fn [current-idx]
|
||||
(cond
|
||||
(= repeat-mode :repeat-single) current-idx
|
||||
|
||||
(or (= repeat-mode :repeat-all)
|
||||
(> current-idx 0))
|
||||
(mod (dec current-idx) (count this))
|
||||
|
||||
:else nil))))
|
||||
|
||||
(set-current-song [playlist song-idx]
|
||||
(assoc playlist :current-idx song-idx))
|
||||
|
||||
(set-playback-mode [playlist playback-mode]
|
||||
(let [current-song (current-song playlist)
|
||||
queue-fn (case playback-mode
|
||||
:shuffled shuffled-queue
|
||||
:linear linear-queue)
|
||||
next-playlist (-> (assoc playlist :playback-mode playback-mode)
|
||||
(update :items (comp queue-fn vals)))
|
||||
next-idx (first (keep (fn [[idx song]]
|
||||
(when (= song current-song)
|
||||
idx))
|
||||
(:items next-playlist)))]
|
||||
;; we have to find out the index of the currently playing song after the
|
||||
;; playlist was created because it might change when shuffling / unshuffling
|
||||
(set-current-song next-playlist next-idx)))
|
||||
|
||||
(set-repeat-mode [playlist repeat-mode]
|
||||
(assoc playlist :repeat-mode repeat-mode))
|
||||
|
||||
(enqueue-last [this song source]
|
||||
(let [order (inc (key (last items)))]
|
||||
;; Arguably this is a bit weird; but if you want to play something last in
|
||||
;; a shuffled playlist, you want to play it last I guess.
|
||||
(assoc-in this [:items order]
|
||||
(vary-meta song assoc
|
||||
:playlist/linear-order order
|
||||
:playlist/source source))))
|
||||
(enqueue-last [this song] (enqueue-last this song nil))
|
||||
|
||||
(enqueue-next [this song source]
|
||||
;; we slice the songs up until the currently playing one and increase the
|
||||
;; order for all the songs after
|
||||
(let [songs (vec (vals items))
|
||||
reordered (-> (subvec songs 0 (inc current-idx))
|
||||
(conj (vary-meta song assoc
|
||||
:playlist/linear-order (inc current-idx)
|
||||
:playlist/source source))
|
||||
(concat (subvec songs (inc current-idx))))]
|
||||
(assoc this :items (->> (map-indexed vector reordered)
|
||||
(into (sorted-map))))))
|
||||
(enqueue-next [this song] (enqueue-next this song nil))
|
||||
|
||||
(move-song [this from-idx to-idx]
|
||||
;; we have to decide whether we move all items in-between
|
||||
;; one up or one down; this depends on whether we move our
|
||||
;; item to the front or to the back
|
||||
(let [shift-fn (cond
|
||||
(< from-idx to-idx) inc
|
||||
(> from-idx to-idx) dec)
|
||||
start (min from-idx to-idx)
|
||||
end (inc (max from-idx to-idx))
|
||||
steps (range start end)
|
||||
result (update this :items
|
||||
(fn [items]
|
||||
(-> (reduce (fn [result idx]
|
||||
(assoc result idx (get items (shift-fn idx))))
|
||||
items steps)
|
||||
(assoc to-idx (get items from-idx)))))]
|
||||
(cond
|
||||
(= from-idx current-idx) (assoc result :current-idx to-idx)
|
||||
(<= to-idx current-idx from-idx) (update result :current-idx inc)
|
||||
(>= to-idx current-idx from-idx) (update result :current-idx dec)
|
||||
:else result)))
|
||||
|
||||
(remove-song [this song-idx]
|
||||
(cond-> (update this :items #(let [n-items (count %)]
|
||||
(-> (reduce (fn [items idx]
|
||||
(assoc items idx (get items (inc idx))))
|
||||
% (range song-idx n-items))
|
||||
(dissoc (dec n-items)))))
|
||||
(= song-idx current-idx) (assoc :current-idx -1))))
|
||||
|
||||
;; constructor wrapper
|
||||
|
||||
(defn set-item-source
|
||||
"Can be used to attach a source route to an item"
|
||||
[item source]
|
||||
(vary-meta item assoc :playlist/source source))
|
||||
|
||||
(defn item-source
|
||||
"Retrieve the source of an item in the playlist"
|
||||
[item]
|
||||
(:playlist/source (meta item)))
|
||||
|
||||
(defmulti ->playlist
|
||||
"Creates a new playlist that behaves according to the given playback- and
|
||||
repeat-mode parameters."
|
||||
(fn [queue & {:keys [playback-mode #_repeat-mode]}]
|
||||
playback-mode))
|
||||
|
||||
(defn- mark-first-song [queue]
|
||||
(let [[first-idx _] (find-where #(= 0 (:playlist/order %)) queue)]
|
||||
(assoc-in queue [first-idx :playlist/currently-playing?] true)))
|
||||
(fn [_ & {:keys [playback-mode]}] playback-mode))
|
||||
|
||||
(defmethod ->playlist :linear
|
||||
[queue & {:keys [playback-mode repeat-mode]}]
|
||||
(let [queue (-> (mapv (fn [order song] (assoc song :playlist/order order)) (range) queue)
|
||||
(mark-first-song))]
|
||||
(->Playlist queue playback-mode repeat-mode)))
|
||||
|
||||
(defn- -shuffle-songs [queue]
|
||||
(->> (shuffle (range (count queue)))
|
||||
(mapv (fn [song order] (assoc song :playlist/order order)) queue)))
|
||||
[items & {:keys [playback-mode repeat-mode source]}]
|
||||
(->Playlist (->> (map #(set-item-source % source) items)
|
||||
(linear-queue))
|
||||
0 playback-mode repeat-mode))
|
||||
|
||||
(defmethod ->playlist :shuffled
|
||||
[queue & {:keys [playback-mode repeat-mode]}]
|
||||
(let [queue (conj (mapv #(update % :playlist/order inc) (-shuffle-songs (rest queue)))
|
||||
(assoc (first queue) :playlist/order 0 :playlist/currently-playing? true))]
|
||||
(->Playlist queue playback-mode repeat-mode)))
|
||||
|
||||
(defn set-current-song
|
||||
"Marks a song in the queue as currently playing, given its ID"
|
||||
[playlist next-idx]
|
||||
(let [[current-idx _] (find-where :playlist/currently-playing? (:queue playlist))]
|
||||
(-> (if current-idx
|
||||
(update-in playlist [:queue current-idx] dissoc :playlist/currently-playing?)
|
||||
playlist)
|
||||
(assoc-in [:queue next-idx :playlist/currently-playing?] true))))
|
||||
|
||||
(defn set-playback-mode
|
||||
"Changes the playback mode of a playlist and re-shuffles it if necessary"
|
||||
[playlist playback-mode]
|
||||
(if (= playback-mode :shuffled)
|
||||
;; for shuffled playlists we reorder the songs make sure that the currently
|
||||
;; playing song has order 0
|
||||
(let [playlist (->playlist (:queue playlist) :playback-mode playback-mode :repeat-mode (:repeat-mode playlist))
|
||||
[current-idx current-song] (find-where :playlist/currently-playing? (:queue playlist))
|
||||
[swap-idx _] (find-where #(= 0 (:playlist/order %)) (:queue playlist))]
|
||||
(-> (assoc-in playlist [:queue current-idx :playlist/order] 0)
|
||||
(assoc-in [:queue swap-idx :playlist/order] (:playlist/order current-song))))
|
||||
;; for linear songs we just make sure that the current does not change
|
||||
(let [[current-idx _] (find-where :playlist/currently-playing? (:queue playlist))]
|
||||
(-> (->playlist (:queue playlist) :playback-mode playback-mode :repeat-mode (:repeat-mode playlist))
|
||||
(set-current-song current-idx)))))
|
||||
|
||||
(defn set-repeat-mode
|
||||
"Allows to change the way the next and previous song of a playlist is selected"
|
||||
[playlist repeat-mode]
|
||||
(assoc playlist :repeat-mode repeat-mode))
|
||||
|
||||
(defn peek
|
||||
"Returns the song in a playlist that is currently playing"
|
||||
[playlist]
|
||||
(->> (:queue playlist)
|
||||
(filter :playlist/currently-playing?)
|
||||
(first)))
|
||||
|
||||
(defmulti next-song "Advances the currently playing song" :repeat-mode)
|
||||
|
||||
(defmethod next-song :repeat-none
|
||||
[playlist]
|
||||
;; this is pretty easy; get the next song and stop playing at the at
|
||||
(let [[current-idx current-song] (find-where :playlist/currently-playing? (:queue playlist))
|
||||
[next-idx _] (find-where #(= (:playlist/order %) (inc (:playlist/order current-song))) (:queue playlist))]
|
||||
(update playlist :queue
|
||||
(fn [queue]
|
||||
(cond-> queue
|
||||
current-idx (update current-idx dissoc :playlist/currently-playing?)
|
||||
next-idx (assoc-in [next-idx :playlist/currently-playing?] true))))))
|
||||
|
||||
(defmethod next-song :repeat-single [playlist] playlist)
|
||||
|
||||
(defmethod next-song :repeat-all
|
||||
[playlist]
|
||||
(let [[current-idx current-song] (find-where :playlist/currently-playing? (:queue playlist))
|
||||
[next-idx _] (find-where #(= (:playlist/order %) (inc (:playlist/order current-song))) (:queue playlist))]
|
||||
(-> (update-in playlist [:queue current-idx] dissoc :playlist/currently-playing?)
|
||||
(update :queue
|
||||
(fn [queue]
|
||||
;; we need special treatment here if we're playing the last song and
|
||||
;; have a shuffled playlist because we need to re-shuffle
|
||||
(if next-idx
|
||||
(assoc-in queue [next-idx :playlist/currently-playing?] true)
|
||||
(case (:playback-mode playlist)
|
||||
:linear (assoc-in queue [0 :playlist/currently-playing?] true)
|
||||
:shuffled (let [queue' (-shuffle-songs queue)
|
||||
[next-idx _] (find-where #(= (:playlist/order %) 0) queue')]
|
||||
(assoc-in queue' [next-idx :playlist/currently-playing?] true)))))))))
|
||||
|
||||
(defmulti previous-song "Goes back along the playback queue" :repeat-mode)
|
||||
|
||||
(defmethod previous-song :repeat-single [playlist] playlist)
|
||||
|
||||
(defmethod previous-song :repeat-none [playlist]
|
||||
(let [[current-idx current-song] (find-where :playlist/currently-playing? (:queue playlist))
|
||||
[next-idx _] (find-where #(= (:playlist/order %) (dec (:playlist/order current-song))) (:queue playlist))]
|
||||
(set-current-song playlist (or next-idx current-idx))))
|
||||
|
||||
(defmethod previous-song :repeat-all [playlist]
|
||||
(let [[_ current-song] (find-where :playlist/currently-playing? (:queue playlist))
|
||||
[next-idx _] (find-where #(= (:playlist/order %)
|
||||
(rem (dec (:playlist/order current-song)) (count playlist)))
|
||||
(:queue playlist))]
|
||||
(if next-idx
|
||||
(set-current-song playlist next-idx)
|
||||
(if (= :shuffled (:playback-mode playlist))
|
||||
(let [highest-order (dec (count playlist))
|
||||
playlist (update playlist :queue -shuffle-songs)
|
||||
[last-idx _] (find-where #(= (:playlist/order %) highest-order) (:queue playlist))]
|
||||
(set-current-song playlist last-idx))
|
||||
(set-current-song playlist (mod (dec (:playlist/order current-song)) (count playlist)))))))
|
||||
|
||||
(defn enqueue-last [playlist song]
|
||||
(let [highest-order (last (sort (map :playlist/order (:queue playlist))))]
|
||||
(update playlist :queue conj (assoc song :playlist/order (inc highest-order)))))
|
||||
|
||||
(defn enqueue-next [playlist song]
|
||||
(let [[_ current-song] (find-where :playlist/currently-playing? (:queue playlist))]
|
||||
(update playlist :queue
|
||||
(fn [queue]
|
||||
(-> (mapv #(if (> (:playlist/order %) (:playlist/order current-song)) (update % :playlist/order inc) %) queue)
|
||||
(conj (assoc song :playlist/order (inc (:playlist/order current-song)))))))))
|
||||
[items & {:keys [playback-mode repeat-mode source]}]
|
||||
(->Playlist (->> (map #(set-item-source % source) items)
|
||||
(shuffled-queue))
|
||||
0 playback-mode repeat-mode))
|
||||
|
|
|
|||
|
|
@ -3,56 +3,85 @@
|
|||
[airsonic-ui.audio.playlist :as playlist]
|
||||
[airsonic-ui.api.helpers :as api]))
|
||||
|
||||
; sets up the db, starts to play a song and adds the rest to a playlist
|
||||
(defn play-all-songs [{:keys [db]
|
||||
:routes/keys [current-route]} [_ songs start-idx]]
|
||||
(let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all :source current-route)
|
||||
(playlist/set-current-song start-idx))]
|
||||
{:audio/play (api/stream-url (:credentials db) (playlist/current-song playlist))
|
||||
:db (assoc-in db [:audio :current-playlist] playlist)}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
; sets up the db, starts to play a song and adds the rest to a playlist
|
||||
:audio-player/play-all
|
||||
(fn [{:keys [db]} [_ songs start-idx]]
|
||||
(let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all)
|
||||
(playlist/set-current-song start-idx))]
|
||||
{:audio/play (api/stream-url (:credentials db) (playlist/peek playlist))
|
||||
:db (assoc-in db [:audio :playlist] playlist)})))
|
||||
[(rf/inject-cofx :routes/current-route)]
|
||||
play-all-songs)
|
||||
|
||||
(rf/reg-event-db
|
||||
:audio-player/set-playback-mode
|
||||
(fn [db [_ playback-mode]]
|
||||
(update-in db [:audio :playlist] #(playlist/set-playback-mode % playback-mode))))
|
||||
(update-in db [:audio :current-playlist] #(playlist/set-playback-mode % playback-mode))))
|
||||
|
||||
(rf/reg-event-db
|
||||
:audio-player/set-repeat-mode
|
||||
(fn [db [_ repeat-mode]]
|
||||
(update-in db [:audio :playlist] #(playlist/set-repeat-mode % repeat-mode))))
|
||||
(update-in db [:audio :current-playlist] #(playlist/set-repeat-mode % repeat-mode))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
:audio-player/next-song
|
||||
(fn [{:keys [db]} _]
|
||||
(let [db (update-in db [:audio :playlist] playlist/next-song)
|
||||
next (playlist/peek (get-in db [:audio :playlist]))]
|
||||
(let [db (update-in db [:audio :current-playlist] playlist/next-song)
|
||||
next (playlist/current-song (get-in db [:audio :current-playlist]))]
|
||||
{:db db
|
||||
:audio/play (api/stream-url (:credentials db) next)})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
:audio-player/previous-song
|
||||
(fn [{:keys [db]} _]
|
||||
(let [db (update-in db [:audio :playlist] playlist/previous-song)
|
||||
prev (playlist/peek (get-in db [:audio :playlist]))]
|
||||
(let [db (update-in db [:audio :current-playlist] playlist/previous-song)
|
||||
song (playlist/current-song (get-in db [:audio :current-playlist]))]
|
||||
{:db db
|
||||
:audio/play (api/stream-url (:credentials db) prev)})))
|
||||
:audio/play (api/stream-url (:credentials db) song)})))
|
||||
|
||||
(rf/reg-event-db
|
||||
(defn set-current-song [{:keys [db]} [_ idx]]
|
||||
(let [db (update-in db [:audio :current-playlist] playlist/set-current-song idx)
|
||||
song (playlist/current-song (get-in db [:audio :current-playlist]))]
|
||||
{:db db
|
||||
:audio/play (api/stream-url (:credentials db) song)}))
|
||||
|
||||
(rf/reg-event-fx :audio-player/set-current-song set-current-song)
|
||||
|
||||
(rf/reg-event-fx
|
||||
:audio-player/enqueue-next
|
||||
(fn [db [_ song]]
|
||||
(update-in db [:audio :playlist] #(playlist/enqueue-next % song))))
|
||||
[(rf/inject-cofx :routes/current-route)]
|
||||
(fn [{:keys [db]
|
||||
:routes/keys [current-route]} [_ song]]
|
||||
{:db (update-in db [:audio :current-playlist] #(playlist/enqueue-next % song current-route))}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
:audio-player/enqueue-last
|
||||
[(rf/inject-cofx :routes/current-route)]
|
||||
(fn [{:keys [db]
|
||||
:routes/keys [current-route]} [_ song]]
|
||||
{:db (update-in db [:audio :current-playlist] #(playlist/enqueue-last % song current-route))}))
|
||||
|
||||
(rf/reg-event-db
|
||||
:audio-player/enqueue-last
|
||||
(fn [db [_ song]]
|
||||
(update-in db [:audio :playlist] #(playlist/enqueue-last % song))))
|
||||
:audio-player/move-song
|
||||
(fn [db [_ from-idx to-idx]]
|
||||
(update-in db [:audio :current-playlist] #(playlist/move-song % from-idx to-idx))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
:audio-player/toggle-play-pause
|
||||
(fn [_ _]
|
||||
{:audio/toggle-play-pause nil}))
|
||||
|
||||
(defn remove-song [{:keys [db]} [_ song-idx]]
|
||||
(let [song-removed (update-in db [:audio :current-playlist] #(playlist/remove-song % song-idx))]
|
||||
(cond-> {:db song-removed}
|
||||
(nil? (playlist/current-song (get-in song-removed [:audio :current-playlist])))
|
||||
(assoc :audio/stop nil))))
|
||||
|
||||
(rf/reg-event-fx :audio-player/remove-song remove-song)
|
||||
|
||||
(defn audio-update
|
||||
"Reacts to audio events fired by the HTML5 audio player and plays the next
|
||||
track if necessary."
|
||||
|
|
@ -65,7 +94,7 @@
|
|||
(rf/reg-event-fx
|
||||
:audio-player/seek
|
||||
(fn [{:keys [db]} [_ percentage]]
|
||||
(let [duration (:duration (playlist/peek (get-in db [:audio :playlist])))]
|
||||
(let [duration (:duration (playlist/current-song (get-in db [:audio :current-playlist])))]
|
||||
{:audio/seek [percentage duration]})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
[airsonic-ui.routes :as routes]
|
||||
[airsonic-ui.helpers :as h]
|
||||
[airsonic-ui.views.cover :refer [cover]]
|
||||
[airsonic-ui.views.icon :refer [icon]]))
|
||||
[bulma.icon :refer [icon]]))
|
||||
|
||||
;; currently playing / coming next / audio controls...
|
||||
|
||||
|
|
@ -121,9 +121,8 @@
|
|||
{:on-click toggle-volume-slider}
|
||||
[icon volume-icon]]]))
|
||||
|
||||
(defn playback-mode-controls [playlist]
|
||||
(let [{:keys [repeat-mode playback-mode]} playlist
|
||||
button :p.control>button.button.is-light
|
||||
(defn playback-mode-controls [{:keys [repeat-mode playback-mode]}]
|
||||
(let [button :p.control>button.button.is-light
|
||||
shuffle-button (h/add-classes button (when (= playback-mode :shuffled) :is-primary))
|
||||
repeat-button (h/add-classes button (case repeat-mode
|
||||
:repeat-single :is-info
|
||||
|
|
@ -142,7 +141,7 @@
|
|||
|
||||
(defn audio-player []
|
||||
(let [current-song @(subscribe [:audio/current-song])
|
||||
playlist @(subscribe [:audio/playlist])
|
||||
current-playlist @(subscribe [:audio/current-playlist])
|
||||
playback-status @(subscribe [:audio/playback-status])
|
||||
is-playing? @(subscribe [:audio/is-playing?])]
|
||||
[:nav.audio-player
|
||||
|
|
@ -153,6 +152,6 @@
|
|||
[progress-indicators current-song playback-status]
|
||||
[playback-controls is-playing?]
|
||||
[volume-controls playback-status]
|
||||
[playback-mode-controls playlist]]
|
||||
[playback-mode-controls current-playlist]]
|
||||
;; not playing anything
|
||||
[:p.navbar-item.idle-notification "No audio playing"])]))
|
||||
|
|
|
|||
|
|
@ -1,31 +1,36 @@
|
|||
(ns airsonic-ui.components.collection.views
|
||||
"A collection is a list of audio files that belong together (e.g. an album or
|
||||
a podcast's overview)"
|
||||
(:require [airsonic-ui.helpers :refer [format-duration]]
|
||||
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||
[airsonic-ui.views.cover :refer [cover card]]
|
||||
[airsonic-ui.views.icon :refer [icon]]
|
||||
[airsonic-ui.views.song :as song]))
|
||||
(:require [re-frame.core :refer [subscribe]]
|
||||
[bulma.icon :refer [icon]]
|
||||
[bulma.dropdown.views :refer [dropdown]]
|
||||
[airsonic-ui.helpers :as h]
|
||||
[airsonic-ui.routes :as routes]
|
||||
[airsonic-ui.views.cover :refer [cover card]]))
|
||||
|
||||
(defn collection-info [{:keys [songCount duration year]}]
|
||||
(vec (cond-> [:ul.is-smaller.collection-info
|
||||
[:li [icon :audio-spectrum] (str songCount (if (= 1 songCount)
|
||||
" track" " tracks"))]
|
||||
[:li [icon :clock] (format-duration duration)]]
|
||||
[:li [icon :clock] (h/format-duration duration)]]
|
||||
year (conj [:li [icon :calendar] (str "Released in " year)]))))
|
||||
|
||||
(defn album-card [album]
|
||||
(let [{:keys [artist artistId name id]} album]
|
||||
[card album
|
||||
:url-fn #(url-for ::routes/album.detail {:id id})
|
||||
:content [:div
|
||||
;; link to album
|
||||
[:div.title.is-5
|
||||
[:a {:href (url-for ::routes/album.detail {:id id})
|
||||
:title name} name]]
|
||||
;; link to artist page
|
||||
[:div.subtitle.is-6 [:a {:href (url-for ::routes/artist.detail {:id artistId})
|
||||
:title artist} artist]]]]))
|
||||
;; TODO: Maybe this view belongs somewhere else?
|
||||
;; Something like a collection-grid component?
|
||||
|
||||
(defn album-card
|
||||
"A single element in a grid of albums. Shows the cover, artist and album name."
|
||||
[{:keys [artist artistId name id] :as album}]
|
||||
[card album
|
||||
:url-fn #(routes/url-for ::routes/album.detail {:id id})
|
||||
:content [:div
|
||||
;; link to album
|
||||
[:div.title.is-5
|
||||
[:a {:href (routes/url-for ::routes/album.detail {:id id})
|
||||
:title name} name]]
|
||||
;; link to artist page
|
||||
[:div.subtitle.is-6 [:a {:href (routes/url-for ::routes/artist.detail {:id artistId})
|
||||
:title artist} artist]]]])
|
||||
|
||||
(defn listing [albums]
|
||||
;; always show 5 in a row
|
||||
|
|
@ -34,8 +39,54 @@
|
|||
^{:key idx} [:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
|
||||
[album-card album]])])
|
||||
|
||||
;; TODO: Avoid duplication
|
||||
(defn artist-link [{id :artistId, artist :artist}]
|
||||
(if id
|
||||
[:a {:href (routes/url-for ::routes/artist.detail {:id id})} artist]
|
||||
artist))
|
||||
|
||||
(defn song-link [{:keys [songs song idx]}]
|
||||
[:a
|
||||
{:href "#" :on-click (h/muted-dispatch [:audio-player/play-all songs idx] :sync? true)}
|
||||
(:title song)])
|
||||
|
||||
(defn song-actions [song]
|
||||
[dropdown {:items [{:label "Play next" :event [:audio-player/enqueue-next song]}
|
||||
{:label "Play last" :event [:audio-player/enqueue-last song]}]}])
|
||||
|
||||
(defn default-thead []
|
||||
[:thead>tr
|
||||
[:td.is-narrow]
|
||||
[:td.song-artist "Artist"]
|
||||
[:td.song-title "Title"]
|
||||
[:td.song-duration "Duration"]
|
||||
[:td.is-narrow]])
|
||||
|
||||
(defn default-tbody [{:keys [songs current-song]}]
|
||||
[:tbody
|
||||
(for [[idx song] (map-indexed vector songs)]
|
||||
^{:key idx}
|
||||
[(if (= (:id song) (:id current-song)) :tr.is-playing :tr)
|
||||
[:td.song-tracknr.is-narrow (:track song)]
|
||||
[:td.song-artist [artist-link song]]
|
||||
[:td.song-title [song-link {:songs songs
|
||||
:song song
|
||||
:idx idx}]]
|
||||
[:td.song-duration (h/format-duration (:duration song) :brief? true)]
|
||||
[:td.song-actions.is-narrow [song-actions song]]])])
|
||||
|
||||
(defn song-table [{:keys [songs thead tbody]
|
||||
:or {thead default-thead, tbody default-tbody}}]
|
||||
;; we subscribe here instead of one level higher up to make this a more
|
||||
;; reusable component; this way we can for example get a list of all songs
|
||||
;; in a search result and easily highlight the currently playing track
|
||||
(let [current-song @(subscribe [:audio/current-song])]
|
||||
[:table.song-listing-table.table.is-fullwidth
|
||||
[thead]
|
||||
[tbody {:songs songs, :current-song current-song}]]))
|
||||
|
||||
(defn detail
|
||||
"Lists all songs in an album"
|
||||
"Shows a detail view of a single album, listing all "
|
||||
[{:keys [album]}]
|
||||
[:div
|
||||
[:section.hero.is-small>div.hero-body
|
||||
|
|
@ -46,4 +97,5 @@
|
|||
[:h2.title (:name album)]
|
||||
[:h3.subtitle (:artist album)]
|
||||
[collection-info album]]]]]
|
||||
[:section.section>div.container [song/listing (:song album)]]])
|
||||
[:section.section>div.container
|
||||
[song-table {:songs (:song album)}]]])
|
||||
|
|
|
|||
15
src/cljs/airsonic_ui/components/current_queue/subs.cljs
Normal file
15
src/cljs/airsonic_ui/components/current_queue/subs.cljs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
(ns airsonic-ui.components.current-queue.subs
|
||||
(:require [re-frame.core :as rf]))
|
||||
|
||||
(defn queue-info [playlist]
|
||||
{:count (count playlist)
|
||||
:duration
|
||||
(reduce (fn [acc [_ item]]
|
||||
(+ acc (:duration item))) 0 (:items playlist))})
|
||||
|
||||
(println "registering the sub")
|
||||
|
||||
(rf/reg-sub
|
||||
:current-queue/info
|
||||
:<- [:audio/current-playlist]
|
||||
queue-info)
|
||||
|
|
@ -1,12 +1,107 @@
|
|||
(ns airsonic-ui.components.current-queue.views
|
||||
(:require [re-frame.core :refer [subscribe]]
|
||||
[airsonic-ui.views.song :as song]
|
||||
[airsonic-ui.routes :as r]))
|
||||
(:require [re-frame.core :refer [subscribe dispatch-sync]]
|
||||
[reagent.core :as r]
|
||||
["react-sortable-hoc" :refer [SortableHandle]]
|
||||
[bulma.icon :refer [icon]]
|
||||
[bulma.dropdown.views :refer [dropdown]]
|
||||
[airsonic-ui.helpers :as helpers]
|
||||
[airsonic-ui.audio.playlist :as playlist]
|
||||
[airsonic-ui.components.collection.views :as collection]
|
||||
[airsonic-ui.components.sortable.views :as sortable]
|
||||
[airsonic-ui.routes :as routes]
|
||||
|
||||
;; ↓ registers subscription handlers ↓
|
||||
[airsonic-ui.components.current-queue.subs]))
|
||||
|
||||
(def SortHandle
|
||||
(SortableHandle.
|
||||
;; Alternative to r/reactify-component, which doens't convert props and hiccup,
|
||||
;; is to just provide fn as component and use as-element or create-element
|
||||
;; to return React elements from the component.
|
||||
(fn []
|
||||
(r/as-element [:span.is-size-7.has-text-grey-lighter
|
||||
[icon :elevator]]))))
|
||||
|
||||
(defn song-actions [{:keys [song idx]}]
|
||||
[dropdown {:items [{:label "Remove from queue"
|
||||
:event [:audio-player/remove-song idx]}
|
||||
{:label "Go to source"
|
||||
:event [:routes/do-navigation (playlist/item-source song)]}]}])
|
||||
|
||||
(defn artist-link [{id :artistId, artist :artist}]
|
||||
(if id
|
||||
[:a {:href (routes/url-for ::routes/artist.detail {:id id})} artist]
|
||||
artist))
|
||||
|
||||
(defn song-link [song idx]
|
||||
[:a
|
||||
{:href "#"
|
||||
:on-click (helpers/muted-dispatch [:audio-player/set-current-song idx])}
|
||||
(:title song)])
|
||||
|
||||
(defn song-table-head []
|
||||
[:thead>tr
|
||||
[:td.is-narrow]
|
||||
[:td.song-artist "Artist"]
|
||||
[:td.song-title "Title"]
|
||||
[:td.song-duration "Duration"]
|
||||
[:td.song-actions.is-narrow]])
|
||||
|
||||
(defn song-table-sortable-tbdoy [{:keys [songs current-song-idx]}]
|
||||
;; we need this closure to pass in custom arguments (current-song-idx)
|
||||
(fn []
|
||||
[sortable/sortable-component
|
||||
{:items songs
|
||||
:container [:tbody]
|
||||
:helper-class "sortable-is-moving"
|
||||
|
||||
:render-item
|
||||
(fn [{[idx song] :value}]
|
||||
[(if (= idx current-song-idx) :tr.is-playing :tr)
|
||||
[:td.sortable-handle.is-narrow [:> SortHandle]]
|
||||
[:td.song-artist [artist-link song]]
|
||||
[:td.song-title [song-link song idx]]
|
||||
[:td.song-duration (helpers/format-duration (:duration song) :brief? true)]
|
||||
[:td.song-actions.is-narrow [song-actions {:song song
|
||||
:idx idx}]]])
|
||||
|
||||
:on-sort-end
|
||||
(fn [{:keys [old-idx new-idx]}]
|
||||
;; if we don't dispatch-sync, the UI sometimes places the row back and
|
||||
;; resorts it a litle later
|
||||
(dispatch-sync [:audio-player/move-song old-idx new-idx]))}]))
|
||||
|
||||
(defn song-table [{:keys [songs current-song-idx]}]
|
||||
[collection/song-table
|
||||
{:songs songs
|
||||
:thead song-table-head
|
||||
:tbody (song-table-sortable-tbdoy {:songs songs
|
||||
:current-song-idx current-song-idx})}])
|
||||
|
||||
(defn queue-info [{:keys [playlist-info]}]
|
||||
[:ul.is-smaller.collection-info
|
||||
[:li [icon :audio-spectrum] (str (:count playlist-info)
|
||||
(if (pos? (:count playlist-info))
|
||||
" tracks"
|
||||
" track"))]
|
||||
[:li [icon :clock] (helpers/format-duration (:duration playlist-info))]])
|
||||
|
||||
(defn playlist [props]
|
||||
[:div
|
||||
[queue-info props]
|
||||
[song-table {:songs (get-in props [:current-playlist :items])
|
||||
:current-song-idx (get-in props [:current-playlist :current-idx])}]])
|
||||
|
||||
(defn empty-playlist []
|
||||
[:p "You are currently not playing anything. Use the search or go to your "
|
||||
[:a {:href (routes/url-for ::routes/library)} "Library"] " to start playing some music."])
|
||||
|
||||
(defn current-queue []
|
||||
[:section.section>div.container
|
||||
[:h1.title "Current Queue"]
|
||||
(if-let [playlist @(subscribe [:audio/playlist])]
|
||||
[song/listing (:queue playlist)]
|
||||
[:p "You are currently not playing anything. Use the search or go to your "
|
||||
[:a {:href (r/url-for ::r/library)} "Library"] " to start playing some music."])])
|
||||
(let [current-playlist @(subscribe [:audio/current-playlist])
|
||||
playlist-info @(subscribe [:current-queue/info])]
|
||||
[:section.section>div.container
|
||||
[:h1.title "Current Queue"]
|
||||
(if (empty? current-playlist)
|
||||
[empty-playlist]
|
||||
[playlist {:current-playlist current-playlist
|
||||
:playlist-info playlist-info}])]))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||
[airsonic-ui.components.podcast.subs :as subs]
|
||||
[airsonic-ui.views.cover :refer [cover card]]
|
||||
[airsonic-ui.views.icon :refer [icon]]
|
||||
[bulma.icon :refer [icon]]
|
||||
[airsonic-ui.components.debug.views :refer [debug]]))
|
||||
|
||||
;; TODO: Implement detail pages for podcasts
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@
|
|||
(:require [re-frame.core :refer [dispatch subscribe]]
|
||||
[goog.functions :refer [debounce]]
|
||||
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||
[airsonic-ui.views.song :as song]
|
||||
[airsonic-ui.helpers :as h]
|
||||
[airsonic-ui.components.collection.views :as collection]
|
||||
[airsonic-ui.views.cover :refer [card]]))
|
||||
|
||||
(def search
|
||||
(debounce #(dispatch [:search/do-search (.. % -target -value)]) 100))
|
||||
|
||||
(defn form []
|
||||
(let [search-term @(subscribe [:search/current-term])
|
||||
throttled-search (debounce #(dispatch [:search/do-search (.. % -target -value)]) 100)]
|
||||
(let [search-term @(subscribe [:search/current-term])]
|
||||
(fn []
|
||||
[:form {:on-submit #(.preventDefault %)}
|
||||
[:div.feld>p.control
|
||||
|
|
@ -15,7 +18,7 @@
|
|||
;; the event might be gone when we the dispatched
|
||||
;; function is fired, we need to persist it
|
||||
(.persist e)
|
||||
(throttled-search e))
|
||||
(search e))
|
||||
:default-value search-term
|
||||
:placeholder "Search"}]]])))
|
||||
|
||||
|
|
@ -41,8 +44,34 @@
|
|||
(defn album-results [{:keys [album]}]
|
||||
[result-cards (map (juxt album-url identity) album)])
|
||||
|
||||
(defn song-results [{:keys [song]}]
|
||||
[song/listing song])
|
||||
(defn song-table-thead []
|
||||
[:thead
|
||||
[:td.song-artist "Artist"]
|
||||
[:td.song-album "Album"]
|
||||
[:td.song-title "Title"]
|
||||
[:td.song-duration "Duration"]
|
||||
[:td.song-actions.is-narrow]])
|
||||
|
||||
(defn album-link [{id :albumId :as song}]
|
||||
[:a {:href (routes/url-for ::routes/album.detail {:id id})} (:album song)])
|
||||
|
||||
(defn song-table-tbody [{:keys [songs current-song]}]
|
||||
[:tbody
|
||||
(for [[idx song] (map-indexed vector songs)]
|
||||
^{:key idx}
|
||||
[(if (= (:id song) (:id current-song)) :tr.is-playing :tr)
|
||||
[:td.song-artist [collection/artist-link song]]
|
||||
[:td.song-album [album-link song]]
|
||||
[:td.song-title [collection/song-link {:songs songs
|
||||
:song song
|
||||
:idx idx}]]
|
||||
[:td.song-duration (h/format-duration (:duration song) :brief? true)]
|
||||
[:td.song-actions.is-narrow [collection/song-actions song]]])])
|
||||
|
||||
(defn song-results [{songs :song}]
|
||||
[collection/song-table {:songs songs
|
||||
:thead song-table-thead
|
||||
:tbody song-table-tbody}])
|
||||
|
||||
(defn results [{:keys [search]}]
|
||||
(let [term @(subscribe [:search/current-term])]
|
||||
|
|
|
|||
98
src/cljs/airsonic_ui/components/sortable/views.cljs
Normal file
98
src/cljs/airsonic_ui/components/sortable/views.cljs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
(ns airsonic-ui.components.sortable.views
|
||||
(:require [reagent.core :as r]
|
||||
[clojure.string :as str]
|
||||
["react-sortable-hoc" :refer [SortableHandle SortableElement
|
||||
SortableContainer]]))
|
||||
;; this code is taken and adapted from https://github.com/reagent-project/reagent/blob/72c95257c13e5de1531e16d1a06da7686041d3f4/examples/react-sortable-hoc/src/example/core.cljs
|
||||
|
||||
(defn make-wrapper [{:keys [container render-item]}]
|
||||
(let [SortableItem (SortableElement.
|
||||
(r/reactify-component render-item))]
|
||||
(SortableContainer.
|
||||
(r/reactify-component
|
||||
(fn [{:keys [items]}]
|
||||
(into container
|
||||
(for [[idx value] (map-indexed vector items)]
|
||||
(r/create-element
|
||||
SortableItem
|
||||
#js {:key (str "item-" idx)
|
||||
:index idx
|
||||
:value value}))))))))
|
||||
|
||||
(defn style-map
|
||||
"Returns a map representing all currently set css styles; this makes sense
|
||||
so we can save a non-updating version of it."
|
||||
[node]
|
||||
(let [style (js/window.getComputedStyle node)]
|
||||
(into {} (keep (fn [idx]
|
||||
(let [property (.item style idx)]
|
||||
[property (.getPropertyValue style property)]))
|
||||
(range (.-length style))))))
|
||||
|
||||
(defn node-seq
|
||||
"Returns a seq of all of a node's children"
|
||||
[node]
|
||||
(loop [waiting [node]
|
||||
nodes []]
|
||||
(if-let [node (first waiting)]
|
||||
(if-let [children (array-seq (.-children node))]
|
||||
(recur (concat (rest waiting) children) (conj nodes node))
|
||||
(recur (rest waiting) (conj nodes node)))
|
||||
(rest nodes))))
|
||||
|
||||
(defn style-snapshot
|
||||
"Recursively grabs the of all of a node's children"
|
||||
[node]
|
||||
(into [] (map style-map (node-seq node))))
|
||||
|
||||
(defn style-from-map!
|
||||
"Restores the styling saved in a stylemap"
|
||||
[style-map node]
|
||||
(let [style (str/join ";" (map (fn [[k v]] (str k ": " v)) style-map))]
|
||||
(.setAttribute node "style" style)))
|
||||
|
||||
(defn restore-snapshot
|
||||
"Recursively restores the styling of all of a nodes children"
|
||||
[style-snapshot node]
|
||||
(let [nodes (vec (node-seq node))]
|
||||
(dotimes [i (count nodes)]
|
||||
(style-from-map! (nth style-snapshot i) (nth nodes i)))))
|
||||
|
||||
(defonce saved-snapshot (atom nil))
|
||||
|
||||
(defn sortable-component
|
||||
"This function allows us to generate sortable components in a reusable way.
|
||||
It takes a prop-map with several keys:
|
||||
|
||||
- :container A hiccup-vector that will be used as the container
|
||||
- :items A seq containing the values we want to render and sort
|
||||
- :render-item Decides how we render each child; will be passed {:value value}
|
||||
- :on-sort-end Will be called with a map containing :old-idx & :new-idx
|
||||
- :helper-class Will be appended to the element that's sorted when it's
|
||||
appended to the body"
|
||||
[{:keys [container items render-item on-sort-end helper-class]}]
|
||||
(let [Wrapper (make-wrapper {:container container
|
||||
:render-item render-item})]
|
||||
(r/create-element
|
||||
Wrapper
|
||||
#js {:items items
|
||||
:helperClass helper-class
|
||||
:axis "y"
|
||||
:lockAxis "y"
|
||||
|
||||
;; save the style of all of the rows children
|
||||
:updateBeforeSortStart
|
||||
(fn [event]
|
||||
(reset! saved-snapshot (style-snapshot (.-node event))))
|
||||
:onSortStart
|
||||
(fn [_]
|
||||
;; the node we get passed as parameter is the original node unfortunately
|
||||
(restore-snapshot @saved-snapshot (js/document.querySelector "body > :last-child")))
|
||||
|
||||
;; update the state to reflect the new order
|
||||
:onSortEnd
|
||||
(fn [event]
|
||||
(on-sort-end {:old-idx (.-oldIndex event)
|
||||
:new-idx (.-newIndex event)}))
|
||||
|
||||
:useDragHandle true})))
|
||||
|
|
@ -4,14 +4,6 @@
|
|||
[clojure.string :as str])
|
||||
(:import [goog.string format]))
|
||||
|
||||
(defn find-where
|
||||
"Returns the the first item in `coll` with its index for which `(p song)`
|
||||
is truthy"
|
||||
[p coll]
|
||||
(->> (map-indexed vector coll)
|
||||
(reduce (fn [_ [idx song]]
|
||||
(when (p song) (reduced [idx song]))) nil)))
|
||||
|
||||
(defn muted-dispatch
|
||||
"Dispatches a re-frame event while canceling default DOM behavior; to be
|
||||
called for example in `:on-click`."
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
[airsonic-ui.views.notifications :refer [notification-list]]
|
||||
[airsonic-ui.views.breadcrumbs :refer [breadcrumbs]]
|
||||
[airsonic-ui.views.login :refer [login-form]]
|
||||
[airsonic-ui.views.icon :refer [icon]]
|
||||
[bulma.icon :refer [icon]]
|
||||
|
||||
[airsonic-ui.components.about.views :refer [about]]
|
||||
[airsonic-ui.components.artist.views :as artist]
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
(ns airsonic-ui.views.song
|
||||
(:require [re-frame.core :refer [subscribe]]
|
||||
[airsonic-ui.helpers :refer [muted-dispatch format-duration]]
|
||||
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||
[airsonic-ui.views.icon :refer [icon]]))
|
||||
|
||||
(defn item [songs song idx]
|
||||
(let [artist-id (:artistId song)
|
||||
duration (:duration song)]
|
||||
[:div
|
||||
(if artist-id
|
||||
[:a {:href (url-for ::routes/artist.detail {:id artist-id})} (:artist song)]
|
||||
(:artist song))
|
||||
" - "
|
||||
[:a
|
||||
{:href "#" :on-click (muted-dispatch [:audio-player/play-all songs idx] :sync? true)}
|
||||
(:title song)]
|
||||
[:span.duration (format-duration duration)]]))
|
||||
|
||||
(defn listing [songs]
|
||||
(let [current-song @(subscribe [:audio/current-song])]
|
||||
[:table.table.is-striped.is-hoverable.is-fullwidth.song-list>tbody
|
||||
(for [[idx song] (map-indexed vector songs)]
|
||||
(let [tag (if (= (:id song) (:id current-song)) :tr.song.is-playing :tr.song)]
|
||||
^{:key idx} [tag
|
||||
[:td.grow [item songs song idx]]
|
||||
[:td>a {:title "Play next"
|
||||
:href "#"
|
||||
:on-click (muted-dispatch [:audio-player/enqueue-next song])}
|
||||
[icon :plus]]
|
||||
[:td>a {:title "Play last"
|
||||
:href "#"
|
||||
:on-click (muted-dispatch [:audio-player/enqueue-last song])}
|
||||
[icon :caret-right]]]))]))
|
||||
20
src/cljs/bulma/dropdown/events.cljs
Normal file
20
src/cljs/bulma/dropdown/events.cljs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
(ns bulma.dropdown.events
|
||||
(:require [re-frame.core :as rf]))
|
||||
|
||||
(defn show-dropdown [db [_ dropdown-id]]
|
||||
(assoc-in db [:bulma :visible-dropdown] dropdown-id))
|
||||
|
||||
(rf/reg-event-db ::show show-dropdown)
|
||||
|
||||
(defn hide-dropdown [db _]
|
||||
(update db :bulma dissoc :visible-dropdown))
|
||||
|
||||
(rf/reg-event-db ::hide hide-dropdown)
|
||||
|
||||
(defn toggle-dropdown [db [_ dropdown-id]]
|
||||
(let [visible-dropdown (get-in db [:bulma :visible-dropdown])]
|
||||
(if (= visible-dropdown dropdown-id)
|
||||
(hide-dropdown db [::hide])
|
||||
(show-dropdown db [::show dropdown-id]))))
|
||||
|
||||
(rf/reg-event-db ::toggle toggle-dropdown)
|
||||
22
src/cljs/bulma/dropdown/subs.cljs
Normal file
22
src/cljs/bulma/dropdown/subs.cljs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
(ns bulma.dropdown.subs
|
||||
(:require [re-frame.core :as rf]))
|
||||
|
||||
;; NOTE: This is almost the same as bulma.modal.subs
|
||||
;; Maybe we can provide some abstraction that covers both, but maybe we shouldn't
|
||||
|
||||
(defn visible-dropdown
|
||||
"Gives us the ID of the currently visible dropdown"
|
||||
[db _]
|
||||
(get-in db [:bulma :visible-dropdown]))
|
||||
|
||||
(rf/reg-sub ::visible-dropdown visible-dropdown)
|
||||
|
||||
(defn visible?
|
||||
"Predicate to check the visibility of a single modal"
|
||||
[visible-dropdown [_ dropdown-id]]
|
||||
(= visible-dropdown dropdown-id))
|
||||
|
||||
(rf/reg-sub
|
||||
::visible?
|
||||
:<- [::visible-dropdown]
|
||||
visible?)
|
||||
43
src/cljs/bulma/dropdown/views.cljs
Normal file
43
src/cljs/bulma/dropdown/views.cljs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
(ns bulma.dropdown.views
|
||||
(:require [re-frame.core :refer [dispatch subscribe]]
|
||||
[reagent.core :as r]
|
||||
[bulma.icon :refer [icon]]
|
||||
[bulma.dropdown.events :as ev]
|
||||
[bulma.dropdown.subs :as sub]))
|
||||
|
||||
(defn choose-action [event-vector]
|
||||
(fn [e]
|
||||
(.preventDefault e)
|
||||
(dispatch [::ev/hide])
|
||||
(dispatch event-vector)))
|
||||
|
||||
(defn generate-id []
|
||||
(str "bulma-dropdown-" (random-uuid)))
|
||||
|
||||
(defn click-overlay
|
||||
[]
|
||||
[:div {:style {:position "fixed"
|
||||
:z-index 19 ;; <- 20 is the z-index of .dropdown-menu
|
||||
:top 0
|
||||
:left 0
|
||||
:bottom 0
|
||||
:right 0}
|
||||
:on-click #(dispatch [::ev/hide])}])
|
||||
|
||||
(defn dropdown [{:keys [items]}]
|
||||
(let [dropdown-id (generate-id)]
|
||||
(fn []
|
||||
(let [visible? @(subscribe [::sub/visible? dropdown-id])]
|
||||
[(if visible? :div.dropdown.is-right.is-active :div.dropdown.is-right)
|
||||
(when visible? [click-overlay])
|
||||
[:div.dropdown-trigger
|
||||
[:span.is-small.button {:aria-haspopup "true"
|
||||
:aria-controls dropdown-id
|
||||
:on-click #(dispatch [::ev/toggle dropdown-id])}
|
||||
[icon :ellipses]]]
|
||||
[:div.dropdown-menu {:id dropdown-id, :role "menu"}
|
||||
[:div.dropdown-content
|
||||
(for [[idx {:keys [label event]}] (map-indexed vector items)]
|
||||
^{:key (str dropdown-id "-" idx)}
|
||||
[:a.dropdown-item {:href "#"
|
||||
:on-click (choose-action event)} label])]]]))))
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
(ns airsonic-ui.views.icon)
|
||||
(ns bulma.icon)
|
||||
|
||||
(defn icon [glyph & extra]
|
||||
(defn icon [glyph]
|
||||
[:span.icon [:span.oi {:data-glyph (name glyph)}]])
|
||||
Loading…
Add table
Add a link
Reference in a new issue