1
0
Fork 0
mirror of https://github.com/heyarne/airsonic-ui.git synced 2026-05-06 10:23:39 +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) { module.exports = function (config) {
const configuration = { const configuration = {
browsers: ['ChromeHeadless'], 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 // The directory where the output file lives
basePath: 'public/test', basePath: 'public/test',
// The file itself // 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", "main": "index.js",
"scripts": { "scripts": {
"build:cljs": "shadow-cljs release app", "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:*", "build": "rm -r public/*; run-p copy:* build:*",
"copy:assets": "cp -R src/assets/* public/", "copy:assets": "cp -R src/assets/* public/",
"copy:icons": "cp -R node_modules/open-iconic/font/fonts 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)\"", "deploy": "npm run build && gh-pages -d public -m \"Deploying $(git rev-parse --short HEAD)\"",
"dev:cljs": "shadow-cljs watch app test", "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:test": "karma start --reporters notify,progress --auto-watch",
"dev": "rm -r public/*; npm-run-all copy:* test:compile -p dev:*", "dev": "rm -r public/*; npm-run-all copy:* test:compile -p dev:*",
"test": "run-s test:compile test:run", "test": "run-s test:compile test:run",
@ -26,15 +26,18 @@
}, },
"dependencies": { "dependencies": {
"@hugojosefson/color-hash": "^2.0.3", "@hugojosefson/color-hash": "^2.0.3",
"autoprefixer": "^9.4.10",
"bulma": "^0.7.3", "bulma": "^0.7.3",
"create-react-class": "^15.6.3", "create-react-class": "^15.6.3",
"open-iconic": "^1.1.1", "open-iconic": "^1.1.1",
"react": "^16.8.1", "postcss-cli": "^6.1.2",
"react-dom": "^16.8.1" "react": "^16.8.4",
"react-dom": "^16.8.4",
"react-sortable-hoc": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"gh-pages": "^1.2.0", "gh-pages": "^1.2.0",
"karma": "^3.1.4", "karma": "^4.0.1",
"karma-chrome-launcher": "^2.2.0", "karma-chrome-launcher": "^2.2.0",
"karma-cljs-test": "^0.1.0", "karma-cljs-test": "^0.1.0",
"karma-notify-reporter": "^1.1.0", "karma-notify-reporter": "^1.1.0",
@ -43,6 +46,6 @@
"react-flip-move": "^3.0.3", "react-flip-move": "^3.0.3",
"react-highlight.js": "^1.0.7", "react-highlight.js": "^1.0.7",
"sass": "^1.17.0", "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"] [funcool/bide "1.6.0"]
[fipp "0.6.14"] [fipp "0.6.14"]
;; debugging ;; debugging
[day8.re-frame/re-frame-10x "0.3.3-react16"] [day8.re-frame/re-frame-10x "0.3.7-react16"]
[day8.re-frame/tracing "0.5.1"] #_[day8.re-frame/tracing "0.5.1"]
[philoskim/debux "0.4.11"] [philoskim/debux "0.5.6"]
;; for CIDER ;; for CIDER
[cider/cider-nrepl "0.18.0"]] [cider/cider-nrepl "0.21.1"]]
:nrepl {:port 9000} :nrepl {:port 9000}

View file

@ -6,8 +6,6 @@
[airsonic-ui.audio.playlist :as playlist] [airsonic-ui.audio.playlist :as playlist]
[goog.functions :refer [throttle]])) [goog.functions :refer [throttle]]))
;; TODO: Manage buffering
(defonce audio (atom nil)) (defonce audio (atom nil))
(defn normalize-time-ranges [time-ranges] (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 ; explanation of these events: https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Cross-browser_audio_basics
(defn attach-listeners! [el] (defn attach-listeners! [el]
(let [emit-audio-update (throttle #(rf/dispatch [:audio/update (->status el)]) 16)] (let [emit-audio-update (throttle #(rf/dispatch [:audio/update (->status el)]) 16)]
(doseq [event ["loadstart" "progress" "play" "timeupdate" "pause" "volumechange"]] (doseq [event ["loadstart" "progress" "play" "timeupdate" "pause" "volumechange"]]
@ -56,7 +53,8 @@
(fn [_] (fn [_]
(when-let [audio @audio] (when-let [audio @audio]
(.pause audio) (.pause audio)
(set! (.-currentTime audio) 0)))) (set! (.-currentTime audio) 0)
(set! (.-src audio) ""))))
(rf/reg-fx (rf/reg-fx
:audio/toggle-play-pause :audio/toggle-play-pause
@ -102,25 +100,26 @@
(rf/reg-sub :audio/summary summary) (rf/reg-sub :audio/summary summary)
(defn playlist (defn current-playlist
"Lists the complete playlist" "Lists the complete current-queue"
[summary _] [summary _]
(:playlist summary)) (:current-playlist summary))
(rf/reg-sub (rf/reg-sub
:audio/playlist :audio/current-playlist
:<- [:audio/summary] :<- [:audio/summary]
playlist) current-playlist)
(defn current-song (defn current-song
"Gives us information about the currently played song as presented by "Gives us information about the currently played song as presented by
the airsonic api" the airsonic api"
[playlist _] [playlist _]
(playlist/peek playlist)) (when-not (empty? playlist)
(playlist/current-song playlist)))
(rf/reg-sub (rf/reg-sub
:audio/current-song :audio/current-song
:<- [:audio/playlist] :<- [:audio/current-playlist]
current-song) current-song)
(defn playback-status (defn playback-status

View file

@ -1,139 +1,196 @@
(ns airsonic-ui.audio.playlist (ns airsonic-ui.audio.playlist
"Implements playlist queues that support different kinds of repetition and "Implements playlist queues that support different kinds of repetition and
song ordering." song ordering.")
(:refer-clojure :exclude [peek])
(:require [airsonic-ui.helpers :refer [find-where]]))
(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 cljs.core/ICounted
(-count [this] (-count [_]
(count (:queue this)))) (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 (defmulti ->playlist
"Creates a new playlist that behaves according to the given playback- and "Creates a new playlist that behaves according to the given playback- and
repeat-mode parameters." repeat-mode parameters."
(fn [queue & {:keys [playback-mode #_repeat-mode]}] (fn [_ & {:keys [playback-mode]}] playback-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)))
(defmethod ->playlist :linear (defmethod ->playlist :linear
[queue & {:keys [playback-mode repeat-mode]}] [items & {:keys [playback-mode repeat-mode source]}]
(let [queue (-> (mapv (fn [order song] (assoc song :playlist/order order)) (range) queue) (->Playlist (->> (map #(set-item-source % source) items)
(mark-first-song))] (linear-queue))
(->Playlist queue playback-mode repeat-mode))) 0 playback-mode repeat-mode))
(defn- -shuffle-songs [queue]
(->> (shuffle (range (count queue)))
(mapv (fn [song order] (assoc song :playlist/order order)) queue)))
(defmethod ->playlist :shuffled (defmethod ->playlist :shuffled
[queue & {:keys [playback-mode repeat-mode]}] [items & {:keys [playback-mode repeat-mode source]}]
(let [queue (conj (mapv #(update % :playlist/order inc) (-shuffle-songs (rest queue))) (->Playlist (->> (map #(set-item-source % source) items)
(assoc (first queue) :playlist/order 0 :playlist/currently-playing? true))] (shuffled-queue))
(->Playlist queue playback-mode repeat-mode))) 0 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)))))))))

View file

@ -3,56 +3,85 @@
[airsonic-ui.audio.playlist :as playlist] [airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.api.helpers :as api])) [airsonic-ui.api.helpers :as api]))
(rf/reg-event-fx
; sets up the db, starts to play a song and adds the rest to a playlist ; sets up the db, starts to play a song and adds the rest to a playlist
:audio-player/play-all (defn play-all-songs [{:keys [db]
(fn [{:keys [db]} [_ songs start-idx]] :routes/keys [current-route]} [_ songs start-idx]]
(let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all) (let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all :source current-route)
(playlist/set-current-song start-idx))] (playlist/set-current-song start-idx))]
{:audio/play (api/stream-url (:credentials db) (playlist/peek playlist)) {:audio/play (api/stream-url (:credentials db) (playlist/current-song playlist))
:db (assoc-in db [:audio :playlist] playlist)}))) :db (assoc-in db [:audio :current-playlist] playlist)}))
(rf/reg-event-fx
:audio-player/play-all
[(rf/inject-cofx :routes/current-route)]
play-all-songs)
(rf/reg-event-db (rf/reg-event-db
:audio-player/set-playback-mode :audio-player/set-playback-mode
(fn [db [_ 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 (rf/reg-event-db
:audio-player/set-repeat-mode :audio-player/set-repeat-mode
(fn [db [_ 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 (rf/reg-event-fx
:audio-player/next-song :audio-player/next-song
(fn [{:keys [db]} _] (fn [{:keys [db]} _]
(let [db (update-in db [:audio :playlist] playlist/next-song) (let [db (update-in db [:audio :current-playlist] playlist/next-song)
next (playlist/peek (get-in db [:audio :playlist]))] next (playlist/current-song (get-in db [:audio :current-playlist]))]
{:db db {:db db
:audio/play (api/stream-url (:credentials db) next)}))) :audio/play (api/stream-url (:credentials db) next)})))
(rf/reg-event-fx (rf/reg-event-fx
:audio-player/previous-song :audio-player/previous-song
(fn [{:keys [db]} _] (fn [{:keys [db]} _]
(let [db (update-in db [:audio :playlist] playlist/previous-song) (let [db (update-in db [:audio :current-playlist] playlist/previous-song)
prev (playlist/peek (get-in db [:audio :playlist]))] song (playlist/current-song (get-in db [:audio :current-playlist]))]
{:db db {: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 :audio-player/enqueue-next
(fn [db [_ song]] [(rf/inject-cofx :routes/current-route)]
(update-in db [:audio :playlist] #(playlist/enqueue-next % song)))) (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 (rf/reg-event-db
:audio-player/enqueue-last :audio-player/move-song
(fn [db [_ song]] (fn [db [_ from-idx to-idx]]
(update-in db [:audio :playlist] #(playlist/enqueue-last % song)))) (update-in db [:audio :current-playlist] #(playlist/move-song % from-idx to-idx))))
(rf/reg-event-fx (rf/reg-event-fx
:audio-player/toggle-play-pause :audio-player/toggle-play-pause
(fn [_ _] (fn [_ _]
{:audio/toggle-play-pause nil})) {: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 (defn audio-update
"Reacts to audio events fired by the HTML5 audio player and plays the next "Reacts to audio events fired by the HTML5 audio player and plays the next
track if necessary." track if necessary."
@ -65,7 +94,7 @@
(rf/reg-event-fx (rf/reg-event-fx
:audio-player/seek :audio-player/seek
(fn [{:keys [db]} [_ percentage]] (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]}))) {:audio/seek [percentage duration]})))
(rf/reg-event-fx (rf/reg-event-fx

View file

@ -4,7 +4,7 @@
[airsonic-ui.routes :as routes] [airsonic-ui.routes :as routes]
[airsonic-ui.helpers :as h] [airsonic-ui.helpers :as h]
[airsonic-ui.views.cover :refer [cover]] [airsonic-ui.views.cover :refer [cover]]
[airsonic-ui.views.icon :refer [icon]])) [bulma.icon :refer [icon]]))
;; currently playing / coming next / audio controls... ;; currently playing / coming next / audio controls...
@ -121,9 +121,8 @@
{:on-click toggle-volume-slider} {:on-click toggle-volume-slider}
[icon volume-icon]]])) [icon volume-icon]]]))
(defn playback-mode-controls [playlist] (defn playback-mode-controls [{:keys [repeat-mode playback-mode]}]
(let [{:keys [repeat-mode playback-mode]} playlist (let [button :p.control>button.button.is-light
button :p.control>button.button.is-light
shuffle-button (h/add-classes button (when (= playback-mode :shuffled) :is-primary)) shuffle-button (h/add-classes button (when (= playback-mode :shuffled) :is-primary))
repeat-button (h/add-classes button (case repeat-mode repeat-button (h/add-classes button (case repeat-mode
:repeat-single :is-info :repeat-single :is-info
@ -142,7 +141,7 @@
(defn audio-player [] (defn audio-player []
(let [current-song @(subscribe [:audio/current-song]) (let [current-song @(subscribe [:audio/current-song])
playlist @(subscribe [:audio/playlist]) current-playlist @(subscribe [:audio/current-playlist])
playback-status @(subscribe [:audio/playback-status]) playback-status @(subscribe [:audio/playback-status])
is-playing? @(subscribe [:audio/is-playing?])] is-playing? @(subscribe [:audio/is-playing?])]
[:nav.audio-player [:nav.audio-player
@ -153,6 +152,6 @@
[progress-indicators current-song playback-status] [progress-indicators current-song playback-status]
[playback-controls is-playing?] [playback-controls is-playing?]
[volume-controls playback-status] [volume-controls playback-status]
[playback-mode-controls playlist]] [playback-mode-controls current-playlist]]
;; not playing anything ;; not playing anything
[:p.navbar-item.idle-notification "No audio playing"])])) [:p.navbar-item.idle-notification "No audio playing"])]))

View file

@ -1,31 +1,36 @@
(ns airsonic-ui.components.collection.views (ns airsonic-ui.components.collection.views
"A collection is a list of audio files that belong together (e.g. an album or "A collection is a list of audio files that belong together (e.g. an album or
a podcast's overview)" a podcast's overview)"
(:require [airsonic-ui.helpers :refer [format-duration]] (:require [re-frame.core :refer [subscribe]]
[airsonic-ui.routes :as routes :refer [url-for]] [bulma.icon :refer [icon]]
[airsonic-ui.views.cover :refer [cover card]] [bulma.dropdown.views :refer [dropdown]]
[airsonic-ui.views.icon :refer [icon]] [airsonic-ui.helpers :as h]
[airsonic-ui.views.song :as song])) [airsonic-ui.routes :as routes]
[airsonic-ui.views.cover :refer [cover card]]))
(defn collection-info [{:keys [songCount duration year]}] (defn collection-info [{:keys [songCount duration year]}]
(vec (cond-> [:ul.is-smaller.collection-info (vec (cond-> [:ul.is-smaller.collection-info
[:li [icon :audio-spectrum] (str songCount (if (= 1 songCount) [:li [icon :audio-spectrum] (str songCount (if (= 1 songCount)
" track" " tracks"))] " track" " tracks"))]
[:li [icon :clock] (format-duration duration)]] [:li [icon :clock] (h/format-duration duration)]]
year (conj [:li [icon :calendar] (str "Released in " year)])))) year (conj [:li [icon :calendar] (str "Released in " year)]))))
(defn album-card [album] ;; TODO: Maybe this view belongs somewhere else?
(let [{:keys [artist artistId name id]} album] ;; 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 [card album
:url-fn #(url-for ::routes/album.detail {:id id}) :url-fn #(routes/url-for ::routes/album.detail {:id id})
:content [:div :content [:div
;; link to album ;; link to album
[:div.title.is-5 [:div.title.is-5
[:a {:href (url-for ::routes/album.detail {:id id}) [:a {:href (routes/url-for ::routes/album.detail {:id id})
:title name} name]] :title name} name]]
;; link to artist page ;; link to artist page
[:div.subtitle.is-6 [:a {:href (url-for ::routes/artist.detail {:id artistId}) [:div.subtitle.is-6 [:a {:href (routes/url-for ::routes/artist.detail {:id artistId})
:title artist} artist]]]])) :title artist} artist]]]])
(defn listing [albums] (defn listing [albums]
;; always show 5 in a row ;; 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 ^{:key idx} [:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
[album-card album]])]) [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 (defn detail
"Lists all songs in an album" "Shows a detail view of a single album, listing all "
[{:keys [album]}] [{:keys [album]}]
[:div [:div
[:section.hero.is-small>div.hero-body [:section.hero.is-small>div.hero-body
@ -46,4 +97,5 @@
[:h2.title (:name album)] [:h2.title (:name album)]
[:h3.subtitle (:artist album)] [:h3.subtitle (:artist album)]
[collection-info 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 (ns airsonic-ui.components.current-queue.views
(:require [re-frame.core :refer [subscribe]] (:require [re-frame.core :refer [subscribe dispatch-sync]]
[airsonic-ui.views.song :as song] [reagent.core :as r]
[airsonic-ui.routes :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 [] (defn current-queue []
(let [current-playlist @(subscribe [:audio/current-playlist])
playlist-info @(subscribe [:current-queue/info])]
[:section.section>div.container [:section.section>div.container
[:h1.title "Current Queue"] [:h1.title "Current Queue"]
(if-let [playlist @(subscribe [:audio/playlist])] (if (empty? current-playlist)
[song/listing (:queue playlist)] [empty-playlist]
[:p "You are currently not playing anything. Use the search or go to your " [playlist {:current-playlist current-playlist
[:a {:href (r/url-for ::r/library)} "Library"] " to start playing some music."])]) :playlist-info playlist-info}])]))

View file

@ -4,7 +4,7 @@
[airsonic-ui.routes :as routes :refer [url-for]] [airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.components.podcast.subs :as subs] [airsonic-ui.components.podcast.subs :as subs]
[airsonic-ui.views.cover :refer [cover card]] [airsonic-ui.views.cover :refer [cover card]]
[airsonic-ui.views.icon :refer [icon]] [bulma.icon :refer [icon]]
[airsonic-ui.components.debug.views :refer [debug]])) [airsonic-ui.components.debug.views :refer [debug]]))
;; TODO: Implement detail pages for podcasts ;; TODO: Implement detail pages for podcasts

View file

@ -2,12 +2,15 @@
(:require [re-frame.core :refer [dispatch subscribe]] (:require [re-frame.core :refer [dispatch subscribe]]
[goog.functions :refer [debounce]] [goog.functions :refer [debounce]]
[airsonic-ui.routes :as routes :refer [url-for]] [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]])) [airsonic-ui.views.cover :refer [card]]))
(def search
(debounce #(dispatch [:search/do-search (.. % -target -value)]) 100))
(defn form [] (defn form []
(let [search-term @(subscribe [:search/current-term]) (let [search-term @(subscribe [:search/current-term])]
throttled-search (debounce #(dispatch [:search/do-search (.. % -target -value)]) 100)]
(fn [] (fn []
[:form {:on-submit #(.preventDefault %)} [:form {:on-submit #(.preventDefault %)}
[:div.feld>p.control [:div.feld>p.control
@ -15,7 +18,7 @@
;; the event might be gone when we the dispatched ;; the event might be gone when we the dispatched
;; function is fired, we need to persist it ;; function is fired, we need to persist it
(.persist e) (.persist e)
(throttled-search e)) (search e))
:default-value search-term :default-value search-term
:placeholder "Search"}]]]))) :placeholder "Search"}]]])))
@ -41,8 +44,34 @@
(defn album-results [{:keys [album]}] (defn album-results [{:keys [album]}]
[result-cards (map (juxt album-url identity) album)]) [result-cards (map (juxt album-url identity) album)])
(defn song-results [{:keys [song]}] (defn song-table-thead []
[song/listing song]) [: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]}] (defn results [{:keys [search]}]
(let [term @(subscribe [:search/current-term])] (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]) [clojure.string :as str])
(:import [goog.string format])) (: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 (defn muted-dispatch
"Dispatches a re-frame event while canceling default DOM behavior; to be "Dispatches a re-frame event while canceling default DOM behavior; to be
called for example in `:on-click`." called for example in `:on-click`."

View file

@ -11,7 +11,7 @@
[airsonic-ui.views.notifications :refer [notification-list]] [airsonic-ui.views.notifications :refer [notification-list]]
[airsonic-ui.views.breadcrumbs :refer [breadcrumbs]] [airsonic-ui.views.breadcrumbs :refer [breadcrumbs]]
[airsonic-ui.views.login :refer [login-form]] [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.about.views :refer [about]]
[airsonic-ui.components.artist.views :as artist] [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)}]]) [:span.icon [:span.oi {:data-glyph (name glyph)}]])

View file

@ -218,18 +218,6 @@
.grow .grow
width: 100% 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 // useful in general to pull elements closer together; bulma es very generous
// with whitespace // with whitespace
.section.is-small .section.is-small
@ -298,17 +286,6 @@
margin-right: 1rem margin-right: 1rem
margin-bottom: 0 margin-bottom: 0
.collection-info
list-style: none
li
display: inline-block
margin-left: 0.75rem
&:first-child
margin-left: 0
.song-list .song-list
counter-reset: track counter-reset: track
@ -322,3 +299,54 @@
font-weight: normal font-weight: normal
display: inline display: inline
padding-right: 0.375rem 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 (ns airsonic-ui.audio.core-test
(:require [airsonic-ui.audio.core :as audio] (:require [airsonic-ui.audio.core :as audio]
[airsonic-ui.audio.playlist-test :as p] #_[airsonic-ui.audio.playlist-test :as p]
[airsonic-ui.fixtures :as fixtures] #_[airsonic-ui.fixtures :as fixtures]
[cljs.test :refer [deftest testing is]])) [cljs.test :refer [deftest testing is]]))
(enable-console-print!) (enable-console-print!)
(deftest current-song-subscription (deftest current-song-subscription
;; NOTE: Should the subscription be moved to the playlist.cljs?
#_(testing "Should provide information about the song"
(letfn [(current-song [db] (letfn [(current-song [db]
(-> (audio/summary db [:audio/summary]) (-> (audio/summary db [:audio/summary])
(audio/current-song [:audio/current-song])))] (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 (deftest playback-status-subscription
(letfn [(is-playing? [playback-status] (letfn [(is-playing? [playback-status]

View file

@ -1,29 +1,12 @@
(ns airsonic-ui.audio.playlist-test (ns airsonic-ui.audio.playlist-test
(:require [cljs.test :refer [deftest testing is]] (:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.audio.playlist :as playlist] [airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.helpers :refer [find-where]]
[airsonic-ui.fixtures :as fixtures] [airsonic-ui.fixtures :as fixtures]
[airsonic-ui.test-helpers :as helpers] [airsonic-ui.test-helpers :refer [song song-queue]]
[debux.cs.core :refer-macros [dbg]])) #_[debux.cs.core :refer-macros [dbg]]))
(enable-console-print!) (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 (def fixture
{:audio {:current-song fixtures/song {:audio {:current-song fixtures/song
:playlist (song-queue 20) :playlist (song-queue 20)
@ -33,14 +16,22 @@
(deftest playlist-creation (deftest playlist-creation
(testing "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)] (let [queue (song-queue 10)]
(doseq [playback-mode [:linear :shuffled] (doseq [repeat-mode [:repeat-none :repeat-single :repeat-all]]
repeat-mode [:repeat-none :repeat-single :repeat-all]]
(is (same-song? (first queue) (is (same-song? (first queue)
(-> (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode) (-> (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)
(playlist/peek))) (playlist/current-song)))
(str playback-mode ", " repeat-mode))))) (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" (testing "should give us a playlist with the correct number of tracks"
(let [queue (song-queue 100)] (let [queue (song-queue 100)]
(doseq [playback-mode [:linear :shuffled] (doseq [playback-mode [:linear :shuffled]
@ -55,21 +46,31 @@
(let [queue (song-queue 10) (let [queue (song-queue 10)
linear (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none) linear (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none)
shuffled (playlist/set-playback-mode linear :shuffled)] 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" (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" (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" (testing "should not change the repeat mode"
(is (= (:repeat-mode shuffled) (:repeat-mode linear)))))) (is (= (:repeat-mode shuffled) (:repeat-mode linear))))))
(testing "from shuffled to linear" (testing "from shuffled to linear"
(let [queue (song-queue 10) (let [queue (song-queue 10)
shuffled (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-none) shuffled (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-none)
linear (playlist/set-playback-mode shuffled :linear)] 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" (testing "should set the correct order for tracks"
(is (every? #(apply same-song? %) (interleave queue (:queue linear)))) (let [linear-order (comp :playlist/linear-order meta)]
(is (< (:playlist/order (first (:queue linear))) (:playlist/order (last (:queue linear)))))) (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" (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" (testing "should not change the repeat mode"
(is (= (:repeat-mode shuffled) (:repeat-mode linear)))))))) (is (= (:repeat-mode shuffled) (:repeat-mode linear))))))))
@ -91,17 +92,18 @@
(let [queue (song-queue 5) (let [queue (song-queue 5)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)] playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode repeat-mode)]
(is (same-song? (nth queue 1) (-> (playlist/next-song playlist) (is (same-song? (nth queue 1) (-> (playlist/next-song playlist)
(playlist/peek))) (playlist/current-song)))
(str repeat-mode ", skipped once")) (str repeat-mode ", skipped once"))
(is (same-song? (nth queue 2) (-> (playlist/next-song playlist) (is (same-song? (nth queue 2) (-> (playlist/next-song playlist)
(playlist/next-song) (playlist/next-song)
(playlist/peek))) (playlist/current-song)))
(str repeat-mode ", skipped twice"))))) (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 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" (testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-single) 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 0)))
(is (same-song? (first queue) (nth played-back 1))) (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 2)))
@ -110,7 +112,7 @@
(is (nil? (-> (song-queue 1) (is (nil? (-> (song-queue 1)
(playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none) (playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none)
(playlist/next-song) (playlist/next-song)
(playlist/peek)))))) (playlist/current-song))))))
(deftest shuffled-next-song (deftest shuffled-next-song
(testing "Should play every track once when called for the entire queue" (testing "Should play every track once when called for the entire queue"
@ -118,35 +120,34 @@
(let [length 10 (let [length 10
playlist (playlist/->playlist (song-queue length) :playback-mode :shuffled :repeat-mode repeat-mode) playlist (playlist/->playlist (song-queue length) :playback-mode :shuffled :repeat-mode repeat-mode)
played-tracks (->> (iterate playlist/next-song playlist) played-tracks (->> (iterate playlist/next-song playlist)
(map playlist/peek) (map playlist/current-song)
(take length))] (take length))]
(is (= (count played-tracks) (count (set played-tracks))) (is (= (count played-tracks) (count (set played-tracks)))
(str repeat-mode))))) (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) (let [playlist (playlist/->playlist (song-queue 100) :playback-mode :shuffled :repeat-mode :repeat-all)
[last-idx _] (find-where #(= (:playlist/order %) 99) (:queue playlist))] next-playlist (-> (playlist/set-current-song playlist 99)
(is (not= (map :playlist/order (:queue playlist)) (playlist/next-song))]
(map :playlist/order (:queue (-> (playlist/set-current-song playlist last-idx) (= (playlist/current-song playlist)
(playlist/next-song)))))))) (playlist/current-song next-playlist))))
(testing "Should always give the same track when repeat-mode is single" (testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3) (let [playlist (playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-single)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single) played-back (map playlist/current-song (iterate playlist/next-song playlist))]
played-back (map playlist/peek (iterate playlist/next-song playlist))] (dotimes [i 3]
(is (same-song? (first queue) (nth played-back 0))) (is (same-song? (nth played-back i) (nth played-back (inc i)))))))
(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")))
(testing "Should stop playing at the end of the queue when repeat-mode is none" (testing "Should stop playing at the end of the queue when repeat-mode is none"
(is (nil? (-> (song-queue 1) (is (nil? (-> (song-queue 1)
(playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none) (playlist/->playlist :playback-mode :linear :repeat-mode :repeat-none)
(playlist/next-song) (playlist/next-song)
(playlist/peek)))))) (playlist/current-song))))))
(deftest linear-previous-song (deftest linear-previous-song
(testing "Should always give the same track when repeat-mode is single" (testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-single) 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 0)))
(is (same-song? (first queue) (nth played-back 1))) (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 2)))
@ -158,61 +159,61 @@
(is (same-song? (nth queue 1) (-> (playlist/next-song playlist) (is (same-song? (nth queue 1) (-> (playlist/next-song playlist)
(playlist/next-song) (playlist/next-song)
(playlist/previous-song) (playlist/previous-song)
(playlist/peek))))))) (playlist/current-song)))))))
(testing "Should repeatedly give the first song when repeat-mode is none" ;; TODO: Should it?
#_(testing "Should repeatedly give the first song when repeat-mode is none"
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none)] playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-none)]
(is (same-song? (first queue) (-> (playlist/previous-song playlist) (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" (testing "Should wrap around to last song when repeat-mode is all"
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-all)] playlist (playlist/->playlist queue :playback-mode :linear :repeat-mode :repeat-all)]
(is (same-song? (last queue) (-> (playlist/previous-song playlist) (is (same-song? (last queue) (-> (playlist/previous-song playlist)
(playlist/peek))))))) (playlist/current-song)))))))
(deftest shuffled-previous-song (deftest shuffled-previous-song
(with-redefs [shuffle reverse] (with-redefs [shuffle reverse]
(testing "Should always give the same track when repeat-mode is single" (testing "Should always give the same track when repeat-mode is single"
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single) playlist (playlist/->playlist queue :playback-mode :shuffled :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))) (dotimes [i 3]
(is (same-song? (first queue) (nth played-back 1))) (is (same-song? (nth played-back i) (nth played-back (inc i)))))))
(is (same-song? (first queue) (nth played-back 2)))
(is (same-song? (first queue) (nth played-back 3)) "wrapping around")))
(testing "Should keep the playing order when repeat-mode is not single" (testing "Should keep the playing order when repeat-mode is not single"
(doseq [repeat-mode '(:repeat-none :repeat-all)] (doseq [repeat-mode '(:repeat-none :repeat-all)]
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode repeat-mode)] 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
(playlist/next-song) (playlist/next-song)
(playlist/previous-song) (playlist/previous-song)
(playlist/peek))) (playlist/current-song)))
(str "for repeat mode " repeat-mode)) (str "for repeat mode " repeat-mode))
(is (same-song? (-> (playlist/next-song playlist) (is (same-song? (-> (playlist/next-song playlist)
(playlist/peek)) (playlist/current-song))
(-> (playlist/next-song playlist) (-> (playlist/next-song playlist)
(playlist/next-song) (playlist/next-song)
(playlist/previous-song) (playlist/previous-song)
(playlist/peek))) (playlist/current-song)))
(str "for repeat mode " repeat-mode))))) (str "for repeat mode " repeat-mode)))))
(testing "Should re-shuffle when repeat-mode is all and we go back to before the first track" (testing "Should keep the song order when repeat-mode is all and we go back to before the first track"
(let [playlist (with-redefs [shuffle identity] (let [playlist (playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-all)
(playlist/->playlist (song-queue 10) :playback-mode :shuffled :repeat-mode :repeat-all)) next-playlist (-> (playlist/previous-song playlist)
playlist' (with-redefs [shuffle reverse] (playlist/set-current-song 0))]
(playlist/previous-song playlist))] (is (= (playlist/current-song playlist)
(is (not= (map :playlist/order (:queue playlist)) (map :playlist/order (:queue playlist')))))))) (playlist/current-song next-playlist)))))))
(deftest set-current-song (deftest set-current-song
(testing "Should correctly set the new song" (testing "Should correctly set the new song"
(doseq [repeat-mode [:repeat-all :repeat-none]]
(let [queue (song-queue 3) (let [queue (song-queue 3)
playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode :repeat-single) playlist (playlist/->playlist queue :playback-mode :shuffled :repeat-mode repeat-mode)
current-track (first queue)
next-track (-> (playlist/set-current-song playlist 1) next-track (-> (playlist/set-current-song playlist 1)
(playlist/peek))] (playlist/current-song))]
(is (not (nil? next-track))) (is (not (nil? next-track)))
(is (not (same-song? current-track next-track)))))) (is (not (same-song? (playlist/current-song playlist)
next-track)))))))
(deftest enqueue-last (deftest enqueue-last
(testing "Should make sure the song is played 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)) (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode))
played-back (->> (iterate playlist/next-song playlist) played-back (->> (iterate playlist/next-song playlist)
(take (dec length)) (take (dec length))
(map #(:id (playlist/peek %))) (map #(:id (playlist/current-song %)))
(set)) (set))
to-enqueue (song) to-enqueue (song)
playlist' (playlist/enqueue-last playlist to-enqueue)] playlist' (playlist/enqueue-last playlist to-enqueue)]
(is (nil? (played-back (-> (->> (iterate playlist/next-song playlist') (is (nil? (played-back (-> (->> (iterate playlist/next-song playlist')
(map playlist/peek)) (map playlist/current-song))
(nth length) (nth length)
(:id)))) (:id))))
(str "for " playback-mode ", " repeat-mode))))) (str "for " playback-mode ", " repeat-mode)))))
@ -240,7 +241,7 @@
played-back-songs (fn played-back-songs [playlist] played-back-songs (fn played-back-songs [playlist]
(->> (iterate playlist/next-song playlist) (->> (iterate playlist/next-song playlist)
(take length) (take length)
(map playlist/peek) (map playlist/current-song)
(map :playlist/order))) (map :playlist/order)))
played-back (played-back-songs playlist) played-back (played-back-songs playlist)
played-back' (played-back-songs (playlist/enqueue-last playlist (song)))] played-back' (played-back-songs (playlist/enqueue-last playlist (song)))]
@ -249,11 +250,92 @@
(deftest enqueue-next (deftest enqueue-next
(testing "Should play the song after the currently playing song" (testing "Should play the song after the currently playing song"
(doseq [playback-mode '(:linear :shuffled) (doseq [playback-mode [:linear :shuffled]
repeat-mode '(:repeat-none :repeat-all)] repeat-mode [:repeat-none :repeat-all]]
(let [length 5, queue (song-queue length) (let [length 5, queue (song-queue length)
playlist (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode) playlist (playlist/->playlist queue :playback-mode playback-mode :repeat-mode repeat-mode)
next-song (song)] next-song (song)]
(is (same-song? next-song (-> (playlist/enqueue-next playlist next-song) (is (same-song? next-song (-> (playlist/enqueue-next playlist next-song)
(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 (ns airsonic-ui.components.audio-player.events-test
(:require [cljs.test :refer-macros [deftest testing is]] (: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])) [airsonic-ui.components.audio-player.events :as events]))
(deftest song-has-ended (deftest song-has-ended
(testing "Should play the next song when current 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 (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)))) (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]] (:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.helpers :as helpers])) [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 (deftest add-classes
(testing "Should add classes to a simple hiccup keyword" (testing "Should add classes to a simple hiccup keyword"
(is (= :div.foo (helpers/add-classes :div :foo))) (is (= :div.foo (helpers/add-classes :div :foo)))

View file

@ -17,3 +17,19 @@
(from arr #(-> (str 0 (.toString % 16)) (from arr #(-> (str 0 (.toString % 16))
(.substr -2))) (.substr -2)))
(join ""))))) (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])))))))