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

@ -1,6 +1,12 @@
module.exports = function (config) {
const configuration = {
browsers: ['ChromeHeadless'],
// The tests are sometimes run before the tests were completely written
// to disc; this is a known problem unfortunately. This is a hack to at
// least keep the browsers connected so the tests are compiled and run
// again even if a developer isn't aware of this
autoWatchBatchDelay: 100,
browserNoActivityTimeout: 60 * 1000 * 10,
// The directory where the output file lives
basePath: 'public/test',
// The file itself

1269
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,13 @@
"main": "index.js",
"scripts": {
"build:cljs": "shadow-cljs release app",
"build:sass": "node-sass --output-style compressed src/sass/app.sass public/app/style.css",
"build:sass": "node-sass --output-style compressed src/sass/app.sass | postcss -o public/app/style.css",
"build": "rm -r public/*; run-p copy:* build:*",
"copy:assets": "cp -R src/assets/* public/",
"copy:icons": "cp -R node_modules/open-iconic/font/fonts public",
"deploy": "npm run build && gh-pages -d public -m \"Deploying $(git rev-parse --short HEAD)\"",
"dev:cljs": "shadow-cljs watch app test",
"dev:sass": "npm run build:sass; node-sass -w src/sass/app.sass public/app/style.css",
"dev:sass": "npm run build:sass; node-sass -w src/sass/app.sass | postcss -o public/app/style.css",
"dev:test": "karma start --reporters notify,progress --auto-watch",
"dev": "rm -r public/*; npm-run-all copy:* test:compile -p dev:*",
"test": "run-s test:compile test:run",
@ -26,15 +26,18 @@
},
"dependencies": {
"@hugojosefson/color-hash": "^2.0.3",
"autoprefixer": "^9.4.10",
"bulma": "^0.7.3",
"create-react-class": "^15.6.3",
"open-iconic": "^1.1.1",
"react": "^16.8.1",
"react-dom": "^16.8.1"
"postcss-cli": "^6.1.2",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-sortable-hoc": "^1.6.1"
},
"devDependencies": {
"gh-pages": "^1.2.0",
"karma": "^3.1.4",
"karma": "^4.0.1",
"karma-chrome-launcher": "^2.2.0",
"karma-cljs-test": "^0.1.0",
"karma-notify-reporter": "^1.1.0",
@ -43,6 +46,6 @@
"react-flip-move": "^3.0.3",
"react-highlight.js": "^1.0.7",
"sass": "^1.17.0",
"shadow-cljs": "^2.7.30"
"shadow-cljs": "^2.8.14"
}
}

5
postcss.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer')
]
}

View file

@ -11,11 +11,11 @@
[funcool/bide "1.6.0"]
[fipp "0.6.14"]
;; debugging
[day8.re-frame/re-frame-10x "0.3.3-react16"]
[day8.re-frame/tracing "0.5.1"]
[philoskim/debux "0.4.11"]
[day8.re-frame/re-frame-10x "0.3.7-react16"]
#_[day8.re-frame/tracing "0.5.1"]
[philoskim/debux "0.5.6"]
;; for CIDER
[cider/cider-nrepl "0.18.0"]]
[cider/cider-nrepl "0.21.1"]]
:nrepl {:port 9000}

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

View file

@ -218,18 +218,6 @@
.grow
width: 100%
// duh
.song-list
.song
.duration
padding-left: .5rem
color: $grey-light
font-weight: normal
&.is-playing
background-color: $light !important
font-weight: bold
// useful in general to pull elements closer together; bulma es very generous
// with whitespace
.section.is-small
@ -298,17 +286,6 @@
margin-right: 1rem
margin-bottom: 0
.collection-info
list-style: none
li
display: inline-block
margin-left: 0.75rem
&:first-child
margin-left: 0
.song-list
counter-reset: track
@ -322,3 +299,54 @@
font-weight: normal
display: inline
padding-right: 0.375rem
.collection-info
list-style: none
li
display: inline-block
margin-left: 0.75rem
&:first-child
margin-left: 0
.song-listing-table
tr.is-playing
background-color: $table-row-active-background-color
color: $table-row-active-color
a, strong, td.song-duration, td.sort-handle span
color: currentColor
span.button, div.dropdown
color: $table-color
td
&.is-narrow
white-space: nowrap
&.song-duration
text-align: right
&.sortable-handle
-webkit-touch-callout: none
user-select: none
tbody .song-duration
color: $grey-light
tr:hover
.button
// drag'n'drop
.sortable-handle
span
cursor: grabbing
user-select: none
tr.sortable-is-moving.is-playing
background-color: $table-row-active-background-color
color: $table-row-active-color
a, strong, td.song-duration, td.sort-handle span
color: currentColor

