mirror of
https://github.com/heyarne/airsonic-ui.git
synced 2026-05-06 18:33:38 +02:00
Move navigation to the top
Squashed commit of the following:
commit b03c1ea7ed0d2fbd56f56f3273e694abc5454101
Author: Arne Schlüter <arne@schlueter.is>
Date: Wed Aug 29 11:38:32 2018 +0200
Fix bug where dropdown menus behind notifications could not be hovered
commit f4d3cd3dad89d0de84f131dbef7268422b26aa35
Author: Arne Schlüter <arne@schlueter.is>
Date: Wed Aug 29 11:16:41 2018 +0200
Move navigation to top
commit 564d972291aebb382d1ca560a21fad332d70cd0c
Author: Arne Schlüter <arne@schlueter.is>
Date: Wed Aug 29 10:23:17 2018 +0200
Move audio player into its own component
commit 382e9e88021db1506efc5fb78935b7846b8257db
Author: Arne Schlüter <arne@schlueter.is>
Date: Wed Aug 29 10:11:14 2018 +0200
Remove link to last.fm in bio
commit f248c2999ca88eeb82769d7491b1e786ee4a7c9d
Author: Arne Schlüter <arne@schlueter.is>
Date: Wed Aug 29 10:01:10 2018 +0200
Add links to external services & hero headers to album and artist pages
This commit is contained in:
parent
5c66a1d5bf
commit
29ea86479c
17 changed files with 223 additions and 179 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html class="has-navbar-fixed-top has-navbar-fixed-bottom">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
|
||||||
38
src/cljs/airsonic_ui/components/artist/views.cljs
Normal file
38
src/cljs/airsonic_ui/components/artist/views.cljs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
(ns airsonic-ui.components.artist.views
|
||||||
|
(:require [airsonic-ui.views.album :as album]
|
||||||
|
[clojure.string :as str]))
|
||||||
|
|
||||||
|
(defn link-button [attrs children]
|
||||||
|
[:p.control>a.button.is-small (merge attrs {:target "_blank"}) children])
|
||||||
|
|
||||||
|
(defn lastfm-bio
|
||||||
|
"Displays the last.fm biography without the 'Read more on Last.fm' link"
|
||||||
|
[artist-info]
|
||||||
|
(when (:biography artist-info)
|
||||||
|
(let [biography (str/replace (:biography artist-info) #"<a .*?>$" "")]
|
||||||
|
[:p {:dangerouslySetInnerHTML {:__html biography}}])))
|
||||||
|
|
||||||
|
(defn lastfm-link [artist-info]
|
||||||
|
[link-button {:href (:lastFmUrl artist-info)} "See on last.fm"])
|
||||||
|
|
||||||
|
(defn musicbrainz-link [artist-info]
|
||||||
|
(let [href (str "https://musicbrainz.org/artist/" (:musicBrainzId artist-info))]
|
||||||
|
[link-button {:href href} "See on musicbrainz"]))
|
||||||
|
|
||||||
|
(defn detail
|
||||||
|
"Creates a nice artist page displaying the artist's name, bio (if available and
|
||||||
|
listing) their albums."
|
||||||
|
[{:keys [artist artist-info]}]
|
||||||
|
[:div
|
||||||
|
[:section.hero>div.hero-body
|
||||||
|
[:div.container
|
||||||
|
[:h2.title (:name artist)]
|
||||||
|
[:div.content
|
||||||
|
[lastfm-bio artist-info]
|
||||||
|
(when-not (empty? (select-keys artist-info [:lastFmUrl :musicBrainzId]))
|
||||||
|
[:div.field.is-grouped
|
||||||
|
(when (:lastFmUrl artist-info)
|
||||||
|
[lastfm-link artist-info])
|
||||||
|
(when (:musicBrainzId artist-info)
|
||||||
|
[musicbrainz-link artist-info])])]]]
|
||||||
|
[:section.section>div.container [album/listing (:album artist)]]])
|
||||||
69
src/cljs/airsonic_ui/components/audio_player/events.cljs
Normal file
69
src/cljs/airsonic_ui/components/audio_player/events.cljs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
(ns airsonic-ui.components.audio-player.events
|
||||||
|
(:require [re-frame.core :as re-frame]
|
||||||
|
[airsonic-ui.audio.playlist :as playlist]
|
||||||
|
[airsonic-ui.api.helpers :as api]))
|
||||||
|
|
||||||
|
(defn- song-url [db song]
|
||||||
|
(let [creds (:credentials db)]
|
||||||
|
(api/song-url (:server creds) (select-keys creds [:u :p]) song)))
|
||||||
|
|
||||||
|
(re-frame/reg-event-fx
|
||||||
|
; sets up the db, starts to play a song and adds the rest to a playlist
|
||||||
|
:audio-player/play-all
|
||||||
|
(fn [{:keys [db]} [_ songs start-idx]]
|
||||||
|
(let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all)
|
||||||
|
(playlist/set-current-song start-idx))]
|
||||||
|
{:audio/play (song-url db (playlist/peek playlist))
|
||||||
|
:db (assoc-in db [:audio :playlist] playlist)})))
|
||||||
|
|
||||||
|
;; FIXME: :audio/play might not get the right argument here
|
||||||
|
|
||||||
|
(re-frame/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
|
||||||
|
: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
|
||||||
|
:audio-player/next-song
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
(let [db (update-in db [:audio :playlist] playlist/next-song)
|
||||||
|
next (playlist/peek (get-in db [:audio :playlist]))]
|
||||||
|
{:db db
|
||||||
|
:audio/play (song-url db next)})))
|
||||||
|
|
||||||
|
(re-frame/reg-event-fx
|
||||||
|
:audio-player/previous-song
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
(let [db (update-in db [:audio :playlist] playlist/previous-song)
|
||||||
|
prev (playlist/peek (get-in db [:audio :playlist]))]
|
||||||
|
{:db db
|
||||||
|
:audio/play (song-url db prev)})))
|
||||||
|
|
||||||
|
(re-frame/reg-event-db
|
||||||
|
:audio-player/enqueue-next
|
||||||
|
(fn [db [_ song]]
|
||||||
|
(update-in db [:audio :playlist] #(playlist/enqueue-next % song))))
|
||||||
|
|
||||||
|
(re-frame/reg-event-db
|
||||||
|
:audio-player/enqueue-last
|
||||||
|
(fn [db [_ song]]
|
||||||
|
(update-in db [:audio :playlist] #(playlist/enqueue-last % song))))
|
||||||
|
|
||||||
|
(re-frame/reg-event-fx
|
||||||
|
:audio-player/toggle-play-pause
|
||||||
|
(fn [_ _]
|
||||||
|
{:audio/toggle-play-pause nil}))
|
||||||
|
|
||||||
|
(defn audio-update
|
||||||
|
"Reacts to audio events fired by the HTML5 audio player and plays the next
|
||||||
|
track if necessary."
|
||||||
|
[{:keys [db]} [_ status]]
|
||||||
|
(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)
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
(ns airsonic-ui.views.audio-player
|
(ns airsonic-ui.components.audio-player.views
|
||||||
(:require [re-frame.core :refer [subscribe]]
|
(:require [re-frame.core :refer [subscribe]]
|
||||||
[airsonic-ui.helpers :refer [add-classes dispatch]]
|
[airsonic-ui.helpers :refer [add-classes dispatch]]
|
||||||
[airsonic-ui.events :as events]
|
|
||||||
[airsonic-ui.views.cover :refer [cover]]
|
[airsonic-ui.views.cover :refer [cover]]
|
||||||
[airsonic-ui.views.icon :refer [icon]]))
|
[airsonic-ui.views.icon :refer [icon]]))
|
||||||
|
|
||||||
|
|
@ -16,9 +15,9 @@
|
||||||
|
|
||||||
(defn song-controls [is-playing?]
|
(defn song-controls [is-playing?]
|
||||||
[:div.field.has-addons
|
[:div.field.has-addons
|
||||||
(let [buttons [[:media-step-backward ::events/previous-song]
|
(let [buttons [[:media-step-backward :audio-player/previous-song]
|
||||||
[(if is-playing? :media-pause :media-play) ::events/toggle-play-pause]
|
[(if is-playing? :media-pause :media-play) :audio-player/toggle-play-pause]
|
||||||
[:media-step-forward ::events/next-song]]]
|
[:media-step-forward :audio-player/next-song]]]
|
||||||
(map (fn [[icon-glyph event]]
|
(map (fn [[icon-glyph event]]
|
||||||
^{:key icon-glyph} [:p.control>button.button.is-light
|
^{:key icon-glyph} [:p.control>button.button.is-light
|
||||||
{:on-click (dispatch [event])}
|
{:on-click (dispatch [event])}
|
||||||
|
|
@ -26,14 +25,14 @@
|
||||||
buttons))])
|
buttons))])
|
||||||
|
|
||||||
(defn- toggle-shuffle [playback-mode]
|
(defn- toggle-shuffle [playback-mode]
|
||||||
(dispatch [::events/set-playback-mode (if (= playback-mode :shuffled)
|
(dispatch [:audio-player/set-playback-mode (if (= playback-mode :shuffled)
|
||||||
:linear :shuffled)]))
|
:linear :shuffled)]))
|
||||||
|
|
||||||
(defn- toggle-repeat-mode [current-mode]
|
(defn- toggle-repeat-mode [current-mode]
|
||||||
(let [modes (cycle '(:repeat-none :repeat-all :repeat-single))
|
(let [modes (cycle '(:repeat-none :repeat-all :repeat-single))
|
||||||
next-mode (->> (drop-while (partial not= current-mode) modes)
|
next-mode (->> (drop-while (partial not= current-mode) modes)
|
||||||
(second))]
|
(second))]
|
||||||
(dispatch [::events/set-repeat-mode next-mode])))
|
(dispatch [:audio-player/set-repeat-mode next-mode])))
|
||||||
|
|
||||||
(defn playback-mode-controls [playlist]
|
(defn playback-mode-controls [playlist]
|
||||||
(let [{:keys [repeat-mode playback-mode]} playlist
|
(let [{:keys [repeat-mode playback-mode]} playlist
|
||||||
|
|
@ -47,17 +46,12 @@
|
||||||
^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode)} [icon :random]]
|
^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode)} [icon :random]]
|
||||||
^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode)} [icon :loop]]]))
|
^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode)} [icon :loop]]]))
|
||||||
|
|
||||||
(def logo-url "./img/airsonic-light-350x100.png")
|
|
||||||
|
|
||||||
(defn audio-player []
|
(defn audio-player []
|
||||||
(let [current-song @(subscribe [:audio/current-song])
|
(let [current-song @(subscribe [:audio/current-song])
|
||||||
playlist @(subscribe [:audio/playlist])
|
playlist @(subscribe [:audio/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.navbar.is-fixed-bottom.audio-player
|
[:nav.navbar.is-fixed-bottom.audio-player
|
||||||
[:div.navbar-brand
|
|
||||||
[:div.navbar-item
|
|
||||||
[:img {:src logo-url}]]]
|
|
||||||
[:div.navbar-menu.is-active
|
[:div.navbar-menu.is-active
|
||||||
(if current-song
|
(if current-song
|
||||||
;; show song info
|
;; show song info
|
||||||
|
|
@ -68,4 +62,4 @@
|
||||||
[:div.level-right [song-controls is-playing?]]
|
[:div.level-right [song-controls is-playing?]]
|
||||||
[:div.level-right [playback-mode-controls playlist]]]
|
[:div.level-right [playback-mode-controls playlist]]]
|
||||||
;; not playing anything
|
;; not playing anything
|
||||||
[:p.idle-notification "Currently no song selected"])]]))
|
[:p.has-text-light.navbar-item.idle-notification "Select a song to start playing"])]]))
|
||||||
12
src/cljs/airsonic_ui/components/collection/views.cljs
Normal file
12
src/cljs/airsonic_ui/components/collection/views.cljs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
(ns airsonic-ui.components.collection.views
|
||||||
|
(:require [airsonic-ui.views.song :as song]))
|
||||||
|
|
||||||
|
(defn detail
|
||||||
|
"Lists all songs in an album"
|
||||||
|
[{:keys [album]}]
|
||||||
|
[:div
|
||||||
|
[:section.hero>div.hero-body
|
||||||
|
[:div.container
|
||||||
|
[:h2.title (:name album)]
|
||||||
|
[:h3.subtitle (:artist album)]]]
|
||||||
|
[:section.section>div.container [song/listing (:song album)]]])
|
||||||
7
src/cljs/airsonic_ui/components/debug/views.cljs
Normal file
7
src/cljs/airsonic_ui/components/debug/views.cljs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
(ns airsonic-ui.components.debug.views
|
||||||
|
(:require [clojure.pprint :refer [pprint]]))
|
||||||
|
|
||||||
|
(defn debug
|
||||||
|
"Returns a nicely formatted debug view of any given data structure"
|
||||||
|
[data]
|
||||||
|
[:pre (with-out-str (pprint data))])
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
page as its argument. When `max-pages` is `nil` an infinite pagination
|
page as its argument. When `max-pages` is `nil` an infinite pagination
|
||||||
will be rendered."
|
will be rendered."
|
||||||
[{:keys [url-fn max-pages current-page]}]
|
[{:keys [url-fn max-pages current-page]}]
|
||||||
[:nav.pagination.is-centered {:role "pagination", :aria-label "pagination"}
|
[:nav.pagination {:role "pagination", :aria-label "pagination"}
|
||||||
[:a.pagination-previous (if (> current-page 1)
|
[:a.pagination-previous (if (> current-page 1)
|
||||||
{:href (url-fn (dec current-page))}
|
{:href (url-fn (dec current-page))}
|
||||||
{:disabled true}) "Previous page"]
|
{:disabled true}) "Previous page"]
|
||||||
|
|
@ -35,25 +35,26 @@
|
||||||
current-page? (add-classes :is-current))
|
current-page? (add-classes :is-current))
|
||||||
(cond-> {:href (url-fn page), :aria-label (str "Page " page)}
|
(cond-> {:href (url-fn page), :aria-label (str "Page " page)}
|
||||||
(= page current-page) (assoc :aria-current "page")) page]))
|
(= page current-page) (assoc :aria-current "page")) page]))
|
||||||
(when (or (not max-pages) (< max-pages (- max-pages 3)))
|
(when (or (not max-pages) (< current-page (- max-pages 2)))
|
||||||
^{:key "ellipsis-after"} [:li>span.pagination-ellipsis "…"])]])
|
^{:key "ellipsis-after"} [:li>span.pagination-ellipsis "…"])]])
|
||||||
|
|
||||||
(defn main [route {:keys [scan-status album-list]}]
|
(defn main [route {:keys [scan-status album-list]}]
|
||||||
(let [[_ {:keys [criteria]} {:keys [page] :or {page 1}}] route
|
(let [[_ {:keys [criteria]} {:keys [page] :or {page 1}}] route
|
||||||
|
tab-items [[[::routes/library {:criteria "recent"} nil] "Recently played"]
|
||||||
|
[[::routes/library {:criteria "newest"} nil] "Newest additions"]
|
||||||
|
[[::routes/library {:criteria "starred"} nil] "Starred"]]
|
||||||
pagination [pagination {:current-page (int page)
|
pagination [pagination {:current-page (int page)
|
||||||
:max-pages 5
|
:max-pages 5
|
||||||
:url-fn #(url-for ::routes/library {:criteria criteria} {:page %})}]]
|
:url-fn #(url-for ::routes/library {:criteria criteria} {:page %})}]]
|
||||||
[:div
|
[:div
|
||||||
[:h2.title "Your library"]
|
[:section.hero.is-small>div.hero-body>div.container
|
||||||
(if (:count scan-status)
|
[:h2.title "Your library"]
|
||||||
[:p.subtitle.is-5.has-text-grey "Containing " [:strong (:count scan-status)] " items"]
|
(if (:count scan-status)
|
||||||
(when (:scanning scan-status)
|
[:p.subtitle.is-5.has-text-grey "Containing " [:strong (:count scan-status)] " items"]
|
||||||
[:p.subtitle.is-5.has-text-grey "Scanning…"]))
|
(when (:scanning scan-status)
|
||||||
(let [items [[[::routes/library {:criteria "recent"} nil] "Recently played"]
|
[:p.subtitle.is-5.has-text-grey "Scanning…"]))]
|
||||||
[[::routes/library {:criteria "newest"} nil] "Newest additions"]
|
[:section.section>div.container
|
||||||
[[::routes/library {:criteria "starred"} nil] "Starred"]]]
|
[tabs {:items tab-items :active-item {:criteria criteria}}]
|
||||||
[tabs {:items items :active-item {:criteria criteria}}])
|
pagination
|
||||||
pagination
|
[:section.section [album/listing (:album album-list)]]
|
||||||
[:section.section
|
pagination]]))
|
||||||
[album/listing (:album album-list)]]
|
|
||||||
pagination]))
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
(ns airsonic-ui.components.search.views
|
(ns airsonic-ui.components.search.views
|
||||||
(:require [clojure.pprint :refer [pprint]]
|
(:require [re-frame.core :refer [dispatch subscribe]]
|
||||||
[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.views.song :as song]
|
||||||
|
|
@ -47,7 +46,7 @@
|
||||||
|
|
||||||
(defn results [{:keys [search]}]
|
(defn results [{:keys [search]}]
|
||||||
(let [term @(subscribe [:search/current-term])]
|
(let [term @(subscribe [:search/current-term])]
|
||||||
[:div
|
[:section.section>div.container
|
||||||
[:h2.title (str "Search results for \"" term "\"")]
|
[:h2.title (str "Search results for \"" term "\"")]
|
||||||
(if (empty? search)
|
(if (empty? search)
|
||||||
[:p "The server returned no results."]
|
[:p "The server returned no results."]
|
||||||
|
|
@ -63,5 +62,4 @@
|
||||||
(when-not (empty? (:song search))
|
(when-not (empty? (:song search))
|
||||||
[:section.section.is-small
|
[:section.section.is-small
|
||||||
[:h3.subtitle.is-5 "Songs"]
|
[:h3.subtitle.is-5 "Songs"]
|
||||||
[song-results search]])])
|
[song-results search]])])]))
|
||||||
[:pre (with-out-str (pprint search))]]))
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
[airsonic-ui.audio.core]
|
[airsonic-ui.audio.core]
|
||||||
[airsonic-ui.api.events]
|
[airsonic-ui.api.events]
|
||||||
[airsonic-ui.api.subs]
|
[airsonic-ui.api.subs]
|
||||||
|
[airsonic-ui.components.audio-player.events]
|
||||||
[airsonic-ui.components.search.events]
|
[airsonic-ui.components.search.events]
|
||||||
[airsonic-ui.components.search.subs]
|
[airsonic-ui.components.search.subs]
|
||||||
[airsonic-ui.events :as events]
|
[airsonic-ui.events :as events]
|
||||||
|
|
|
||||||
|
|
@ -102,20 +102,11 @@
|
||||||
|
|
||||||
(re-frame/reg-event-fx :credentials/authentication-success authentication-success)
|
(re-frame/reg-event-fx :credentials/authentication-success authentication-success)
|
||||||
|
|
||||||
;; TODO: We have to find another solution for this once we have routes that
|
|
||||||
;; don't require a login but have the bottom controls
|
|
||||||
|
|
||||||
(re-frame/reg-fx
|
|
||||||
:show-nav-bar
|
|
||||||
(fn [_]
|
|
||||||
(.. js/document -documentElement -classList (add "has-navbar-fixed-bottom"))))
|
|
||||||
|
|
||||||
(defn logged-in
|
(defn logged-in
|
||||||
[cofx _]
|
[cofx _]
|
||||||
(let [redirect (or (get-in cofx [:routes/from-query-param :redirect])
|
(let [redirect (or (get-in cofx [:routes/from-query-param :redirect])
|
||||||
[::routes/library])]
|
[::routes/library])]
|
||||||
{:dispatch [:routes/do-navigation redirect]
|
{:dispatch [:routes/do-navigation redirect]}))
|
||||||
:show-nav-bar nil}))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx
|
(re-frame/reg-event-fx
|
||||||
::logged-in
|
::logged-in
|
||||||
|
|
@ -135,77 +126,6 @@
|
||||||
|
|
||||||
(re-frame/reg-event-fx ::logout logout)
|
(re-frame/reg-event-fx ::logout logout)
|
||||||
|
|
||||||
;; ---
|
|
||||||
;; musique
|
|
||||||
;; ---
|
|
||||||
|
|
||||||
; TODO: Make play, next and previous a bit prettier and more DRY
|
|
||||||
|
|
||||||
(defn- song-url [db song]
|
|
||||||
(let [creds (:credentials db)]
|
|
||||||
(api/song-url (:server creds) (select-keys creds [:u :p]) song)))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx
|
|
||||||
; sets up the db, starts to play a song and adds the rest to a playlist
|
|
||||||
::play-songs
|
|
||||||
(fn [{:keys [db]} [_ songs start-idx]]
|
|
||||||
(let [playlist (-> (playlist/->playlist songs :playback-mode :linear :repeat-mode :repeat-all)
|
|
||||||
(playlist/set-current-song start-idx))]
|
|
||||||
{:audio/play (song-url db (playlist/peek playlist))
|
|
||||||
:db (assoc-in db [:audio :playlist] playlist)})))
|
|
||||||
|
|
||||||
;; FIXME: :audio/play might not get the right argument here
|
|
||||||
|
|
||||||
(re-frame/reg-event-db
|
|
||||||
::set-playback-mode
|
|
||||||
(fn [db [_ playback-mode]]
|
|
||||||
(update-in db [:audio :playlist] #(playlist/set-playback-mode % playback-mode))))
|
|
||||||
|
|
||||||
(re-frame/reg-event-db
|
|
||||||
::set-repeat-mode
|
|
||||||
(fn [db [_ repeat-mode]]
|
|
||||||
(update-in db [:audio :playlist] #(playlist/set-repeat-mode % repeat-mode))))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx
|
|
||||||
::next-song
|
|
||||||
(fn [{:keys [db]} _]
|
|
||||||
(let [db (update-in db [:audio :playlist] playlist/next-song)
|
|
||||||
next (playlist/peek (get-in db [:audio :playlist]))]
|
|
||||||
{:db db
|
|
||||||
:audio/play (song-url db next)})))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx
|
|
||||||
::previous-song
|
|
||||||
(fn [{:keys [db]} _]
|
|
||||||
(let [db (update-in db [:audio :playlist] playlist/previous-song)
|
|
||||||
prev (playlist/peek (get-in db [:audio :playlist]))]
|
|
||||||
{:db db
|
|
||||||
:audio/play (song-url db prev)})))
|
|
||||||
|
|
||||||
(re-frame/reg-event-db
|
|
||||||
::enqueue-next
|
|
||||||
(fn [db [_ song]]
|
|
||||||
(update-in db [:audio :playlist] #(playlist/enqueue-next % song))))
|
|
||||||
|
|
||||||
(re-frame/reg-event-db
|
|
||||||
::enqueue-last
|
|
||||||
(fn [db [_ song]]
|
|
||||||
(update-in db [:audio :playlist] #(playlist/enqueue-last % song))))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx
|
|
||||||
::toggle-play-pause
|
|
||||||
(fn [_ _]
|
|
||||||
{:audio/toggle-play-pause nil}))
|
|
||||||
|
|
||||||
(defn audio-update
|
|
||||||
"Reacts to audio events fired by the HTML5 audio player and plays the next
|
|
||||||
track if necessary."
|
|
||||||
[{:keys [db]} [_ status]]
|
|
||||||
(cond-> {:db (assoc-in db [:audio :playback-status] status)}
|
|
||||||
(:ended? status) (assoc :dispatch [::next-song])))
|
|
||||||
|
|
||||||
(re-frame/reg-event-fx :audio/update audio-update)
|
|
||||||
|
|
||||||
;; ---
|
;; ---
|
||||||
;; routing
|
;; routing
|
||||||
;; ---
|
;; ---
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
::user
|
::user
|
||||||
(fn [_ _] [(subscribe [::credentials])])
|
(fn [_ _] [(subscribe [::credentials])])
|
||||||
(fn [[credentials] _]
|
(fn [[credentials] _]
|
||||||
{:name (:u credentials)}))
|
(when credentials {:name (:u credentials)})))
|
||||||
|
|
||||||
(defn cover-url
|
(defn cover-url
|
||||||
"Provides a convenient way for views to get cover images so they don't have
|
"Provides a convenient way for views to get cover images so they don't have
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
(ns airsonic-ui.views
|
(ns airsonic-ui.views
|
||||||
|
"This module contains the outmost layer of our app views. It makes sure that
|
||||||
|
the proper subscriptions are run and arranges the complete layout."
|
||||||
(:require [re-frame.core :refer [dispatch subscribe]]
|
(:require [re-frame.core :refer [dispatch subscribe]]
|
||||||
[airsonic-ui.routes :as routes :refer [url-for]]
|
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||||
[airsonic-ui.events :as events]
|
[airsonic-ui.events :as events]
|
||||||
|
|
@ -7,73 +9,71 @@
|
||||||
|
|
||||||
[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.audio-player :refer [audio-player]]
|
|
||||||
[airsonic-ui.views.login :refer [login-form]]
|
[airsonic-ui.views.login :refer [login-form]]
|
||||||
[airsonic-ui.views.album :as album]
|
[airsonic-ui.components.audio-player.views :refer [audio-player]]
|
||||||
[airsonic-ui.views.song :as song]
|
|
||||||
[airsonic-ui.components.search.views :as search]
|
[airsonic-ui.components.search.views :as search]
|
||||||
[airsonic-ui.components.library.views :as library]))
|
[airsonic-ui.components.library.views :as library]
|
||||||
|
[airsonic-ui.components.artist.views :as artist]
|
||||||
|
[airsonic-ui.components.collection.views :as collection]))
|
||||||
|
|
||||||
;; TODO: Find better names and places for these.
|
(def logo-url "./img/airsonic-light-350x100.png")
|
||||||
|
|
||||||
(defn album-detail [{:keys [album]}]
|
(defn navbar-top
|
||||||
[:div
|
"Contains search, some navigational links and the logo"
|
||||||
[:h2.title (str (:artist album) " - " (:name album))]
|
[{:keys [user]}]
|
||||||
[song/listing (:song album)]])
|
[:nav.navbar.is-fixed-top.is-dark {:role "navigation", :aria-label "search and navigation"}
|
||||||
|
[:div.navbar-brand
|
||||||
(defn artist-detail [{:keys [artist artist-info]}]
|
[:div.navbar-item>img {:src logo-url}]]
|
||||||
[:div
|
;; user is `nil` when we're not logged in, we can hide the extended navbar
|
||||||
[:h2.title (:name artist)]
|
(when user
|
||||||
[:div.content>p {:dangerouslySetInnerHTML {:__html (:biography artist-info)}}]
|
[:div.navbar-menu
|
||||||
[album/listing (:album artist)]])
|
[:div.navbar-start
|
||||||
|
[:div.navbar-item [search/form]]]
|
||||||
(defn sidebar [user]
|
[:div.navbar-end
|
||||||
[:aside.menu.section
|
[:div.navbar-item.has-dropdown.is-hoverable
|
||||||
[search/form]
|
[:div.navbar-link "Library"]
|
||||||
[:p.menu-label "Music"]
|
[:div.navbar-dropdown
|
||||||
[:ul.menu-list
|
[:a.navbar-item {:href (url-for ::routes/library {:criteria "recent"})} "Recently played"]
|
||||||
[:li [:a "By artist"]]
|
[:a.navbar-item {:href (url-for ::routes/library {:criteria "newest"})} "Newest additions"]
|
||||||
[:li [:a "Top rated"]]
|
[:a.navbar-item {:href (url-for ::routes/library {:criteria "starred"})} "Starred"]]]
|
||||||
[:li [:a "Most played"]]]
|
[:a.navbar-item {} "Podcasts"]
|
||||||
[:p.menu-label "Playlists"]
|
[:a.navbar-item {} "Shares"]
|
||||||
[:p.menu-label "Shares"]
|
[:div.navbar-item.has-dropdown.is-hoverable
|
||||||
[:p.menu-label "Podcasts"]
|
[:div.navbar-link "More"]
|
||||||
[:p.menu-label "User area"]
|
[:div.navbar-dropdown.is-right
|
||||||
[:ul.menu-list
|
[:a.navbar-item {:disabled true} "Settings"]
|
||||||
[:li [:a "Settings"]]
|
[:a.navbar-item
|
||||||
[:li [:a
|
|
||||||
{:on-click #(dispatch [::events/logout]) :href "#"}
|
{:on-click #(dispatch [::events/logout]) :href "#"}
|
||||||
(str "Logout (" (:name user) ")")]]]])
|
(str "Logout (" (:name user) ")")]]]]])])
|
||||||
|
|
||||||
;; putting everything together
|
(defn media-content
|
||||||
|
"Provides the complete UI to browse the media library, interact with search
|
||||||
(defn app [route-id params query]
|
results etc"
|
||||||
(let [user @(subscribe [::subs/user])
|
[route-id params query]
|
||||||
;; TODO: Move this to a layer 3 subscription ↓
|
(let [;; TODO: Move this to a layer 3 subscription ↓
|
||||||
route-events @(subscribe [:routes/events-for-current-route])
|
route-events @(subscribe [:routes/events-for-current-route])
|
||||||
content @(subscribe [:api/route-data route-events])]
|
content @(subscribe [:api/route-data route-events])]
|
||||||
[:div
|
[:div
|
||||||
[:main.columns
|
[:section.section
|
||||||
[:div.column.is-2.sidebar
|
[breadcrumbs content]
|
||||||
[sidebar user]]
|
(case route-id
|
||||||
[:div.column.is-10
|
::routes/library [library/main [route-id params query] content]
|
||||||
[:section.section
|
::routes/artist-view [artist/detail content]
|
||||||
[breadcrumbs content]
|
::routes/album-view [collection/detail content]
|
||||||
(case route-id
|
::routes/search [search/results content])]
|
||||||
::routes/library [library/main [route-id params query] content]
|
|
||||||
::routes/artist-view [artist-detail content]
|
|
||||||
::routes/album-view [album-detail content]
|
|
||||||
::routes/search [search/results content])]]]
|
|
||||||
[audio-player]]))
|
[audio-player]]))
|
||||||
|
|
||||||
(defn main-panel []
|
(defn main-panel []
|
||||||
(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 [:routes/current-route])]
|
[route-id params query] @(subscribe [:routes/current-route])
|
||||||
|
user @(subscribe [::subs/user])]
|
||||||
[(add-classes :div route-id)
|
[(add-classes :div route-id)
|
||||||
[notification-list notifications]
|
[notification-list notifications]
|
||||||
(if is-booting?
|
(if is-booting?
|
||||||
[:div.app-loading>div.loader]
|
[:div.app-loading>div.loader]
|
||||||
(case route-id
|
[:div
|
||||||
::routes/login [login-form]
|
[navbar-top {:user user}]
|
||||||
[app route-id params query]))]))
|
(case route-id
|
||||||
|
::routes/login [login-form]
|
||||||
|
[media-content route-id params query])])]))
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
:other-content))
|
:other-content))
|
||||||
|
|
||||||
(defn- bulma-breadcrumbs [& items]
|
(defn- bulma-breadcrumbs [& items]
|
||||||
[:nav.breadcrumb {:aria-label "breadcrumbs"}
|
[:div.container>nav.breadcrumb {:aria-label "breadcrumbs"}
|
||||||
[:ul
|
[:ul
|
||||||
(for [[idx [href label]] (map-indexed vector (butlast items))]
|
(for [[idx [href label]] (map-indexed vector (butlast items))]
|
||||||
[:li {:key idx} [:a {:href href} label]])
|
[:li {:key idx} [:a {:href href} label]])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
(ns airsonic-ui.views.song
|
(ns airsonic-ui.views.song
|
||||||
(:require [airsonic-ui.helpers :refer [dispatch]]
|
(:require [airsonic-ui.helpers :refer [dispatch]]
|
||||||
[airsonic-ui.events :as events]
|
|
||||||
[airsonic-ui.routes :as routes :refer [url-for]]
|
[airsonic-ui.routes :as routes :refer [url-for]]
|
||||||
[airsonic-ui.views.icon :refer [icon]]))
|
[airsonic-ui.views.icon :refer [icon]]))
|
||||||
|
|
||||||
|
|
@ -12,7 +11,7 @@
|
||||||
(:artist song))
|
(:artist song))
|
||||||
" - "
|
" - "
|
||||||
[:a
|
[:a
|
||||||
{:href "#" :on-click (dispatch [::events/play-songs songs idx])}
|
{:href "#" :on-click (dispatch [:audio-player/play-all songs idx])}
|
||||||
(:title song)]]))
|
(:title song)]]))
|
||||||
|
|
||||||
(defn listing [songs]
|
(defn listing [songs]
|
||||||
|
|
@ -23,9 +22,9 @@
|
||||||
;; FIXME: Not implemented yet
|
;; FIXME: Not implemented yet
|
||||||
[:td>a {:title "Play next"
|
[:td>a {:title "Play next"
|
||||||
:href "#"
|
:href "#"
|
||||||
:on-click (dispatch [::events/enqueue-next song])}
|
:on-click (dispatch [:audio-player/enqueue-next song])}
|
||||||
[icon :plus]]
|
[icon :plus]]
|
||||||
[:td>a {:title "Play last"
|
[:td>a {:title "Play last"
|
||||||
:href "#"
|
:href "#"
|
||||||
:on-click (dispatch [::events/enqueue-last song])}
|
:on-click (dispatch [:audio-player/enqueue-last song])}
|
||||||
[icon :caret-right]]])])
|
[icon :caret-right]]])])
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
// floating notifications
|
// floating notifications
|
||||||
.notifications
|
.notifications:not(:empty)
|
||||||
@extend .container
|
@extend .container
|
||||||
z-index: 100
|
z-index: 100
|
||||||
position: fixed
|
position: fixed
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
(ns airsonic-ui.components.audio-player.events-test
|
||||||
|
(:require [cljs.test :refer-macros [deftest testing is]]
|
||||||
|
[airsonic-ui.test-helpers :refer [dispatches?]]
|
||||||
|
[airsonic-ui.components.audio-player.events :as events]))
|
||||||
|
|
||||||
|
|
||||||
|
(deftest song-has-ended
|
||||||
|
(testing "Should play the next song when current song has ended"
|
||||||
|
(is (not (dispatches? (events/audio-update {} [:audio/update {:ended? false}]) :audio-player/next-song)))
|
||||||
|
(is (dispatches? (events/audio-update {} [:audio/update {:ended? true}]) :audio-player/next-song))))
|
||||||
|
|
@ -129,8 +129,3 @@
|
||||||
(testing "Should automatically remove a message after a while"
|
(testing "Should automatically remove a message after a while"
|
||||||
(let [fx (events/show-notification {} [:_ :info "This is a notification"])]
|
(let [fx (events/show-notification {} [:_ :info "This is a notification"])]
|
||||||
(is (= :notification/hide (-> (:dispatch-later fx) first :dispatch first))))))
|
(is (= :notification/hide (-> (:dispatch-later fx) first :dispatch first))))))
|
||||||
|
|
||||||
(deftest song-has-ended
|
|
||||||
(testing "Should play the next song when current song has ended"
|
|
||||||
(is (not (dispatches? (events/audio-update {} [:audio/update {:ended? false}]) ::events/next-song)))
|
|
||||||
(is (dispatches? (events/audio-update {} [:audio/update {:ended? true}]) ::events/next-song))))
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue