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

Start restructuring audio playback, add some tests for audio

Fixes #15 where audio was not stopped on logout
This commit is contained in:
Arne Schlüter 2018-08-01 18:36:47 +02:00
commit 80225d46b1
10 changed files with 187 additions and 69 deletions

View file

@ -20,8 +20,10 @@
(doseq [event ["loadstart" "progress" "play" "timeupdate" "pause"]] (doseq [event ["loadstart" "progress" "play" "timeupdate" "pause"]]
(.addEventListener el event #(re-frame/dispatch [:audio/update (->status el)])))) (.addEventListener el event #(re-frame/dispatch [:audio/update (->status el)]))))
;; effects to be fired from event handlers
(re-frame/reg-fx (re-frame/reg-fx
:play-song :audio/play
(fn [song-url] (fn [song-url]
(when-not @audio (when-not @audio
(reset! audio (js/Audio.)) (reset! audio (js/Audio.))
@ -31,9 +33,74 @@
(.play @audio))) (.play @audio)))
(re-frame/reg-fx (re-frame/reg-fx
:toggle-play-pause :audio/pause
(fn [_] (fn [_]
(let [a @audio] (some-> @audio .pause)))
(re-frame/reg-fx
:audio/stop
(fn [_]
(when-let [audio @audio]
(.pause audio)
(set! (.-currentTime audio) 0))))
(re-frame/reg-fx
:audio/toggle-play-pause
(fn [_]
(if-let [a @audio]
(if (.-paused a) (if (.-paused a)
(.play a) (.play a)
(.pause a))))) (.pause a)))))
;; subscriptions
(defn summary
"Returns all information about audio that we have"
[db _]
(:audio db))
(re-frame/reg-sub :audio/summary summary)
(defn current-song
"Gives us information about the currently played song as presented by
the airsonic api"
[summary _]
(:current-song summary))
(re-frame/reg-sub
:audio/current-song
(fn [_ _] (re-frame/subscribe [:audio/summary]))
current-song)
(defn playback-status
"Gives us information about the most recently fired html 5 audio event"
[summary _]
(:playback-status summary))
(re-frame/reg-sub
:audio/playback-status
(fn [_ _] (re-frame/subscribe [:audio/summary]))
playback-status)
(defn is-playing?
"Predicate to tell us whether we currently have audio output or not"
[playback-status _]
(and (not (:paused? playback-status))
(not (:ended? playback-status))))
(re-frame/reg-sub
:audio/is-playing?
(fn [_ _] (re-frame/subscribe [:audio/current-playback-status]))
is-playing?)
(comment
;; NOTE: Not in use currently
(defn current-playlist
"Lists the complete playlist"
[summary _]
(:playlist summary))
(re-frame/reg-sub
:audio/current-playlist
(fn [_ _] (re-frame/subscribe [:audio/summary]))
current-playlist))

View file

@ -130,8 +130,8 @@
[::routes/login {} {:redirect (routes/encode-route redirect)}] [::routes/login {} {:redirect (routes/encode-route redirect)}]
[::routes/login])] [::routes/login])]
:store nil :store nil
:db (-> (merge (:db cofx) db/default-db) :db db/default-db
(dissoc :credentials))})) :audio/stop nil}))
(re-frame/reg-event-fx ::logout logout) (re-frame/reg-event-fx ::logout logout)
@ -180,41 +180,40 @@
; 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
::play-songs ::play-songs
(fn [{:keys [db]} [_ songs song]] (fn [{:keys [db]} [_ songs song]]
{:play-song (song-url db song) {:audio/play (song-url db song)
:db (-> db :db (-> (assoc-in db [:audio :current-song] song)
(assoc-in [:currently-playing :item] song) (assoc-in [:audio :playlist] songs))}))
(assoc-in [:currently-playing :playlist] songs))}))
(re-frame/reg-event-fx (re-frame/reg-event-fx
::next-song ::next-song
(fn [{:keys [db]} _] (fn [{:keys [db]} _]
(let [playlist (-> db :currently-playing :playlist) (let [playlist (get-in db [:audio :playlist])
current (-> db :currently-playing :item) current-song (get-in db [:audio :current-song])
next (first (rest (drop-while #(not= % current) playlist)))] next (first (rest (drop-while #(not= % current-song) playlist)))]
(when next (when next
{:play-song (song-url db next) {:audio/play (song-url db next)
:db (assoc-in db [:currently-playing :item] next)})))) :db (assoc-in db [:audio :current-song] next)}))))
(re-frame/reg-event-fx (re-frame/reg-event-fx
::previous-song ::previous-song
(fn [{:keys [db]} _] (fn [{:keys [db]} _]
(let [playlist (-> db :currently-playing :playlist) (let [playlist (get-in db [:audio :playlist])
current (-> db :currently-playing :item) current-song (get-in db [:audio :current-song])
previous (last (take-while #(not= % current) playlist))] previous (last (take-while #(not= % current-song) playlist))]
(when previous (when previous
{:play-song (song-url db previous) {:audio/play (song-url db previous)
:db (assoc-in db [:currently-playing :item] previous)})))) :db (assoc-in db [:audio :current-song] previous)}))))
(re-frame/reg-event-fx (re-frame/reg-event-fx
::toggle-play-pause ::toggle-play-pause
(fn [_ _] (fn [_ _]
{:toggle-play-pause nil})) {:audio/toggle-play-pause nil}))
(re-frame/reg-event-db (re-frame/reg-event-db
:audio/update :audio/update
(fn [db [_ status]] (fn [db [_ status]]
; we receive this from the player once it's playing ; this is coming from HTML5 Audio events
(assoc-in db [:currently-playing :status] status))) (assoc-in db [:audio :playback-status] status)))
;; --- ;; ---
;; routing ;; routing

View file

@ -47,21 +47,6 @@
(fn [db _] (fn [db _]
(:response db))) (:response db)))
(re-frame/reg-sub
; returns info on the current song as is (basically the metadata you can read from the file system)
::currently-playing
(fn [db _]
(:currently-playing db)))
(re-frame/reg-sub
::is-playing?
(fn [query-v _]
[(re-frame/subscribe [::currently-playing])])
(fn [[currently-playing] _]
(let [status (:status currently-playing)]
(and (not (:paused? status))
(not (:ended? status))))))
;; user notifications ;; user notifications
(defn notifications [db _] (:notifications db)) (defn notifications [db _] (:notifications db))

View file

@ -67,9 +67,6 @@
(let [notifications @(subscribe [::subs/notifications]) (let [notifications @(subscribe [::subs/notifications])
is-booting? @(subscribe [::subs/is-booting?]) is-booting? @(subscribe [::subs/is-booting?])
[route-id params query] @(subscribe [::subs/current-route])] [route-id params query] @(subscribe [::subs/current-route])]
(println "route-id" route-id (case route-id
::routes/login "::routes/login"
"something else"))
[:div [:div
[notification-list notifications] [notification-list notifications]
(if is-booting? (if is-booting?

View file

@ -1,18 +1,17 @@
(ns airsonic-ui.views.bottom-bar (ns airsonic-ui.views.bottom-bar
(:require [re-frame.core :refer [dispatch subscribe]] (:require [re-frame.core :refer [dispatch subscribe]]
[airsonic-ui.events :as events] [airsonic-ui.events :as events]
[airsonic-ui.subs :as subs]
[airsonic-ui.views.cover :refer [cover]] [airsonic-ui.views.cover :refer [cover]]
[airsonic-ui.views.icon :refer [icon]])) [airsonic-ui.views.icon :refer [icon]]))
;; currently playing / coming next / audio controls... ;; currently playing / coming next / audio controls...
(defn current-song-info [{:keys [item status]}] (defn current-song-info [song status]
[:article [:article
[:div (:artist item) " - " (:title item)] [:div (:artist song) " - " (:title song)]
;; FIXME: Sometimes items don't have a duration ;; FIXME: Sometimes items don't have a duration
[:progress.progress.is-tiny {:value (:current-time status) [:progress.progress.is-tiny {:value (:current-time status)
:max (:duration item)}]]) :max (:duration song)}]])
(defn playback-controls [is-playing?] (defn playback-controls [is-playing?]
[:div.field.has-addons [:div.field.has-addons
@ -25,22 +24,23 @@
[icon icon-glyph]]) [icon icon-glyph]])
buttons))]) buttons))])
(def logo-url "https://airsonic.github.io/airsonic-ui/assets/images/logo/airsonic-light-350x100.png") (def logo-url "https://airsonic.github.io/airsonic-ui/assets/images/logo/airsonic-light-350x100.png")
(defn bottom-bar [] (defn bottom-bar []
(let [currently-playing @(subscribe [::subs/currently-playing]) (let [current-song @(subscribe [:audio/current-song])
is-playing? @(subscribe [::subs/is-playing?])] playback-status @(subscribe [:audio/playback-status])
is-playing? @(subscribe [:audio/is-playing?])]
[:nav.navbar.is-fixed-bottom.playback-area [:nav.navbar.is-fixed-bottom.playback-area
[:div.navbar-brand [:div.navbar-brand
[:div.navbar-item [:div.navbar-item
[:img {:src logo-url}]]] [:img {:src logo-url}]]]
[:div.navbar-menu.is-active [:div.navbar-menu.is-active
(if currently-playing (if current-song
;; show song info ;; show song info
[:section.level.audio-interaction [:section.level.audio-interaction
[:div.level-left>article.media [:div.level-left>article.media
[:div.media-left [cover (:item currently-playing) 48]] [:div.media-left [cover current-song 48]]
[:div.media-content [current-song-info currently-playing]]] [:div.media-content [current-song-info current-song playback-status]]]
[:div.level-right [playback-controls is-playing?]]] [:div.level-right [playback-controls is-playing?]]]
;; not playing anything ;; not playing anything
[:p.idle-notification "Currently no song selected"])]])) [:p.idle-notification "Currently no song selected"])]]))

View file

@ -0,0 +1,40 @@
(ns airsonic-ui.audio-test
(:require [airsonic-ui.audio :as audio]
[airsonic-ui.fixtures :as fixtures]
[airsonic-ui.test-helpers :as helpers]
[cljs.test :refer [deftest testing is]]))
(enable-console-print!)
(defn- simulate-playlist [n ]
(repeatedly n #(hash-map :id (rand-int 9999)
:coverArt (rand-int 9999)
:year (+ 1900 (rand-int 118))
:artist (helpers/rand-str)
:aristId (rand-int 100000)
:title (helpers/rand-str)
:album (helpers/rand-str))))
(def fixture
{:audio {:current-song fixtures/song
:playlist (simulate-playlist 20)
:playback-status fixtures/playback-status}})
(deftest current-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 fixture)))))
(deftest playback-status
(letfn [(is-playing? [playback-status]
(audio/is-playing? playback-status [:audio/is-playing?]))]
(testing "Should be shown as not playing when the song is paused or has ended"
(is (not (is-playing? {:paused? true, :ended? false})))
(is (not (is-playing? {:paused? false, :ended? true}))))
(testing "Should be shown as playing when the song is not paused or finished"
(is (is-playing? {:paused? false, :ended? false})))))
#_(deftest current-playlist
(testing "Should show the complete playlist"))

