1
0
Fork 0
mirror of https://github.com/heyarne/airsonic-ui.git synced 2026-05-07 02:33:39 +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]
[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 api-request
@ -19,7 +13,7 @@
current app state."
[{:keys [db]} [_ endpoint params]]
{: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})
:on-success [:api/good-response endpoint params]
:on-failure [:api/failed-response endpoint params]}

View file

@ -6,22 +6,25 @@
:c "airsonic-ui-cljs"
:v "1.15.0"})
(defn- encode [c]
(js/encodeURIComponent c))
(def ^:private encode js/encodeURIComponent)
(defn url
"Returns an absolute url to an API endpoint"
[server endpoint params]
(let [query (->> (merge default-params params)
[credentials endpoint 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))))
(str/join "&"))]
(str server (when-not (str/ends-with? server "/") "/") "rest/" endpoint "?" query)))
(defn song-url [server credentials song]
(url server "stream" (merge (select-keys song [:id]) credentials)))
(defn stream-url [credentials song-or-episode]
;; 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]
(url server "getCoverArt" (merge {:id (:coverArt item) :size size} credentials)))
(defn cover-url [credentials item size]
(url credentials "getCoverArt" {:id (:coverArt item) :size size}))
(defn is-error? [response]
(= "failed" (get-in response [:subsonic-response :status])))

View file

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

View file