View file

@ -1,17 +1,20 @@
(ns airsonic-ui.audio.core-test
(:require [airsonic-ui.audio.core :as audio]
[airsonic-ui.audio.playlist-test :as p]
[airsonic-ui.fixtures :as fixtures]
#_[airsonic-ui.audio.playlist-test :as p]
#_[airsonic-ui.fixtures :as fixtures]
[cljs.test :refer [deftest testing is]]))
(enable-console-print!)
(deftest current-song-subscription
(letfn [(current-song [db]
;; NOTE: Should the subscription be moved to the playlist.cljs?
#_(testing "Should provide information about the song"
(letfn [(current-song [db]
(-> (audio/summary db [:audio/summary])
(audio/current-song [:audio/current-song])))]
(testing "Should provide information about the song"
(= fixtures/song (current-song p/fixture)))))
(= fixtures/song (current-song p/fixture))))
(testing "Should work fine when no song is playing"
(is (nil? (audio/current-song nil [:audio/current-song])))))
(deftest playback-status-subscription
(letfn [(is-playing? [playback-status]

View file

@ -1,29 +1,12 @@
(ns airsonic-ui.audio.playlist-test
(:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.helpers :refer [find-where]]
[airsonic-ui.fixtures :as fixtures]
[airsonic-ui.test-helpers :as helpers]
[debux.cs.core :refer-macros [dbg]]))
[airsonic-ui.test-helpers :refer [song song-queue]]
#_[debux.cs.core :refer-macros [dbg]]))
(enable-console-print!)
(defn- song []
(hash-map :id (rand-int 9999)
:coverArt (rand-int 9999)
:year (+ 1900 (rand-int 118))
:artist (helpers/rand-str)
:artistId (rand-int 100000)
:title (helpers/rand-str)
:album (helpers/rand-str)))
(defn- song-queue
"Generates a seq of n different songs"
[n]
(let [r-int (atom 0)]
(with-redefs [rand-int #(mod (swap! r-int inc) %1)]
(repeatedly n song))))
(def fixture
{:audio {:current-song fixtures/song
:playlist (song-queue 20)
@ -33,14 +16,22 @@
(deftest playlist-creation
(testing "Playlist creation"
(testing "should give us the correct current song"
(testing "should give us the correct current song for linear playback-mode"
(let [queue (song-queue 10)]
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-single :repeat-all]]
(doseq [repeat-mode [:repeat-none :repeat-single :repeat-all]]
(is (same-song? (first queue)
(-> (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode)
(playlist/peek)))
(str playback-mode ", " repeat-mode)))))
(-> (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)
(playlist/current-song)))
(str "repeat-mode: " repeat-mode)))))
(testing "any current song for shuffled playback mode"
(let [queue (song-queue 10)]
(doseq [repeat-mode [:repeat-none :repeat-single :repeat-all]]
(is (some? ((set queue)
(-> (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)
(playlist/current-song))))
(str "repeat-mode: " repeat-mode)))))
(testing "should give us a playlist with the correct number of tracks"
(let [queue (song-queue 100)]
(doseq [playback-mode [:linear :shuffled]
@ -55,21 +46,31 @@
(let [queue (song-queue 10)
linear (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none)
shuffled (playlist/set-playback-mode linear :shuffled)]
(testing "should indicate the new playback mode"
(is (= :linear (:playback-mode linear)))
(is (= :shuffled (:playback-mode shuffled))))
(testing "should re-order the tracks"
(is (not= (map :playlist/order (:queue shuffled)) (map :playlist/order (:queue linear)))))
(is (not= (:items shuffled) (:items linear))))
(testing "should not change the currently playing track"
(is (same-song? (playlist/peek linear) (playlist/peek shuffled))))
(is (same-song? (playlist/current-song linear) (playlist/current-song shuffled))))
(testing "should not change the repeat mode"
(is (= (:repeat-mode shuffled) (:repeat-mode linear))))))
(testing "from shuffled to linear"
(let [queue (song-queue 10)
shuffled (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-none)
linear (playlist/set-playback-mode shuffled :linear)]
(testing "should indicate the new playback mode"
(is (= :linear (:playback-mode linear)))
(is (= :shuffled (:playback-mode shuffled))))
(testing "should set the correct order for tracks"
(is (every? #(apply same-song? %) (interleave queue (:queue linear))))
(is (< (:playlist/order (first (:queue linear))) (:playlist/order (last (:queue linear))))))
(let [linear-order (comp :playlist/linear-order meta)]
(is (every? #(apply same-song? %) (interleave queue (vals (:items linear)))))
;; every song should have a smaller order than its successor
(is (->> (map linear-order (vals (:items linear)))
(partition 2 1)
(every? (fn [[a b]] (< a b)))))))
(testing "should not change the currently playing track"
(is (same-song? (playlist/peek linear) (playlist/peek shuffled))))
(is (same-song? (playlist/current-song linear) (playlist/current-song shuffled))))
(testing "should not change the repeat mode"
(is (= (:repeat-mode shuffled) (:repeat-mode linear))))))))
@ -91,17 +92,18 @@
(let [queue (song-queue 5)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)]
(is (same-song? (nth queue 1) (-> (playlist/next-song playlist)
(playlist/peek)))
(playlist/current-song)))
(str repeat-mode ", skipped once"))
(is (same-song? (nth queue 2) (-> (playlist/next-song playlist)
(playlist/next-song)
(playlist/peek)))
(playlist/current-song)))
(str repeat-mode ", skipped twice")))))
;; TODO: Write this test
(testing "Should go back to the first song when repeat-mode is all and we played the last song")
(testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-single)
played-back (map playlist/peek (iterate playlist/next-song playlist))]
played-back (map playlist/current-song (iterate playlist/next-song playlist))]
(is (same-song? (first queue) (nth played-back 0)))
(is (same-song? (first queue) (nth played-back 1)))
(is (same-song? (first queue) (nth played-back 2)))
@ -110,7 +112,7 @@
(is (nil? (-> (song-queue 1)
(playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none)
(playlist/next-song)
(playlist/peek))))))
(playlist/current-song))))))
(deftest shuffled-next-song
(testing "Should play every track once when called for the entire queue"
@ -118,35 +120,34 @@
(let [length 10
playlist (playlist/->playlist (song-queue length) :playback-mode :shuffled :repeat-mode repeat-mode)
played-tracks (->> (iterate playlist/next-song playlist)
(map playlist/peek)
(map playlist/current-song)
(take length))]
(is (= (count played-tracks) (count (set played-tracks)))
(str repeat-mode)))))
(testing "Should re-shuffle the playlist when wrapping around and repeat-mode is all"
(testing "Should keep the song order when wrapping around and repeat-mode is all"
(let [playlist (playlist/->playlist (song-queue 100) :playback-mode :shuffled :repeat-mode :repeat-all)
[last-idx _] (find-where #(= (:playlist/order %) 99) (:queue playlist))]
(is (not= (map :playlist/order (:queue playlist))
(map :playlist/order (:queue (-> (playlist/set-current-song playlist last-idx)
(playlist/next-song))))))))
next-playlist (-> (playlist/set-current-song playlist 99)
(playlist/next-song))]
(= (playlist/current-song playlist)
(playlist/current-song next-playlist))))
(testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single)
played-back (map playlist/peek (iterate playlist/next-song playlist))]
(is (same-song? (first queue) (nth played-back 0)))
(is (same-song? (first queue) (nth played-back 1)))
(is (same-song? (first queue) (nth played-back 2)))
(is (same-song? (first queue) (nth played-back 3)) "wrapping around")))
(let [playlist (playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-single)
played-back (map playlist/current-song (iterate playlist/next-song playlist))]
(dotimes [i 3]
(is (same-song? (nth played-back i) (nth played-back (inc i)))))))
(testing "Should stop playing at the end of the queue when repeat-mode is none"
(is (nil? (-> (song-queue 1)
(playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none)
(playlist/next-song)
(playlist/peek))))))
(playlist/current-song))))))
(deftest linear-previous-song
(testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-single)
played-back (map playlist/peek (iterate playlist/next-song playlist))]
played-back (map playlist/current-song (iterate playlist/next-song playlist))]
(is (same-song? (first queue) (nth played-back 0)))
(is (same-song? (first queue) (nth played-back 1)))
(is (same-song? (first queue) (nth played-back 2)))
@ -158,61 +159,61 @@
(is (same-song? (nth queue 1) (-> (playlist/next-song playlist)
(playlist/next-song)
(playlist/previous-song)
(playlist/peek)))))))
(testing "Should repeatedly give the first song when repeat-mode is none"
(playlist/current-song)))))))
;; TODO: Should it?
#_(testing "Should repeatedly give the first song when repeat-mode is none"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none)]
(is (same-song? (first queue) (-> (playlist/previous-song playlist)
(playlist/peek))))))
(playlist/current-song))))))
(testing "Should wrap around to last song when repeat-mode is all"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-all)]
(is (same-song? (last queue) (-> (playlist/previous-song playlist)
(playlist/peek)))))))
(playlist/current-song)))))))
(deftest shuffled-previous-song
(with-redefs [shuffle reverse]
(testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single)
played-back (map playlist/peek (iterate playlist/next-song playlist))]
(is (same-song? (first queue) (nth played-back 0)))
(is (same-song? (first queue) (nth played-back 1)))
(is (same-song? (first queue) (nth played-back 2)))
(is (same-song? (first queue) (nth played-back 3)) "wrapping around")))
played-back (map playlist/current-song (iterate playlist/next-song playlist))]
(dotimes [i 3]
(is (same-song? (nth played-back i) (nth played-back (inc i)))))))
(testing "Should keep the playing order when repeat-mode is not single"
(doseq [repeat-mode '(:repeat-none :repeat-all)]
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode repeat-mode)]
(is (same-song? (playlist/peek playlist)
(is (same-song? (playlist/current-song playlist)
(-> playlist
(playlist/next-song)
(playlist/previous-song)
(playlist/peek)))
(playlist/current-song)))
(str "for repeat mode " repeat-mode))
(is (same-song? (-> (playlist/next-song playlist)
(playlist/peek))
(playlist/current-song))
(-> (playlist/next-song playlist)
(playlist/next-song)
(playlist/previous-song)
(playlist/peek)))
(playlist/current-song)))
(str "for repeat mode " repeat-mode)))))
(testing "Should re-shuffle when repeat-mode is all and we go back to before the first track"
(let [playlist (with-redefs [shuffle identity]
(playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-all))
playlist' (with-redefs [shuffle reverse]
(playlist/previous-song playlist))]
(is (not= (map :playlist/order (:queue playlist)) (map :playlist/order (:queue playlist'))))))))
(testing "Should keep the song order when repeat-mode is all and we go back to before the first track"
(let [playlist (playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-all)
next-playlist (-> (playlist/previous-song playlist)
(playlist/set-current-song 0))]
(is (= (playlist/current-song playlist)
(playlist/current-song next-playlist)))))))
(deftest set-current-song
(testing "Should correctly set the new song"
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single)
current-track (first queue)
next-track (-> (playlist/set-current-song playlist 1)
(playlist/peek))]
(is (not (nil? next-track)))
(is (not (same-song? current-track next-track))))))
(doseq [repeat-mode [:repeat-all :repeat-none]]
(let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode repeat-mode)
next-track (-> (playlist/set-current-song playlist 1)
(playlist/current-song))]
(is (not (nil? next-track)))
(is (not (same-song? (playlist/current-song playlist)
next-track)))))))
(deftest enqueue-last
(testing "Should make sure the song is played last"
@ -223,12 +224,12 @@
(playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode))
played-back (->> (iterate playlist/next-song playlist)
(take (dec length))
(map #(:id (playlist/peek %)))
(map #(:id (playlist/current-song %)))
(set))
to-enqueue (song)
playlist' (playlist/enqueue-last playlist to-enqueue)]
(is (nil? (played-back (-> (->> (iterate playlist/next-song playlist')
(map playlist/peek))
(map playlist/current-song))
(nth length)
(:id))))
(str "for " playback-mode ", " repeat-mode)))))
@ -240,7 +241,7 @@
played-back-songs (fn played-back-songs [playlist]
(->> (iterate playlist/next-song playlist)
(take length)
(map playlist/peek)
(map playlist/current-song)
(map :playlist/order)))
played-back (played-back-songs playlist)
played-back' (played-back-songs (playlist/enqueue-last playlist (song)))]
@ -249,11 +250,92 @@
(deftest enqueue-next
(testing "Should play the song after the currently playing song"
(doseq [playback-mode '(:linear :shuffled)
repeat-mode '(:repeat-none :repeat-all)]
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all]]
(let [length 5, queue (song-queue length)
playlist (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode)
next-song (song)]
(is (same-song? next-song (-> (playlist/enqueue-next playlist next-song)
(playlist/next-song)
(playlist/peek))))))))
(playlist/current-song))))))))
(deftest move-track
(testing "Should correctly set the new order"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)]
(is (same-song? (-> (playlist/next-song playlist)
(playlist/next-song)
(playlist/current-song))
(-> (playlist/move-song playlist 2 1)
(playlist/next-song)
(playlist/current-song)))))))
(testing "Should update the currently playing track's index"
(testing "when inserting a track before"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)]
(is (= 4 (-> (playlist/set-current-song playlist 3)
(playlist/move-song 5 3)
:current-idx))))))
(testing "when moving a track behind it"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)]
(is (= 2 (-> (playlist/set-current-song playlist 3)
(playlist/move-song 2 5)
:current-idx))))))
(testing "when moving it"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)]
(is (= 7 (-> (playlist/set-current-song playlist 3)
(playlist/move-song 3 7)
:current-idx))))))
(testing "when the current track is outside of the modified range"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)]
(is (= 3 (-> (playlist/set-current-song playlist 3)
(playlist/move-song 4 7)
:current-idx))))))))
(deftest remove-song
(with-redefs [shuffle identity]
(testing "Should remove a single song from the playlist"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)
playlist (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)
first-removed (playlist/remove-song playlist 0)
middle-removed (playlist/remove-song playlist 5)
last-removed (playlist/remove-song playlist 9)
song-not-in-list? (fn [song playlist]
(every? #(not (same-song? % song))
(vals (:items playlist))))]
(is (= 9 (count first-removed) (count middle-removed) (count last-removed)))
(is (song-not-in-list? (first queue) first-removed))
(is (same-song? (second queue) (get (:items first-removed) 0)))
(is (song-not-in-list? (nth queue 5) middle-removed))
(is (same-song? (nth queue 6) (get (:items middle-removed) 5)))
(is (song-not-in-list? (last queue) last-removed)))))
(testing "Should pause if the currently playing song is removed"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-all :repeat-single]]
(let [n-songs 10
queue (song-queue n-songs)]
(is (nil? (-> (playlist/->playlist queue :repeat-mode repeat-mode :playback-mode playback-mode)
(playlist/set-current-song 5)
(playlist/remove-song 5)
(playlist/current-song)))))))))