View file

@ -93,7 +93,9 @@
(testing "Should redirect to the login screen" (testing "Should redirect to the login screen"
(is (dispatches? fx [:routes/do-navigation [::routes/login]]))) (is (dispatches? fx [:routes/do-navigation [::routes/login]])))
(testing "Should reset the app-db" (testing "Should reset the app-db"
(is (= db/default-db (:db fx))))) (is (= db/default-db (:db fx))))
(testing "Should stop currently playing songs"
(is (contains? fx :audio/stop))))
(testing "Should be able to keep a redirection parameter" (testing "Should be able to keep a redirection parameter"
(let [redirect [:route {:with-data #{1 2 3 4 5}}] (let [redirect [:route {:with-data #{1 2 3 4 5}}]
navigation-event (:dispatch (events/logout {} [:_ :redirect-to redirect]))] navigation-event (:dispatch (events/logout {} [:_ :redirect-to redirect]))]
@ -102,13 +104,15 @@
(is (= ::routes/login route-id)) (is (= ::routes/login route-id))
(is (contains? query :redirect)))))) (is (contains? query :redirect))))))
(defn- first-notification [fx]
(-> (get-in fx [:db :notifications]) vals first))
(deftest api-interaction (deftest api-interaction
(testing "Should show an error notification when airsonic responds with an error" (testing "Should show an error notification when airsonic responds with an error"
(let [fx (events/good-api-response {} [:_ (:error fixtures/responses)])] (let [fx (events/good-api-response {} [:_ (:error fixtures/responses)])
(is (= :error (-> fx :dispatch second)))))) ev (:dispatch fx)]
(is (= :notification/show (first ev)))
(is (= :error (second ev))))))
(defn- first-notification [fx]
(-> (get-in fx [:db :notifications]) vals first))
(deftest user-notifications (deftest user-notifications
(testing "Should be able to display a message with an assigned level" (testing "Should be able to display a message with an assigned level"

View file

@ -43,3 +43,11 @@
:contentType "audio/mpeg", :contentType "audio/mpeg",
:album "Reincarnations, Pt. 2 - The Remix Chapter 2009 - 2014", :album "Reincarnations, Pt. 2 - The Remix Chapter 2009 - 2014",
:track 14}) :track 14})
(def playback-status
{:ended? false
:loop? false
:muted? false
:paused? false
:current-src "https://londe.arnes.space/rest/stream?f=json&c=airsonic-ui-cljs&v=1.15.0&id=9574&u=arne&p=27h-%25bO%5B8-.ys%40SQ%7Bg%24-%5B5NZkX%7Dw%24NNwY%263DPATi%2CgaFoH%40e"
:current-time 3.477029})