@ -3,17 +3,13 @@
[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))
{:audio/play (api/stream-url (:credentials db) (playlist/peek playlist))
:db (assoc-in db [:audio :playlist] playlist)})))
;; FIXME: :audio/play might not get the right argument here
@ -34,7 +30,7 @@
(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)})))
:audio/play (api/stream-url (:credentials db) next)})))
(re-frame/reg-event-fx
:audio-player/previous-song
@ -42,7 +38,7 @@
(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)})))
:audio/play (api/stream-url (:credentials db) prev)})))
(re-frame/reg-event-db
:audio-player/enqueue-next

View file

@ -22,17 +22,17 @@
year (conj [:li [icon :calendar] (str "Released in " year)]))))
(defn preview [album]
(defn album-card [album]
(let [{:keys [artist artistId name id]} album]
[card album
:url-fn #(url-for ::routes/album-view {:id id})
:url-fn #(url-for ::routes/album.detail {:id id})
:content [:div
;; link to album
[: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]]
;; 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]]]]))
(defn listing [albums]
@ -40,7 +40,7 @@
[:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector albums)]
^{:key idx} [:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
[preview album]])])
[album-card album]])])
(defn detail
"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
:placeholder "Search"}]]])))
(defn artist-results [{:keys [artist]}]
[:div.columns.is-multiline.is-mobile
(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
[card artist
:url-fn url
@ -33,7 +34,7 @@
(defn album-results [{:keys [album]}]
[:div.columns.is-multiline.is-mobile
(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) ")")]
^{:key idx} [:div.column.is-2 [card album
:url-fn url

View file

@ -67,9 +67,7 @@
the credentials when the request was successful."
[cofx [_ credentials]]
(assoc cofx :http-xhrio {:method :get
:uri (api/url (:server credentials) "getUser"
(merge (select-keys credentials [:u :p])
{:username (:u credentials)}))
:uri (api/url credentials "getUser" {:username (:u credentials)})
:response-format (ajax/json-response-format {:keywords? true})
: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

View file

@ -9,9 +9,11 @@
(r/router [["/" ::login]
["/library" ::library]
["/library/:criteria" ::library]
["/artist/:id" ::artist-view]
["/album/:id" ::album-view]
["/search" ::search]]))
["/artist/:id" ::artist.detail]
["/album/:id" ::album.detail]
["/search" ::search]
["/podcast" ::podcast.overview]
["/podcast/:id" ::podcast.detail]]))
;; use this in views to construct a url
(defn url-for
@ -20,15 +22,20 @@
([k params query] (str "#" (r/resolve router k params query))))
;; 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
;; 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
"Returns the events that take care of correct data being fetched."
(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
[route-id {:keys [criteria]} {:keys [page]}]
@ -37,13 +44,13 @@
[:api/request "getAlbumList2" {:type criteria, :size 20, :offset (* 20 (dec page))}]]
[:routes/do-navigation [route-id {:criteria "recent"} {:page 1}]]))
(defmethod -route-events ::artist-view
(defmethod -route-events ::artist.detail
[route-id params query]
(let [params (select-keys params [:id])]
[[:api/request "getArtist" params]
[:api/request "getArtistInfo2" params]]))
(defmethod -route-events ::album-view
(defmethod -route-events ::album.detail
[route-id params query]
[:api/request "getAlbum" (select-keys params [:id])])
@ -52,6 +59,15 @@
[[:search/restore-term-from-param (:query 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
(defn- n-events?

View file

@ -80,12 +80,12 @@
(defn cover-url
"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."
[[{:keys [server u p]}] [_ song size]]
(api/cover-url server {:u u :p p} song size))
[credentials [_ song size]]
(api/cover-url credentials song size))
(reg-sub
::cover-url
(fn [_ _] [(subscribe [::credentials])])
:<- [::credentials]
cover-url)
;; user notifications

View file

@ -15,58 +15,81 @@
[airsonic-ui.components.search.views :as search]
[airsonic-ui.components.library.views :as library]
[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")
;; ---
;; 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
"Contains search, some navigational links and the logo"
[]
(let [active? (r/atom false)
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])
(let [user @(subscribe [:user/info])
stream-role @(subscribe [:user/roles :stream])
podcast-role @(subscribe [:user/roles :podcast])
playlist-role @(subscribe [:user/roles :playlist])
share-role @(subscribe [:user/roles :share])
settings-role @(subscribe [:user/roles :settings])]
(fn []
[: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
[:div.navbar-brand
[:div.navbar-item>img {:src logo-url}]
[:div.navbar-burger.burger {:on-click toggle-active} (repeat 3 [:span])]]
(when user
[(if @active? :div.navbar-menu.is-active :div.navbar-menu)
[:div.navbar-start
[:div.navbar-item [search/form]]]
[:div.navbar-end
(when stream-role
[:div.navbar-item.has-dropdown.is-hoverable
[:div.navbar-link "Library"]
[:div.navbar-dropdown
[navbar-item {:href (url-for ::routes/library {:criteria "recent"})} "Recently played"]
[navbar-item {:href (url-for ::routes/library {:criteria "newest"})} "Newest additions"]
[navbar-item {:href (url-for ::routes/library {:criteria "starred"})} "Starred"]]])
(when podcast-role
[navbar-item {} "Podcasts"])
(when playlist-role
[navbar-item {} "Playlists"])
(when share-role
[navbar-item {} "Shares"])
[:div.navbar-item.has-dropdown.is-hoverable
[:div.navbar-link "More"]
[:div.navbar-dropdown.is-right
(when settings-role
[navbar-item "Settings"])
[:a.navbar-item
{:on-click (fn [_]
(toggle-active)
(dispatch [::events/logout]))
:href "#"}
(str "Logout (" (:username user) ")")]]]]])])))
[: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
[:div.navbar-brand
[:div.navbar-item>img {:src logo-url}]
[:div.navbar-burger.burger {:on-click toggle-navbar-active!}
(for [idx (range 3)] ^{:key (str "burger-" idx)} [:span])]]
(when user
[(if @navbar-active? :div.navbar-menu.is-active :div.navbar-menu)
[:div.navbar-start
[:div.navbar-item [search/form]]]
[:div.navbar-end
(when stream-role
[navbar-dropdown "Library"
[[{:href (url-for ::routes/library {:criteria "recent"})} "Recently played"]
[{:href (url-for ::routes/library {:criteria "newest"})} "Newest additions"]
[{:href (url-for ::routes/library {:criteria "starred"})} "Starred"]]])
(when podcast-role
(let [podcast-url (url-for ::routes/podcast.overview)]
[navbar-dropdown "Podcast" {:href podcast-url}
[[{:href podcast-url} "Overview"]]]))
(when playlist-role
[navbar-item {} "Playlists"])
(when share-role
[navbar-item {} "Shares"])
[:div.navbar-item.has-dropdown.is-hoverable
[:div.navbar-link "More"]
[:div.navbar-dropdown.is-right
(when settings-role
[navbar-item {} "Settings"])
[:a.navbar-item
{:on-click (fn [_]
(toggle-navbar-active!)
(dispatch [::events/logout]))
:href "#"}
(str "Logout (" (:username user) ")")]]]]])]))
;; ---
;; this is the section the user mainly interacts with
;; ---
(defn media-content
"Provides the complete UI to browse the media library, interact with search
@ -80,12 +103,17 @@
[breadcrumbs content]
(case route-id
::routes/library [library/main [route-id params query] content]
::routes/artist-view [artist/detail content]
::routes/album-view [collection/detail content]
::routes/search [search/results content])]
::routes/artist.detail [artist/detail content]
::routes/album.detail [collection/detail content]
::routes/search [search/results content]
::routes/podcast.overview [podcast/overview content]
::routes/podcast.detail [podcast/detail content])]
[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])
is-booting? @(subscribe [::subs/is-booting?])
[route-id params query] @(subscribe [:routes/current-route])]

View file

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