View file

@ -1,10 +1,39 @@
(ns airsonic-ui.components.audio-player.events-test
(:require [cljs.test :refer-macros [deftest testing is]]
[airsonic-ui.test-helpers :refer [dispatches?]]
[airsonic-ui.audio.core :as audio]
[airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.fixtures :as fixtures]
[airsonic-ui.test-helpers :refer [dispatches? song-queue]]
[airsonic-ui.components.audio-player.events :as events]))
(deftest song-has-ended
(testing "Should play the next song when current song has ended"
(is (not (dispatches? (events/audio-update {} [:audio/update {:ended? false}]) :audio-player/next-song)))
(is (dispatches? (events/audio-update {} [:audio/update {:ended? true}]) :audio-player/next-song))))
(deftest changing-current-song
(testing "Should correctly set the current song index"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-single :repeat-all]]
(let [n-songs 100
next-idx (rand-int n-songs)
fixture {:db {:credentials fixtures/credentials
:audio {:current-playlist (playlist/->playlist (song-queue n-songs) :playback-mode playback-mode :repeat-mode repeat-mode)}}}
effects (events/set-current-song fixture [:audio/set-current-song next-idx])]
(is (= next-idx
(-> (:db effects)
(audio/summary [:audio/summary])
(audio/current-playlist [:audio/current-playlist])
(:current-idx)))
(str "for playback-mode " playback-mode " and repeat-mode " repeat-mode))
(is (contains? effects :audio/play))))))
(deftest removing-currently-playing-song
(testing "Should stop all audio when removing the currently playing song"
(doseq [playback-mode [:linear :shuffled]
repeat-mode [:repeat-none :repeat-single :repeat-all]]
(let [n-songs 100
fixture {:db {:credentials fixtures/credentials
:audio {:current-playlist (playlist/->playlist (song-queue n-songs) :playback-mode playback-mode :repeat-mode repeat-mode)}}}]
(is (contains? (events/remove-song fixture [:audio/remove-song 0]) :audio/stop))
(is (not (contains? (events/remove-song fixture [:audio/remove-song 99]) :audio/stop)))))))

