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

Merge incomplete podcast support

commit 4ac35d6530f7770e7b80307321c72541a55e2c8e
Author: Arne Schlüter <arne@schlueter.is>
Date:   Mon Oct 8 21:09:04 2018 +0200

    Stub out podcast detail view

commit 60742a22e93bfe6f432e06d56d3e4da671184559
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Sep 18 23:02:39 2018 +0200

    Simplify api helpers; closes #16

commit 8bbc79ebf4dbbe3dbfa08cb4c7c1edd341d507eb
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Sep 18 19:39:17 2018 +0200

    Adjust `stream-url` to work with podcast episodes

commit 991ba5b65230a7429c160ca1b7968ecbb8595e0b
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Sep 18 19:14:08 2018 +0200

    Fix breadcrumbs for podcasts

commit 37c3a894eded2fe37f9af031d3132c7175702266
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Sep 18 15:11:54 2018 +0200

    Stub out overview for podcasts
This commit is contained in:
Arne Schlüter 2018-10-08 21:15:29 +02:00
commit fa485bbf42
19 changed files with 350 additions and 133 deletions

View file

@ -6,12 +6,6 @@
[ajax.core :as ajax] [ajax.core :as ajax]
[airsonic-ui.api.helpers :as api])) [airsonic-ui.api.helpers :as api]))
(defn- api-url
"Small helper function which makes constructing API URLs a bit easier"
[db endpoint params]
(let [creds (:credentials db)]
(api/url (:server creds) endpoint (merge params (select-keys creds [:u :p])))))
(defn- cache-path [endpoint params] [:api/responses [endpoint params]]) (defn- cache-path [endpoint params] [:api/responses [endpoint params]])
(defn api-request (defn api-request
@ -19,7 +13,7 @@
current app state." current app state."
[{:keys [db]} [_ endpoint params]] [{:keys [db]} [_ endpoint params]]
{:http-xhrio {:method :get {:http-xhrio {:method :get
:uri (api-url db endpoint params) :uri (api/url (:credentials db) endpoint params)
:response-format (ajax/json-response-format {:keywords? true}) :response-format (ajax/json-response-format {:keywords? true})
:on-success [:api/good-response endpoint params] :on-success [:api/good-response endpoint params]
:on-failure [:api/failed-response endpoint params]} :on-failure [:api/failed-response endpoint params]}

View file

@ -6,22 +6,25 @@
:c "airsonic-ui-cljs" :c "airsonic-ui-cljs"
:v "1.15.0"}) :v "1.15.0"})
(defn- encode [c] (def ^:private encode js/encodeURIComponent)
(js/encodeURIComponent c))
(defn url (defn url
"Returns an absolute url to an API endpoint" "Returns an absolute url to an API endpoint"
[server endpoint params] [credentials endpoint params]
(let [query (->> (merge default-params params) (let [server (:server credentials)
query (->> (merge default-params (select-keys credentials [:u :p]) params)
(map (fn [[k v]] (str (encode (name k)) "=" (encode v)))) (map (fn [[k v]] (str (encode (name k)) "=" (encode v))))
(str/join "&"))] (str/join "&"))]
(str server (when-not (str/ends-with? server "/") "/") "rest/" endpoint "?" query))) (str server (when-not (str/ends-with? server "/") "/") "rest/" endpoint "?" query)))
(defn song-url [server credentials song] (defn stream-url [credentials song-or-episode]
(url server "stream" (merge (select-keys song [:id]) credentials))) ;; podcasts have a stream-id, normal songs just use their id
(let [params {:id (or (:streamId song-or-episode)
(:id song-or-episode))}]
(url credentials "stream" params)))
(defn cover-url [server credentials item size] (defn cover-url [credentials item size]
(url server "getCoverArt" (merge {:id (:coverArt item) :size size} credentials))) (url credentials "getCoverArt" {:id (:coverArt item) :size size}))
(defn is-error? [response] (defn is-error? [response]
(= "failed" (get-in response [:subsonic-response :status]))) (= "failed" (get-in response [:subsonic-response :status])))

View file

