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

Add keyboard shortcuts (#43)

* Use rf instead of re-frame

* Add bulma modal component

* Add option to toggle a modal

* Add rudimentary keyboard shortcuts; closes #41
This commit is contained in:
Arne Schlüter 2019-01-30 18:35:08 +01:00 committed by GitHub
commit 149fd07566
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 291 additions and 101 deletions

View file

@ -2,7 +2,7 @@
"This namespace contains some JS interop code to interact with an audio player
and receive information about the current playback status so we can use it in
our re-frame app."
(:require [re-frame.core :as re-frame]
(:require [re-frame.core :as rf]
[airsonic-ui.audio.playlist :as playlist]
[goog.functions :refer [throttle]]))
@ -29,13 +29,13 @@
(defn attach-listeners! [el]
(let [emit-audio-update (throttle #(re-frame/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"]]
(.addEventListener el event emit-audio-update))))
;; effects to be fired from event handlers
(re-frame/reg-fx
(rf/reg-fx
:audio/play
(fn [stream-url]
(when-not @audio
@ -45,19 +45,19 @@
(set! (.-src @audio) stream-url)
(.play @audio)))
(re-frame/reg-fx
(rf/reg-fx
:audio/pause
(fn [_]
(some-> @audio .pause)))
(re-frame/reg-fx
(rf/reg-fx
:audio/stop
(fn [_]
(when-let [audio @audio]
(.pause audio)
(set! (.-currentTime audio) 0))))
(re-frame/reg-fx
(rf/reg-fx
:audio/toggle-play-pause
(fn [_]
(if-let [a @audio]
@ -65,7 +65,7 @@
(.play a)
(.pause a)))))
(re-frame/reg-fx
(rf/reg-fx
:audio/seek
(fn [[percentage duration]]
(set! (. @audio -currentTime)
@ -78,16 +78,16 @@
[db _]
(:audio db))
(re-frame/reg-sub :audio/summary summary)
(rf/reg-sub :audio/summary summary)
(defn playlist
"Lists the complete playlist"
[summary _]
(:playlist summary))
(re-frame/reg-sub
(rf/reg-sub
:audio/playlist
(fn [_ _] (re-frame/subscribe [:audio/summary]))
(fn [_ _] (rf/subscribe [:audio/summary]))
playlist)
(defn current-song
@ -96,9 +96,9 @@
[playlist _]
(playlist/peek playlist))
(re-frame/reg-sub
(rf/reg-sub
:audio/current-song
(fn [_ _] (re-frame/subscribe [:audio/playlist]))
(fn [_ _] (rf/subscribe [:audio/playlist]))
current-song)
(defn playback-status
@ -106,9 +106,9 @@
[summary _]
(:playback-status summary))
(re-frame/reg-sub
(rf/reg-sub
:audio/playback-status
(fn [_ _] (re-frame/subscribe [:audio/summary]))
(fn [_ _] (rf/subscribe [:audio/summary]))
playback-status)
(defn is-playing?
@ -117,7 +117,7 @@
(and (not (:paused? playback-status))
(not (:ended? playback-status))))
(re-frame/reg-sub
(rf/reg-sub
:audio/is-playing?
(fn [_ _] (re-frame/subscribe [:audio/playback-status]))
(fn [_ _] (rf/subscribe [:audio/playback-status]))
is-playing?)

View file

@ -1,9 +1,9 @@
(ns airsonic-ui.components.audio-player.events
(:require [re-frame.core :as re-frame]
(:require [re-frame.core :as rf]
[airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.api.helpers :as api]))
(re-frame/reg-event-fx
(rf/reg-event-fx
; sets up the db, starts to play a song and adds the rest to a playlist
:audio-player/play-all
(fn [{:keys [db]} [_ songs start-idx]]
@ -12,17 +12,17 @@
{:audio/play (api/stream-url (:credentials db) (playlist/peek playlist))
:db (assoc-in db [:audio :playlist] playlist)})))
(re-frame/reg-event-db
(rf/reg-event-db
:audio-player/set-playback-mode
(fn [db [_ playback-mode]]
(update-in db [:audio :playlist] #(playlist/set-playback-mode % playback-mode))))
(re-frame/reg-event-db
(rf/reg-event-db
:audio-player/set-repeat-mode
(fn [db [_ repeat-mode]]
(update-in db [:audio :playlist] #(playlist/set-repeat-mode % repeat-mode))))
(re-frame/reg-event-fx
(rf/reg-event-fx
:audio-player/next-song
(fn [{:keys [db]} _]
(let [db (update-in db [:audio :playlist] playlist/next-song)
@ -30,7 +30,7 @@
{:db db
:audio/play (api/stream-url (:credentials db) next)})))
(re-frame/reg-event-fx
(rf/reg-event-fx
:audio-player/previous-song
(fn [{:keys [db]} _]
(let [db (update-in db [:audio :playlist] playlist/previous-song)
@ -38,17 +38,17 @@
{:db db
:audio/play (api/stream-url (:credentials db) prev)})))
(re-frame/reg-event-db
(rf/reg-event-db
:audio-player/enqueue-next
(fn [db [_ song]]
(update-in db [:audio :playlist] #(playlist/enqueue-next % song))))
(re-frame/reg-event-db
(rf/reg-event-db
:audio-player/enqueue-last
(fn [db [_ song]]
(update-in db [:audio :playlist] #(playlist/enqueue-last % song))))
(re-frame/reg-event-fx
(rf/reg-event-fx
:audio-player/toggle-play-pause
(fn [_ _]
{:audio/toggle-play-pause nil}))
@ -60,9 +60,9 @@
(cond-> {:db (assoc-in db [:audio :playback-status] status)}
(:ended? status) (assoc :dispatch [:audio-player/next-song])))
(re-frame/reg-event-fx :audio/update audio-update)
(rf/reg-event-fx :audio/update audio-update)
(re-frame/reg-event-fx
(rf/reg-event-fx
:audio-player/seek
(fn [{:keys [db]} [_ percentage]]
(let [duration (:duration (playlist/peek (get-in db [:audio :playlist])))]

View file

@ -0,0 +1,19 @@
(ns airsonic-ui.components.keyboard-shortcuts.config)
;; this keymap has the following structure:
;; [[readable-key readable-description event-vector event-keys]
;; ...]
(def keymap
[["Space" "Toggle play / pause"
[:audio-player/toggle-play-pause]
[{:keyCode 32}]]
["←" "Previous song"
[:audio-player/previous-song]
[{:keyCode 37}]]
["→" "Next song"
[:audio-player/next-song]
[{:keyCode 39}]]
["?" "Show / hide keyboard shortcut help"
[:bulma.modal.events/toggle :keyboard-shortcuts-help]
[{:keyCode 63}]]])

View file

@ -0,0 +1,13 @@
(ns airsonic-ui.components.keyboard-shortcuts.events
(:require [re-frame.core :as rf]
[re-pressed.core :as rp]
[airsonic-ui.components.keyboard-shortcuts.config :as config]))
(rf/reg-event-fx
::init-shortcuts
(fn []
(let [event-keys (map (juxt #(nth % 2) #(nth % 3)) config/keymap)
prevent-default-keys (mapcat last event-keys)]
{:dispatch-n [[::rp/add-keyboard-event-listener "keydown"]
[::rp/set-keydown-rules {:event-keys event-keys
:prevent-default-keys prevent-default-keys}]]})))

View file

@ -0,0 +1,12 @@
(ns airsonic-ui.components.keyboard-shortcuts.views
(:require [bulma.modal.views :as bulma]
[airsonic-ui.components.keyboard-shortcuts.config :as config]))
(defn help-modal []
[bulma/modal-card {:title "Keyboard Shortcuts"
:modal-id :keyboard-shortcuts-help}
[:table.table.is-hoverable.is-fullwidth
[:thead [:tr [:th "Key"] [:th "Function"]]]
[:tbody
(for [[idx [k desc]] (map-indexed vector config/keymap)]
^{:key idx} [:tr [:td>code k] [:td desc]])]]])

View file

@ -1,5 +1,5 @@
(ns airsonic-ui.components.library.subs
(:require [re-frame.core :as re-frame]
(:require [re-frame.core :as rf]
[airsonic-ui.config :as conf]))
;; first some helper functions to make the structure a bit clearer
@ -34,7 +34,7 @@
(map (fn [[k v]] [(inc k) v]))
(into (sorted-map))))
(re-frame/reg-sub
(rf/reg-sub
:library/paginated
:<- [:api/responses-for-endpoint "getAlbumList2"]
paginated-library)

View file

@ -1,6 +1,6 @@
(ns airsonic-ui.core
(:require [reagent.core :as reagent]
[re-frame.core :as re-frame]
[re-frame.core :as rf]
;; 3rd party effects / coeffects
[day8.re-frame.http-fx]
[akiroz.re-frame.storage :as storage]
@ -11,6 +11,7 @@
[airsonic-ui.api.events]
[airsonic-ui.api.subs]
[airsonic-ui.components.audio-player.events]
[airsonic-ui.components.keyboard-shortcuts.events :as keyboard]
[airsonic-ui.components.library.subs]
[airsonic-ui.components.search.events]
[airsonic-ui.components.search.subs]
@ -24,12 +25,12 @@
(println "dev mode")))
(defn mount-root []
(re-frame/clear-subscription-cache!)
(rf/clear-subscription-cache!)
(reagent/render [views/main-panel] (.getElementById js/document "app")))
(defn ^:export init []
(storage/reg-co-fx! :airsonic-ui {:fx :store
:cofx :store})
(re-frame/dispatch-sync [::events/initialize-app])
(storage/reg-co-fx! :airsonic-ui {:fx :store, :cofx :store})
(rf/dispatch-sync [::events/initialize-app])
(rf/dispatch [::keyboard/init-shortcuts])
(dev-setup)
(mount-root))

View file

@ -1,11 +1,11 @@
(ns airsonic-ui.events
(:require [re-frame.core :as re-frame]
(:require [re-frame.core :as rf]
[ajax.core :as ajax]
[airsonic-ui.routes :as routes]
[airsonic-ui.db :as db]
[airsonic-ui.api.helpers :as api]))
(re-frame/reg-fx
(rf/reg-fx
;; a simple effect to keep println statements out of our event handlers
:log
(fn [params]
@ -31,9 +31,9 @@
(assoc effects :dispatch [:credentials/verify credentials])
effects)))
(re-frame/reg-event-fx
(rf/reg-event-fx
::initialize-app
[(re-frame/inject-cofx :store)]
[(rf/inject-cofx :store)]
initialize-app)
(defn verify-credentials
@ -44,7 +44,7 @@
(if (every? string? ((juxt :u :p :server) credentials))
{:dispatch [:credentials/send-authentication-request credentials]}))
(re-frame/reg-event-fx :credentials/verify verify-credentials)
(rf/reg-event-fx :credentials/verify verify-credentials)
;; ---
;; auth logic
@ -57,7 +57,7 @@
{:db (assoc db :credentials credentials)
:dispatch [:credentials/send-authentication-request credentials]}))
(re-frame/reg-event-fx :credentials/user-login user-login)
(rf/reg-event-fx :credentials/user-login user-login)
(defn authentication-request
"Tries to authenticate a user by requesting info about the given user, saving
@ -69,7 +69,7 @@
:on-success [:credentials/authentication-response credentials]
:on-failure [:api/failed-response]}}) ; <- we don't need endpoint and params here because the response is not cached
(re-frame/reg-event-fx :credentials/send-authentication-request authentication-request)
(rf/reg-event-fx :credentials/send-authentication-request authentication-request)
(defn authentication-response
"Since we don't get real status codes, we have to look into the server's
@ -79,7 +79,7 @@
[:credentials/authentication-failure response]
[:credentials/authentication-success credentials response])})
(re-frame/reg-event-fx :credentials/authentication-response authentication-response)
(rf/reg-event-fx :credentials/authentication-response authentication-response)
(defn authentication-failure
"Removes all stored credentials and displays potential api errors to the user"
@ -88,7 +88,7 @@
:store (dissoc store :credentials)
:db (dissoc db :credentials)})
(re-frame/reg-event-fx :credentials/authentication-failure authentication-failure)
(rf/reg-event-fx :credentials/authentication-failure authentication-failure)
(defn authentication-success
"Gets called after the server indicates that the credentials entered by a user
@ -99,7 +99,7 @@
(assoc :user (api/unwrap-response auth-response)))
:dispatch [::logged-in]})
(re-frame/reg-event-fx :credentials/authentication-success authentication-success)
(rf/reg-event-fx :credentials/authentication-success authentication-success)
(defn logged-in
[cofx _]
@ -107,9 +107,9 @@
[::routes/library])]
{:dispatch [:routes/do-navigation redirect]}))
(re-frame/reg-event-fx
(rf/reg-event-fx
::logged-in
[(re-frame/inject-cofx :routes/from-query-param :redirect)]
[(rf/inject-cofx :routes/from-query-param :redirect)]
logged-in)
(defn logout
@ -123,21 +123,21 @@
:db db/default-db
:audio/stop nil}))
(re-frame/reg-event-fx ::logout logout)
(rf/reg-event-fx ::logout logout)
;; ---
;; routing
;; ---
(re-frame/reg-event-fx
(rf/reg-event-fx
:routes/did-navigate
(fn [{:keys [db]} [_ route params query]]
{:db (assoc db :routes/current-route [route params query])
:dispatch-n (routes/route-events route params query)}))
(re-frame/reg-event-fx
(rf/reg-event-fx
:routes/unauthorized
[(re-frame/inject-cofx :routes/current-route)]
[(rf/inject-cofx :routes/current-route)]
(fn [{:routes/keys [current-route]} _]
{:dispatch [::logout :redirect-to current-route]}))
@ -161,10 +161,10 @@
:dispatch-later [{:ms (get notification-duration level)
:dispatch [:notification/hide id]}]}))
(re-frame/reg-event-fx :notification/show show-notification)
(rf/reg-event-fx :notification/show show-notification)
(defn hide-notification
[db [_ notification-id]]
(update db :notifications dissoc notification-id))
(re-frame/reg-event-db :notification/hide hide-notification)
(rf/reg-event-db :notification/hide hide-notification)

View file

@ -1,7 +1,7 @@
(ns airsonic-ui.routes
(:require [bide.core :as r]
[cljs.reader :refer [read-string]]
[re-frame.core :as re-frame]
[re-frame.core :as rf]
[airsonic-ui.config :as conf]))
(def default-route ::login)
@ -96,15 +96,15 @@
;; subscription returning the matched route for the current hashbang
(re-frame/reg-sub :routes/current-route (fn [db _] (:routes/current-route db)))
(rf/reg-sub :routes/current-route (fn [db _] (:routes/current-route db)))
;; NOTE: There is some duplication here. The route events are provided as a
;; subscription but they are also invoked directly in events.cljs. It didn't
;; seem to justify pulling in a whole library and we need it in our top most view
(re-frame/reg-sub
(rf/reg-sub
:routes/events-for-current-route
(fn [db _] (re-frame/subscribe [:routes/current-route]))
(fn [db _] (rf/subscribe [:routes/current-route]))
(fn [current-route _] (apply route-events current-route)))
;; these are helper effects we can use to navigate; the first two manage an atom
@ -133,7 +133,7 @@
(apply r/navigate! router route)
(dissoc context :event)))))
(re-frame/reg-event-fx :routes/do-navigation do-navigation (fn [& _] nil))
(rf/reg-event-fx :routes/do-navigation do-navigation (fn [& _] nil))
(defn can-access? [route]
(or (not (protected-routes route))
@ -143,8 +143,8 @@
[route-id params query]
#_(println "calling on-navigate with" route credentials')
(if (can-access? route-id)
(re-frame/dispatch [:routes/did-navigate route-id params query])
(re-frame/dispatch [:routes/unauthorized route-id params query])))
(rf/dispatch [:routes/did-navigate route-id params query])
(rf/dispatch [:routes/unauthorized route-id params query])))
(defn encode-route
"Takes a parsed route and returns a representation that's suitable for
@ -163,13 +163,13 @@
(r/match router (subs (.. js/window -location -hash) 1)))
;; add the current route to our coeffect map
(re-frame/reg-cofx
(rf/reg-cofx
:routes/current-route
(fn [coeffects _]
(assoc coeffects :routes/current-route (current-route))))
;; add route into from a URL parameter to our coeffect map
(re-frame/reg-cofx
(rf/reg-cofx
:routes/from-query-param
(fn [coeffects param]
;; this allows us to encode a complete route in a url fragment; useful for
@ -184,5 +184,5 @@
:on-navigate on-navigate}))
([_] (start-routing!))) ;; <- 1-arity is for the re-frame effect exposed below
(re-frame/reg-fx
(rf/reg-fx
:routes/start-routing start-routing!)

View file

@ -19,6 +19,7 @@
[airsonic-ui.components.bangpow.views :refer [not-found]]
[airsonic-ui.components.collection.views :as collection]
[airsonic-ui.components.current-queue.views :refer [current-queue]]
[airsonic-ui.components.keyboard-shortcuts.views :as keyboard]
[airsonic-ui.components.library.views :as library]
[airsonic-ui.components.podcast.views :as podcast]
[airsonic-ui.components.search.views :as search]))
@ -130,6 +131,7 @@
[route-id :as route] @(subscribe [:routes/current-route])]
[(add-classes :div route-id)
[notification-list notifications]
[keyboard/help-modal]
(if is-booting?
[:div.app-loading>div.loader]
[:div

View file

@ -0,0 +1,20 @@
(ns bulma.modal.events
(:require [re-frame.core :as rf]))
(defn show-modal [db [_ modal-id]]
(assoc-in db [:bulma :visible-modal] modal-id))
(rf/reg-event-db ::show show-modal)
(defn hide-modal [db _]
(update db :bulma dissoc :visible-modal))
(rf/reg-event-db ::hide hide-modal)
(defn toggle-modal [db [_ modal-id]]
(let [visible-modal (get-in db [:bulma :visible-modal])]
(if (= visible-modal modal-id)
(hide-modal db [::hide])
(show-modal db [::show modal-id]))))
(rf/reg-event-db ::toggle toggle-modal)

View file

@ -0,0 +1,19 @@
(ns bulma.modal.subs
(:require [re-frame.core :as rf]))
(defn visible-modal
"Gives us the ID of the currently visible modal"
[db _]
(get-in db [:bulma :visible-modal]))
(rf/reg-sub ::visible-modal visible-modal)
(defn visible?
"Predicate to check the visibility of a single modal"
[visible-modal [_ modal-id]]
(= visible-modal modal-id))
(rf/reg-sub
::visible?
:<- [::visible-modal]
visible?)

View file

@ -0,0 +1,47 @@
(ns bulma.modal.views
(:require [re-frame.core :as rf]
[bulma.modal.events :as ev]
[bulma.modal.subs :as sub]))
(defn hide-modal [_]
(rf/dispatch [::ev/hide]))
(defn modal
"Generic modal; arguments:
options:
{:has-hide-button? boolean
:modal-id :some-identifier}
& children"
[{:keys [has-hide-button? modal-id]} & children]
{:pre [(some? modal-id)]}
(let [visible? @(rf/subscribe [::sub/visible? modal-id])
modal-tag (if visible? :div.modal.is-active :div.modal)]
[modal-tag
[:div.modal-background {:on-click hide-modal}]
(into [:div.modal-content] children)
(when has-hide-button?
[:button.modal-hide.is-large {:aria-label "hide"
:on-click hide-modal}])]))
(defn modal-card
"A card modal that renders content on a background. Arguments:
options:
{:title \"Title of the card\"
:foot [[:div \"An array of hiccup elements\"]]
:modal-id :some-identifier}
& children"
[{:keys [title foot modal-id]} & children]
[modal {:has-hide-button? (not (some? title))
:modal-id modal-id}
(when title
[:div.modal-card-head
[:p.modal-card-title title]
[:button.delete {:aria-label "hide"
:on-click hide-modal}]])
(into [:section.modal-card-body] children)
(when foot
(into [:div.modal-card-foot] foot))])