View file

@ -6,3 +6,14 @@
[cofx ev] [cofx ev]
(let [all-events (conj (get cofx :dispatch-n []) (:dispatch cofx))] (let [all-events (conj (get cofx :dispatch-n []) (:dispatch cofx))]
(boolean (some #(= ev (if (vector? ev) % (first %))) all-events)))) (boolean (some #(= ev (if (vector? ev) % (first %))) all-events))))
(defn rand-str
"Generates a random string; ported from https://stackoverflow.com/a/27747377/2345852"
([] (rand-str 40))
([len]
(let [arr (js/Uint8Array. (/ len 2))]
(.. js/window -crypto (getRandomValues arr))
(.. js/Array
(from arr #(-> (str 0 (.toString % 16))
(.substr -2)))
(join "")))))

View file

@ -1,17 +1,24 @@
(ns airsonic-ui.test-helpers-test (ns airsonic-ui.test-helpers-test
(:require [cljs.test :refer [deftest testing is]] (:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.test-helpers :refer [dispatches?]])) [airsonic-ui.test-helpers :as h]))
(deftest dispatch-helper (deftest dispatch-helper
(testing "single dispatch" (testing "Should identify singly dispatched events"
(is (false? (dispatches? {} :foo))) (is (false? (h/dispatches? {} :foo)))
(is (true? (dispatches? {:dispatch [:foo 1 2 3]} :foo))) (is (true? (h/dispatches? {:dispatch [:foo 1 2 3]} :foo)))
(is (false? (dispatches? {:dispatch [:foo 1 2 3]} :bar))) (is (false? (h/dispatches? {:dispatch [:foo 1 2 3]} :bar)))
(is (true? (dispatches? {:dispatch [:foo 1 2 3]} [:foo 1 2 3]))) (is (true? (h/dispatches? {:dispatch [:foo 1 2 3]} [:foo 1 2 3])))
(is (false? (dispatches? {:dispatch [:foo 1 2 3]} [:bar 2 3])))) (is (false? (h/dispatches? {:dispatch [:foo 1 2 3]} [:bar 2 3]))))
(testing "multiple dispatch" (testing "Should identify an event along multiple dispatched events"
(is (false? (dispatches? {:dispatch-n [[:bar]]} :foo))) (is (false? (h/dispatches? {:dispatch-n [[:bar]]} :foo)))
(is (true? (dispatches? {:dispatch-n [[:foo 1 2 3]]} :foo))) (is (true? (h/dispatches? {:dispatch-n [[:foo 1 2 3]]} :foo)))
(is (false? (dispatches? {:dispatch-n [[:foo 1 2 3]]} :bar))) (is (false? (h/dispatches? {:dispatch-n [[:foo 1 2 3]]} :bar)))
(is (dispatches? {:dispatch-n [[:foo 1 2 3]]} [:foo 1 2 3])) (is (h/dispatches? {:dispatch-n [[:foo 1 2 3]]} [:foo 1 2 3]))
(is (false? (dispatches? {:dispatch-n [[:foo 1 2 3]]} [:bar 2 3]))))) (is (false? (h/dispatches? {:dispatch-n [[:foo 1 2 3]]} [:bar 2 3])))))
(deftest rand-str
(testing "Generates strings"
(is (string? (h/rand-str)))
(is (string? (h/rand-str 20))))
(testing "Should respect the length for even lengths"
(is (= 124 (count (h/rand-str 124))))))