@ -28,12 +28,12 @@
(re-frame/reg-fx (re-frame/reg-fx
:audio/play :audio/play
(fn [song-url] (fn [stream-url]
(when-not @audio (when-not @audio
(reset! audio (js/Audio.)) (reset! audio (js/Audio.))
(attach-listeners! @audio)) (attach-listeners! @audio))
(.pause @audio) (.pause @audio)
(set! (.-src @audio) song-url) (set! (.-src @audio) stream-url)
(.play @audio))) (.play @audio)))
(re-frame/reg-fx (re-frame/reg-fx

View file

@ -3,17 +3,13 @@
[airsonic-ui.audio.playlist :as playlist] [airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.api.helpers :as api])) [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 (re-frame/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 :audio-player/play-all
(fn [{:keys [db]} [_ songs start-idx]] (fn [{:keys [db]} [_ 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)
(playlist/set-current-song start-idx))] (playlist/set-current-song start-idx))]
{:audio/play (song-url db (playlist/peek playlist)) {:audio/play (api/stream-url (:credentials db) (playlist/peek playlist))
:db (assoc-in db [:audio :playlist] playlist)}))) :db (assoc-in db [:audio :playlist] playlist)})))
;; FIXME: :audio/play might not get the right argument here ;; FIXME: :audio/play might not get the right argument here
@ -34,7 +30,7 @@
(let [db (update-in db [:audio :playlist] playlist/next-song) (let [db (update-in db [:audio :playlist] playlist/next-song)
next (playlist/peek (get-in db [:audio :playlist]))] next (playlist/peek (get-in db [:audio :playlist]))]
{:db db {:db db
:audio/play (song-url db next)}))) :audio/play (api/stream-url (:credentials db) next)})))
(re-frame/reg-event-fx (re-frame/reg-event-fx
:audio-player/previous-song :audio-player/previous-song
@ -42,7 +38,7 @@
(let [db (update-in db [:audio :playlist] playlist/previous-song) (let [db (update-in db [:audio :playlist] playlist/previous-song)
prev (playlist/peek (get-in db [:audio :playlist]))] prev (playlist/peek (get-in db [:audio :playlist]))]
{:db db {:db db
:audio/play (song-url db prev)}))) :audio/play (api/stream-url (:credentials db) prev)})))
(re-frame/reg-event-db (re-frame/reg-event-db
:audio-player/enqueue-next :audio-player/enqueue-next

View file

@ -22,17 +22,17 @@
year (conj [:li [icon :calendar] (str "Released in " year)])))) year (conj [:li [icon :calendar] (str "Released in " year)]))))
(defn preview [album] (defn album-card [album]
(let [{:keys [artist artistId name id]} album] (let [{:keys [artist artistId name id]} album]
[card album [card album
:url-fn #(url-for ::routes/album-view {:id id}) :url-fn #(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-view {:id id}) [:a {:href (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-view {:id artistId}) [:div.subtitle.is-6 [:a {:href (url-for ::routes/artist.detail {:id artistId})
:title artist} artist]]]])) :title artist} artist]]]]))
(defn listing [albums] (defn listing [albums]
@ -40,7 +40,7 @@
[:div.columns.is-multiline.is-mobile [:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector albums)] (for [[idx album] (map-indexed vector albums)]
^{: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
[preview album]])]) [album-card album]])])
(defn detail (defn detail
"Lists all songs in an album" "Lists all songs in an album"

View file

@ -0,0 +1,13 @@
(ns airsonic-ui.components.podcast.events)
(defn subscribe-to-channel
[db [_ channel-url]])
(defn delete-podcast-channel
[db [_ channel-id]])
(defn download-episode
[db [_ episode-id]])
(defn delete-episode
[db [_ episode-id]])

View file

@ -0,0 +1,52 @@
(ns airsonic-ui.components.podcast.subs
(:require [re-frame.core :refer [reg-sub]]))
;; this unwraps the api response into a collection
(reg-sub
::podcast.response
:<- [:api/response-for "getPodcasts"]
(fn [response]
(:channel response)))
(defn podcast-channels
"Given a podcast response, returns information about the channels that have
been subscribed to."
[response _]
(when response
(map #(dissoc % :episode) response)))
(reg-sub
::podcast.channels
:<- [::podcast.response]
podcast-channels)
(defn sorted-podcast-episodes
"Given a response of all podcasts, returns all episodes sorted by a given function"
[response [_ key-fn & {:keys [n reverse?]
:or {n 15
reverse? true}}]]
;; some podcasts have an :artist and some don't, we make sure all of them have one
(let [id->channel (into {} (map (juxt :id :title) response))]
(let [sorted (->> (mapcat :episode response)
(map (fn [episode]
(assoc episode :artist (id->channel (:channelId episode)))))
(sort-by (or key-fn identity)))]
(take n (if reverse? (reverse sorted) sorted)))))
(reg-sub
::podcast.all-episodes-by
:<- [::podcast.response]
sorted-podcast-episodes)
(defn podcast-detail
"Since there's no real detail request, this function provides some abstraction
for that in providing a lense to only the podcast with a specific channel-id."
[[response [_ params _]] _]
(let [channel-id (:id params)]
(first (filter #(= channel-id (:id %)) response))))
(reg-sub
::podcast.detail-from-route
:<- [::podcast.response]
:<- [:routes/current-route]
podcast-detail)

View file

@ -0,0 +1,83 @@
(ns airsonic-ui.components.podcast.views
(:require [re-frame.core :refer [subscribe]]
[airsonic-ui.helpers :refer [muted-dispatch]]
[airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.components.podcast.subs :as subs]
[airsonic-ui.views.cover :refer [cover card]]
[airsonic-ui.views.icon :refer [icon]]
[airsonic-ui.components.debug.views :refer [debug]]))
;; TODO: Implement detail pages for podcasts
;; TODO: Implement CRUD frontend for podcasts
;; TODO: Error handling for channels and episodes
(defn channel-card
"Displays the cover of a podcast and links to the podcasts detail page"
[channel]
[card channel
:url-fn #(url-for ::routes/podcast.detail {:id (:id channel)})
:content [:div.title.is-5
[:a {:href (url-for ::routes/podcast.detail {:id (:id channel)})
:title (:title channel)} (:title channel)]]])
(defn- channel-overview [channels]
[:div.columns.is-multiline.is-mobile
(for [[idx channel] (map-indexed vector channels)]
^{:key idx}
[:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
[channel-card channel]])])
(defn- episode-actions [episode]
(case (:status episode)
"completed"
[[:td>a {:title "Play next"
:href "#"
:on-click (muted-dispatch [:audio-player/enqueue-next episode])}
[icon :plus]]
[:td>a {:title "Play last"
:href "#"
:on-click (muted-dispatch [:audio-player/enqueue-last episode])}
[icon :caret-right]]]
"skipped" ;; FIXME: Show download button
[[:td] [:td]]))
(defn- episode-list [episodes]
[:table.table.is-striped.is-hoverable.is-fullwidth>tbody
(for [[idx episode] (map-indexed vector episodes)]
^{:key idx}
(into
[:tr
[:td.grow [:span
[:a {:href (url-for ::routes/podcast.detail {:id (:channelId episode)})}
(:artist episode)]
" - "
[:a {:title (:title episode)
:href "#"
:on-click (muted-dispatch [:audio-player/play-all episodes idx])}
(:title episode)]]]]
(episode-actions episode)))])
(defn detail
"Detail page for a single channel"
[_]
;; NOTE: This isn't especially pretty, but it works. The detail page can only
;; ever be displayed for the podcast the current route points to
(let [channel @(subscribe [::subs/podcast.detail-from-route])]
[:div
[:section.section>div.hero-body
[:div.container>article.media
[:div.media-left [cover channel 128]]
[:div.media-content
[:h2.title (:title channel)]
[:p (:description channel)]]]]
[:section.section>div.container [episode-list (:episode channel)]]]))
(defn overview
"All channels and most recently published shows"
[_]
(let [channels @(subscribe [::subs/podcast.channels])
episodes @(subscribe [::subs/podcast.all-episodes-by :created])]
[:section.section>div.container
[:h1.title "Subscriptions"]
[channel-overview channels]
[:h1.title "Latest Episodes"]
[episode-list episodes]]))

View file

@ -19,10 +19,11 @@
:default-value search-term :default-value search-term
:placeholder "Search"}]]]))) :placeholder "Search"}]]])))
(defn artist-results [{:keys [artist]}] (defn artist-results [{:keys [artist]}]
[:div.columns.is-multiline.is-mobile [:div.columns.is-multiline.is-mobile
(for [[idx artist] (map-indexed vector artist)] (for [[idx artist] (map-indexed vector artist)]
(let [url #(url-for ::routes/artist-view (select-keys % [:id]))] (let [url #(url-for ::routes/artist.detail (select-keys % [:id]))]
^{:key idx} [:div.column.is-2 ^{:key idx} [:div.column.is-2
[card artist [card artist
:url-fn url :url-fn url
@ -33,7 +34,7 @@
(defn album-results [{:keys [album]}] (defn album-results [{:keys [album]}]
[:div.columns.is-multiline.is-mobile [:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector album)] (for [[idx album] (map-indexed vector album)]
(let [url #(url-for ::routes/album-view (select-keys % [:id])) (let [url #(url-for ::routes/album.detail (select-keys % [:id]))
title (str (:name album) " (" (:artist album) ")")] title (str (:name album) " (" (:artist album) ")")]
^{:key idx} [:div.column.is-2 [card album ^{:key idx} [:div.column.is-2 [card album
:url-fn url :url-fn url

View file

@ -67,9 +67,7 @@
the credentials when the request was successful." the credentials when the request was successful."
[cofx [_ credentials]] [cofx [_ credentials]]
(assoc cofx :http-xhrio {:method :get (assoc cofx :http-xhrio {:method :get
:uri (api/url (:server credentials) "getUser" :uri (api/url credentials "getUser" {:username (:u credentials)})
(merge (select-keys credentials [:u :p])
{:username (:u credentials)}))
:response-format (ajax/json-response-format {:keywords? true}) :response-format (ajax/json-response-format {:keywords? true})
:on-success [:credentials/authentication-response credentials] :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 :on-failure [:api/failed-response]})) ; <- we don't need endpoint and params here because the response is not cached

View file

@ -9,9 +9,11 @@
(r/router [["/" ::login] (r/router [["/" ::login]
["/library" ::library] ["/library" ::library]
["/library/:criteria" ::library] ["/library/:criteria" ::library]
["/artist/:id" ::artist-view] ["/artist/:id" ::artist.detail]
["/album/:id" ::album-view] ["/album/:id" ::album.detail]
["/search" ::search]])) ["/search" ::search]
["/podcast" ::podcast.overview]
["/podcast/:id" ::podcast.detail]]))
;; use this in views to construct a url ;; use this in views to construct a url
(defn url-for (defn url-for
@ -20,15 +22,20 @@
([k params query] (str "#" (r/resolve router k params query)))) ([k params query] (str "#" (r/resolve router k params query))))
;; which routes need valid login credentials? ;; which routes need valid login credentials?
(def protected-routes #{::library ::artist-view ::album-view ::search}) (def protected-routes #{::library ::artist.detail ::album.detail ::search
::podcast.overview ::podcast.detail})
;; which data should be requested for which route? can either be a vector or a function returning a vector ;; which data should be requested for which route? can either be a vector or a function returning a vector
;; TODO: It's not so nice to have this all so close to the routing logic;
;; it would be nicer to abstract this away, so the components themselves
;; could tell what kind of events they expect
(defmulti -route-events (defmulti -route-events
"Returns the events that take care of correct data being fetched." "Returns the events that take care of correct data being fetched."
(fn [route-id & _] route-id)) (fn [route-id & _] route-id))
(defmethod -route-events :default [route-id params query] nil) (defmethod -route-events :default [route-id params query])
(defmethod -route-events ::library (defmethod -route-events ::library
[route-id {:keys [criteria]} {:keys [page]}] [route-id {:keys [criteria]} {:keys [page]}]
@ -37,13 +44,13 @@
[:api/request "getAlbumList2" {:type criteria, :size 20, :offset (* 20 (dec page))}]] [:api/request "getAlbumList2" {:type criteria, :size 20, :offset (* 20 (dec page))}]]
[:routes/do-navigation [route-id {:criteria "recent"} {:page 1}]])) [:routes/do-navigation [route-id {:criteria "recent"} {:page 1}]]))
(defmethod -route-events ::artist-view (defmethod -route-events ::artist.detail
[route-id params query] [route-id params query]
(let [params (select-keys params [:id])] (let [params (select-keys params [:id])]
[[:api/request "getArtist" params] [[:api/request "getArtist" params]
[:api/request "getArtistInfo2" params]])) [:api/request "getArtistInfo2" params]]))
(defmethod -route-events ::album-view (defmethod -route-events ::album.detail
[route-id params query] [route-id params query]
[:api/request "getAlbum" (select-keys params [:id])]) [:api/request "getAlbum" (select-keys params [:id])])
@ -52,6 +59,15 @@
[[:search/restore-term-from-param (:query query)] [[:search/restore-term-from-param (:query query)]
[:api/request "search3" query]]) [:api/request "search3" query]])
(defmethod -route-events ::podcast.overview
[route-id params query]
[[:api/request "getPodcasts"]])
(defmethod -route-events ::podcast.detail
[route-id params query]
;; this is identical to ::podcast.overview on purpose
[[:api/request "getPodcasts"]])
;; shouldn't need to change anything below ;; shouldn't need to change anything below
(defn- n-events? (defn- n-events?

View file

@ -80,12 +80,12 @@
(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
to build them themselves and can live a simple and happy life." to build them themselves and can live a simple and happy life."
[[{:keys [server u p]}] [_ song size]] [credentials [_ song size]]
(api/cover-url server {:u u :p p} song size)) (api/cover-url credentials song size))
(reg-sub (reg-sub
::cover-url ::cover-url
(fn [_ _] [(subscribe [::credentials])]) :<- [::credentials]
cover-url) cover-url)
;; user notifications ;; user notifications

View file

@ -15,43 +15,62 @@
[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.artist.views :as artist]
[airsonic-ui.components.collection.views :as collection])) [airsonic-ui.components.collection.views :as collection]
[airsonic-ui.components.podcast.views :as podcast]))
(def logo-url "./img/airsonic-light-350x100.png") (def logo-url "./img/airsonic-light-350x100.png")
;; ---
;; top navigation
;; ---
(defonce navbar-active? (r/atom false))
(def toggle-navbar-active! #(swap! navbar-active? not))
(defn navbar-item [{:keys [href]} label]
[:a.navbar-item {:href href :on-click toggle-navbar-active!} label])
(defn navbar-dropdown
([label items] (navbar-dropdown label {} items))
([label label-opts items]
[:div.navbar-item.has-dropdown.is-hoverable
[:div.navbar-link label-opts label]
[:div.navbar-dropdown
(for [[idx [opts label]] (map-indexed vector items)]
^{:key (str "navbar-dropdown-" idx)}
[navbar-item
(merge {:on-click toggle-navbar-active!} opts)
label])]]))
(defn navbar-top (defn navbar-top
"Contains search, some navigational links and the logo" "Contains search, some navigational links and the logo"
[] []
(let [active? (r/atom false) (let [user @(subscribe [:user/info])
toggle-active #(swap! active? not)
navbar-item (fn navbar-item [{:keys [href]} label]
[:a.navbar-item {:href href :on-click toggle-active} label])
user @(subscribe [:user/info])
stream-role @(subscribe [:user/roles :stream]) stream-role @(subscribe [:user/roles :stream])
podcast-role @(subscribe [:user/roles :podcast]) podcast-role @(subscribe [:user/roles :podcast])
playlist-role @(subscribe [:user/roles :playlist]) playlist-role @(subscribe [:user/roles :playlist])
share-role @(subscribe [:user/roles :share]) share-role @(subscribe [:user/roles :share])
settings-role @(subscribe [:user/roles :settings])] settings-role @(subscribe [:user/roles :settings])]
(fn []
[:nav.navbar.is-fixed-top.is-dark {:role "navigation", :aria-label "search and navigation"} [:nav.navbar.is-fixed-top.is-dark {:role "navigation", :aria-label "search and navigation"}
;; user is `nil` when we're not logged in, we can hide the extended navigation ;; user is `nil` when we're not logged in, we can hide the extended navigation
[:div.navbar-brand [:div.navbar-brand
[:div.navbar-item>img {:src logo-url}] [:div.navbar-item>img {:src logo-url}]
[:div.navbar-burger.burger {:on-click toggle-active} (repeat 3 [:span])]] [:div.navbar-burger.burger {:on-click toggle-navbar-active!}
(for [idx (range 3)] ^{:key (str "burger-" idx)} [:span])]]
(when user (when user
[(if @active? :div.navbar-menu.is-active :div.navbar-menu) [(if @navbar-active? :div.navbar-menu.is-active :div.navbar-menu)
[:div.navbar-start [:div.navbar-start
[:div.navbar-item [search/form]]] [:div.navbar-item [search/form]]]
[:div.navbar-end [:div.navbar-end
(when stream-role (when stream-role
[:div.navbar-item.has-dropdown.is-hoverable [navbar-dropdown "Library"
[:div.navbar-link "Library"] [[{:href (url-for ::routes/library {:criteria "recent"})} "Recently played"]
[:div.navbar-dropdown [{:href (url-for ::routes/library {:criteria "newest"})} "Newest additions"]
[navbar-item {:href (url-for ::routes/library {:criteria "recent"})} "Recently played"] [{:href (url-for ::routes/library {:criteria "starred"})} "Starred"]]])
[navbar-item {:href (url-for ::routes/library {:criteria "newest"})} "Newest additions"]
[navbar-item {:href (url-for ::routes/library {:criteria "starred"})} "Starred"]]])
(when podcast-role (when podcast-role
[navbar-item {} "Podcasts"]) (let [podcast-url (url-for ::routes/podcast.overview)]
[navbar-dropdown "Podcast" {:href podcast-url}
[[{:href podcast-url} "Overview"]]]))
(when playlist-role (when playlist-role
[navbar-item {} "Playlists"]) [navbar-item {} "Playlists"])
(when share-role (when share-role
@ -60,13 +79,17 @@
[:div.navbar-link "More"] [:div.navbar-link "More"]
[:div.navbar-dropdown.is-right [:div.navbar-dropdown.is-right
(when settings-role (when settings-role
[navbar-item "Settings"]) [navbar-item {} "Settings"])
[:a.navbar-item [:a.navbar-item
{:on-click (fn [_] {:on-click (fn [_]
(toggle-active) (toggle-navbar-active!)
(dispatch [::events/logout])) (dispatch [::events/logout]))
:href "#"} :href "#"}
(str "Logout (" (:username user) ")")]]]]])]))) (str "Logout (" (:username user) ")")]]]]])]))
;; ---
;; this is the section the user mainly interacts with
;; ---
(defn media-content (defn media-content
"Provides the complete UI to browse the media library, interact with search "Provides the complete UI to browse the media library, interact with search
@ -80,12 +103,17 @@
[breadcrumbs content] [breadcrumbs content]
(case route-id (case route-id
::routes/library [library/main [route-id params query] content] ::routes/library [library/main [route-id params query] content]
::routes/artist-view [artist/detail content] ::routes/artist.detail [artist/detail content]
::routes/album-view [collection/detail content] ::routes/album.detail [collection/detail content]
::routes/search [search/results content])] ::routes/search [search/results content]
::routes/podcast.overview [podcast/overview content]
::routes/podcast.detail [podcast/detail content])]
[audio-player]])) [audio-player]]))
(defn main-panel [] (defn main-panel
"The outermost wrapper; handles display of the login form if necessary,
makes the code in media-content a bit easier to follow"
[]
(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])]

View file

@ -12,6 +12,7 @@
#{:artist :artist-info} :artist #{:artist :artist-info} :artist
#{:album} :album #{:album} :album
#{:search} :search #{:search} :search
#{:podcasts} :podcast
:other-content)) :other-content))
(defn- bulma-breadcrumbs [& items] (defn- bulma-breadcrumbs [& items]
@ -26,18 +27,20 @@
(defmethod breadcrumbs :default [content] (defmethod breadcrumbs :default [content]
[bulma-breadcrumbs "Start"]) [bulma-breadcrumbs "Start"])
(def start [(url-for ::routes/library) "Start"])
(defmethod breadcrumbs :artist [{:keys [artist]}] (defmethod breadcrumbs :artist [{:keys [artist]}]
[bulma-breadcrumbs [bulma-breadcrumbs start
[(url-for ::routes/library) "Start"]
(:name artist)]) (:name artist)])
(defmethod breadcrumbs :album [{:keys [album]}] (defmethod breadcrumbs :album [{:keys [album]}]
[bulma-breadcrumbs [bulma-breadcrumbs start
[(url-for ::routes/library) "Start"] [(url-for ::routes/artist.detail {:id (:artistId album)}) (:artist album)]
[(url-for ::routes/artist-view {:id (:artistId album)}) (:artist album)]
(:name album)]) (:name album)])
(defmethod breadcrumbs :search [_] (defmethod breadcrumbs :search [_]
[bulma-breadcrumbs [bulma-breadcrumbs start "Search"])
[(url-for ::routes/library) "Start"]
"Search"]) (defmethod breadcrumbs :podcast [{:keys [channel]}]
;; TODO: Detail view
[bulma-breadcrumbs start "Podcasts"])

View file

@ -7,7 +7,7 @@
(let [artist-id (:artistId song)] (let [artist-id (:artistId song)]
[:div [:div
(if artist-id (if artist-id
[:a {:href (url-for ::routes/artist-view {:id artist-id})} (:artist song)] [:a {:href (url-for ::routes/artist.detail {:id artist-id})} (:artist song)]
(:artist song)) (:artist song))
" - " " - "
[:a [:a

View file

@ -132,7 +132,8 @@
.preview-card .card-content .preview-card .card-content
padding: 0.375rem 0.75rem 0.75rem padding: 0.375rem 0.75rem 0.75rem
.album-view .album
&.detail
.collection-info .collection-info
list-style: none list-style: none
@ -159,3 +160,5 @@
content: counter(track) content: counter(track)
display: inline display: inline
padding-right: 0.375rem padding-right: 0.375rem
.no-wrap

View file

@ -10,12 +10,12 @@
(api/url server endpoint {})) (api/url server endpoint {}))
(def fixtures (def fixtures
{:default-url (url "http://localhost:8080" "ping")}) {:default-url (url {:server "http://localhost:8080"} "ping")})
(deftest general-url-construction (deftest general-url-construction
(testing "Handles missing slashes" (testing "Handles missing slashes"
(is (true? (str/starts-with? (fixtures :default-url) "http://localhost:8080/rest/ping"))) (is (true? (str/starts-with? (url {:server "http://localhost:8080"} "ping") "http://localhost:8080/rest/ping")))
(is (true? (str/starts-with? (url "http://localhost:8080/" "ping") "http://localhost:8080/rest/ping")))) (is (true? (str/starts-with? (url {:server "http://localhost:8080/"} "ping") "http://localhost:8080/rest/ping"))))
(testing "Should set correct default parameters" (testing "Should set correct default parameters"
(is (string? (re-find #"f=json" (fixtures :default-url)))) (is (string? (re-find #"f=json" (fixtures :default-url))))
(is (string? (re-find #"v=1\.15\.0" (fixtures :default-url)))))) (is (string? (re-find #"v=1\.15\.0" (fixtures :default-url))))))
@ -24,19 +24,22 @@
(testing "Should escape url parameters" (testing "Should escape url parameters"
(let [query "äöüß" (let [query "äöüß"
encoded-str (js/encodeURIComponent query)] encoded-str (js/encodeURIComponent query)]
(is (str/includes? (api/url "http://localhost" "search3" {:query query}) encoded-str))))) (is (str/includes? (api/url {:server "http://localhost"} "search3" {:query query}) encoded-str)))))
(deftest song-urls (deftest stream-urls
(testing "Should construct the url based on a song's id" (testing "Should construct the url based on a song's id"
(let [song {:id 1234}] (let [stream-url (api/stream-url {:server "http://localhost"} fixtures/song)]
(is (true? (str/includes? (api/song-url "http://localhost" {} song) (str "id=" (:id song)))))))) (is (str/includes? stream-url (str "id=" (:id fixtures/song))))))
(testing "Should also work for podcasts"
(let [stream-url (api/stream-url {:server "http://localhost"} fixtures/podcast-episode)]
(is (str/includes? stream-url (str "id=" (:streamId fixtures/podcast-episode)))))))
(deftest cover-urls (deftest cover-urls
(let [album {:coverArt "cover-99999"}] (let [album {:coverArt "cover-99999"}]
(testing "Should construct the url based on an item's cover-id" (testing "Should construct the url based on an item's cover-id"
(is (true? (str/includes? (api/cover-url "http://server.tld" {} album -1) (str "id=" (:coverArt album)))))) (is (true? (str/includes? (api/cover-url {:server "http://server.tld"} album -1) (str "id=" (:coverArt album))))))
(testing "Should scale an image to a given size" (testing "Should scale an image to a given size"
(is (true? (str/includes? (api/cover-url "http://server.tld" {} album 48) "size=48")))))) (is (true? (str/includes? (api/cover-url {:server "http://server.tld"} album 48) "size=48"))))))
(deftest response-handling (deftest response-handling
(testing "Should unwrap responses" (testing "Should unwrap responses"

View file

@ -86,3 +86,30 @@
:paused? 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-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}) :current-time 3.477029})
(def podcast-episode
{:genre "Vocal",
:description
"Themen der Sendung: Druck auf Maaßen nach Äußerungen zu Chemnitz wächst, Köthen: 22-Jähriger stirbt nach Streit an Herzversagen, Parlamentswahl in Schweden, Russland und Syrien setzen Luftangriffe auf syrische Provinz Idlib fort, Tote und Verletzte bei Ausschreitungen im irakischem Basra, Nordkorea feiert 70. Jubiläum seiner Staatsgründung, Zahl der Toten nach Erdbeben in Japan steigt auf 39, Pläne von CDU und CSU: Fluggesellschaften sollen Auskunft über Verspätungen geben, Menschenkette in Dangast als Zeichen gegen Flüchtlingssterben im Mittelmeer, Das Wetter",
:suffix "mp3",
:isDir false,
:bitRate 64,
:parent "10409",
:channelId "4",
:type "podcast",
:created "2018-09-09T19:41:13.000Z",
:duration 965,
:artist "Tagesschau (Audio-Podcast)",
:isVideo false,
:publishDate "2018-09-09T18:00:00.000Z",
:size 7812758,
:title "09.09.2018 - tagesschau 20:00 Uhr",
:playCount 0,
:year 2018,
:streamId "11181",
:status "completed",
:id "507",
:coverArt "10409",
:contentType "audio/mpeg",
:album "tagesschau",
:track 1})

View file

@ -31,11 +31,8 @@
:u "test-user" :u "test-user"
:p "some-random-password"}] :p "some-random-password"}]
(testing "Should give the correct path once the credentials are set" (testing "Should give the correct path once the credentials are set"
(is (= (api/cover-url (:server credentials) (is (= (api/cover-url credentials fixtures/song 48)
(select-keys credentials [:u :p]) (subs/cover-url credentials [:subs/cover-image fixtures/song 48]))))))
fixtures/song
48)
(subs/cover-url [credentials] [:subs/cover-image fixtures/song 48]))))))
(def successful-auth-db (def successful-auth-db
"For the details see event_test.cljs" "For the details see event_test.cljs"