1
0
Fork 0
mirror of https://github.com/heyarne/airsonic-ui.git synced 2026-05-06 18:33:38 +02:00

Improvements to currently playing queue (#48)

* First sloppy import of code from heyarne/reagent-movable

* Consistently use "current queue" to avoid confusion

* Update shadow-cljs, re-frame and debux

* Solve styling problem when sorting table rows

* Make sortable component more reusable

* Refactor playlist to use a sorted-map

* Make sure current queue is displayed again

* Fix sorting when converting a shuffled into a linear playlist

* Implement set-current-track

* Implement song-move in playlist

* Add autoprefixer

* Implement drag and drop reordering in current queue

* Fix broken dev sass build

* Bump some dependencies

* Move airsonic-ui.views.icon to bulma.icon

* Implement reusable dropdown in bulma.dropdown

* Immediately render reordered tracks, reimplement actions in album view

* Use new song-table on search result page

* Make song-table more reusable

* Remove current song

* Implement go to source in current queue

* Remove unused song view
This commit is contained in:
heyarne 2019-03-12 15:22:13 +01:00 committed by GitHub
commit 8bf222a6e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1773 additions and 869 deletions

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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"])]))

View file

@ -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)}]]])

View 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)

View file

@ -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}])]))

View file

@ -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

View file

@ -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])]

View 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})))

View file

@ -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`."

View file

@ -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]

View file

@ -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]]]))]))

View 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)

View 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?)

View 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])]]]))))

View file

@ -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)}]])