View file

@ -2,17 +2,6 @@
(:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.helpers :as helpers]))
(deftest find-where
(testing "Finds the correct item and index"
(is (= [0 1] (helpers/find-where (partial = 1) (range 1 10))))
(is (= [2 {:foo true, :bar false}] (helpers/find-where :foo '({}
{:foo false
:bar true}
{:foo true
:bar false})))))
(testing "Returns nil when nothing is found"
(is (nil? (helpers/find-where (partial = 2) (range 2))))))
(deftest add-classes
(testing "Should add classes to a simple hiccup keyword"
(is (= :div.foo (helpers/add-classes :div :foo)))

View file

@ -17,3 +17,19 @@
(from arr #(-> (str 0 (.toString % 16))
(.substr -2)))
(join "")))))
(defn song []
(hash-map :id (rand-int 9999)
:coverArt (rand-int 9999)
:year (+ 1900 (rand-int 118))
:artist (rand-str)
:artistId (rand-int 100000)
:title (rand-str)
:album (rand-str)))
(defn song-queue
"Generates a seq of n different songs"
[n]
(let [r-int (atom 0)]
(with-redefs [rand-int #(mod (swap! r-int inc) %1)]
(repeatedly n song))))

View file

@ -0,0 +1,40 @@
(ns bulma.dropdown-test
(:require [cljs.test :refer-macros [deftest testing is]]
[bulma.dropdown.subs :as sub]
[bulma.dropdown.events :as ev]))
;; NOTE: Here as well; this code is very much like the modal code
;; Not sure whether to explicitly duplicate it or provide some smarter
;; abstraction that's harder to understand at first sight
(enable-console-print!)
(deftest bulma-dropdowns
(testing "Should create a collection of dropdowns if there is none"
(let [new-db (ev/show-dropdown {} [::ev/show :some-dropdown-id])]
(is (= :some-dropdown-id (sub/visible-dropdown new-db [::sub/visible-dropdown])))))
(testing "Should hide other dropdowns when displaying a new one"
(let [dropdown-ids [:some-id-1 :some-id-2 :some-id-3]
new-db (reduce (fn [db dropdown-id]
(ev/show-dropdown db [::ev/show dropdown-id]))
{} dropdown-ids)]
(is (= :some-id-3 (sub/visible-dropdown new-db [::sub/visible-dropdown])))))
(testing "Should remove a dropdown from the collection when we hide it"
(let [dropdown-ids [:some-id-1 :some-id-2 :some-id-3]
new-db (-> (reduce (fn [db dropdown-id]
(ev/show-dropdown db [::ev/show dropdown-id]))
{} dropdown-ids)
(ev/hide-dropdown [::ev/hide]))]
(is (not (some? (sub/visible-dropdown new-db [::sub/visible-dropdown]))))))
(testing "Should tell us about the visibility of a dropdown with a predicate"
(is (true? (-> (ev/show-dropdown {} [::ev/show :getting-repetitive])
(sub/visible-dropdown [::sub/visible-dropdown])
(sub/visible? [::sub/visible? :getting-repetitive])))))
(testing "Dropdown toggling"
(is (true? (-> (ev/toggle-dropdown {} [::ev/toggle :some-generic-dropdown])
(sub/visible-dropdown [::sub/visible-dropdown])
(sub/visible? [::sub/visible? :some-generic-dropdown]))))
(is (not (true? (-> (ev/toggle-dropdown {} [::ev/toggle :some-generic-dropdown])
(ev/toggle-dropdown [::ev/toggle :some-generic-dropdown])
(sub/visible-dropdown [::sub/visible-dropdown])
(sub/visible? [::sub/visible? :some-generic-dropdown])))))))