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

Put on some lipgloss

commit 9fa1a611e2fa093819b332791c28783a4f92a6dc
Author: Arne Schlüter <arne@schlueter.is>
Date:   Fri May 18 00:00:31 2018 +0200

    Add album previews

commit 317a6632b898039e370e3f8d52627e08a8c8186c
Author: Arne Schlüter <arne@schlueter.is>
Date:   Fri May 18 00:00:06 2018 +0200

    Fix cover art url

commit 0ba09903b96f5241853f003f679c0f407243f12a
Author: Arne Schlüter <arne@schlueter.is>
Date:   Thu May 17 22:59:23 2018 +0200

    Add bulma breadcrumbs

commit d21c7c8acc802101ff8ec096d5c6fad90e4f8ea7
Author: Arne Schlüter <arne@schlueter.is>
Date:   Wed May 16 18:55:45 2018 +0200

    Add basic styling to bottom bar

commit 23b37984ca9e8af84767e073492a42bf6c5924ea
Author: Arne Schlüter <arne@schlueter.is>
Date:   Wed May 16 18:21:27 2018 +0200

    Add retina version of cover component

commit d86a44bc5f7b7472f0084c47b691b0f7d151f497
Author: Arne Schlüter <arne@schlueter.is>
Date:   Wed May 16 18:20:47 2018 +0200

    Add info about server address to README

commit 4114581c259e17e3d0342755124e4fa56cd5dd3a
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue May 8 11:51:07 2018 +0200

    Add cover image

commit e6cb0745b366cbce3c25f225d8e008f12fcaae8b
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue May 8 10:49:26 2018 +0200

    Move views to their own namespaces

commit 5c24a47cc07b347beedd972e32ec145348a82d65
Author: Arne Schlüter <arne@schlueter.is>
Date:   Mon May 7 18:10:00 2018 +0200

    Add login styling

commit e04e0505f684d99316bdb3e875c403af2c28c127
Author: Arne Schlüter <arne@schlueter.is>
Date:   Sun May 6 11:19:08 2018 +0200

    Add gh-pages deploy script

commit e1a4cb4bb646def28989100f2084990863160dd9
Author: Arne Schlüter <arne@schlueter.is>
Date:   Sun May 6 00:43:51 2018 +0200

    Add bulma
This commit is contained in:
Arne Schlüter 2018-05-28 12:29:42 +02:00
commit 8d24c1b42a
15 changed files with 2544 additions and 612 deletions

View file

@ -21,7 +21,7 @@ To build the project make sure you have Node.js (v6.0.0), npm and Java 8 install
``` ```
# after cloning the project, first install all dependencies # after cloning the project, first install all dependencies
$ npm install $ npm install
# start a continuous build with hot-code-reloading; first build takes a while # start a continuous build with hot-code-reloading; first build takes a while. open http://localhost:8080
$ npm run dev $ npm run dev
# build and optimize the code once for production # build and optimize the code once for production
$ npm run build $ npm run build

2623
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,19 +5,33 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "shadow-cljs watch app", "build:cljs": "shadow-cljs release app",
"build": "rm -r public/app/js; shadow-cljs release app" "build:sass": "node-sass --output-style compressed sass/app.sass public/app/style.css | sed 's/^/sass - /'",
"build": "rm -r public/app/*; run-p build:*; sed -i '' 's/\"\\/app\\//\".\\/app\\//g' public/index.html",
"dev:cljs": "shadow-cljs watch app",
"dev:sass": "{ node-sass sass/app.sass public/app/style.css; node-sass -w sass/app.sass public/app/style.css; } | sed 's/^/sass - /'",
"dev": "sed -i '' 's/\"\\.\\/app\\//\"\\/app\\//g' public/index.html; run-p dev:*",
"deploy": "gh-pages -d public"
}, },
"author": "Arne Schlüter", "author": "Arne Schlüter",
"license": "ISC", "license": "ISC",
"repository": {
"type": "git",
"url": "git://github.com/heyarne/airsonic-ui.git"
},
"dependencies": { "dependencies": {
"bulma": "^0.7.1",
"create-react-class": "^15.6.3", "create-react-class": "^15.6.3",
"react": "^16.3.2", "react": "^16.3.2",
"react-dom": "^16.3.2", "react-dom": "^16.3.2",
"shadow-cljs": "^2.3.19" "shadow-cljs": "^2.3.19"
}, },
"devDependencies": { "devDependencies": {
"gh-pages": "^1.1.0",
"node-sass": "^4.9.0",
"npm-run-all": "^4.1.2",
"react-flip-move": "^3.0.1", "react-flip-move": "^3.0.1",
"react-highlight.js": "^1.0.7" "react-highlight.js": "^1.0.7",
"sass": "^1.3.2"
} }
} }

View file

@ -1,11 +1,13 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<title>Airsonic UI</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Airsonic</title>
<link rel="stylesheet" href="/app/style.css">
</head> </head>
<body> <body>
<div id="app"> </div> <div id="app"></div>
<script src="/app/js/main.js"></script> <script src="/app/js/main.js"></script>
<script>airsonic_ui.core.init()</script> <script>airsonic_ui.core.init()</script>
</body> </body>

16
sass/app.sass Normal file
View file

@ -0,0 +1,16 @@
@import "../node_modules/bulma/bulma"
.progress.is-tiny
height: 0.25rem
.image.is-256x256
// for cover images
width: 256px
height: 256px
.album-preview .image.is-256x256
// make sure the grid doesn't overflow
width: auto
height: auto
max-width: 256px
max-height: 256px

View file

@ -17,7 +17,10 @@
(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 song-url [server credentials song]
(url server "stream" (merge {:id (:id song)} credentials))) (url server "stream" (merge (select-keys song [:id]) credentials)))
(defn cover-url [server credentials item size]
(url server "getCoverArt" (merge {:id (:coverArt item) :size size} credentials)))
(defn- api-error? (defn- api-error?
"We need to look at the message body because the subsonic api always responds "We need to look at the message body because the subsonic api always responds

View file

@ -44,11 +44,20 @@
(assoc :login login)) (assoc :login login))
:dispatch [::logged-in]}))) :dispatch [::logged-in]})))
;; 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"))))
;; we do this in two steps to make sure the credentials are set once we navigate ;; we do this in two steps to make sure the credentials are set once we navigate
(re-frame/reg-event-fx (re-frame/reg-event-fx
::logged-in ::logged-in
(fn [_ _] (fn [_ _]
{:routes/navigate [::routes/main]})) {:routes/navigate [::routes/main]
:show-nav-bar nil}))
;; TODO: Test that credentials are actually taken ;; TODO: Test that credentials are actually taken
;; TODO: Move these in the future? events.cljs should just do wiring. We could ;; TODO: Move these in the future? events.cljs should just do wiring. We could

View file

@ -2,12 +2,18 @@
(:require [re-frame.core :as re-frame])) (:require [re-frame.core :as re-frame]))
;; can be used to query the user's credentials ;; can be used to query the user's credentials
;; TODO: Organize login credentials and server location differently (i.e. together)
(re-frame/reg-sub (re-frame/reg-sub
::login ::login
(fn [db] (fn [db]
(:login db))) (:login db)))
(re-frame/reg-sub
::server
(fn [db]
(:server db)))
;; current hashbang ;; current hashbang
(re-frame/reg-sub (re-frame/reg-sub

View file

@ -1,138 +1,32 @@
(ns airsonic-ui.views (ns airsonic-ui.views
(:require [re-frame.core :refer [dispatch subscribe]] (:require [re-frame.core :refer [dispatch subscribe]]
[reagent.core :as r]
[airsonic-ui.config :as config] [airsonic-ui.config :as config]
[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]
[airsonic-ui.subs :as subs])) [airsonic-ui.subs :as subs]
(defn- >reset! [airsonic-ui.views.breadcrumbs :refer [breadcrumbs]]
"Sends all target values to the given atom" [airsonic-ui.views.bottom-bar :refer [bottom-bar]]
[atom] [airsonic-ui.views.login :refer [login-form]]
#(reset! atom (.. % -target -value))) [airsonic-ui.views.album :as album]
[airsonic-ui.views.song :as song]))
;; login form ;; TODO: Find better names and places for these.
(defn login-form []
(let [user (r/atom "")
pass (r/atom "")
server (r/atom config/server)]
(fn []
[:div
[:div
[:span "User"]
[:input {:type "text"
:name "user"
:on-change (>reset! user)}]]
[:div
[:span "Password"]
[:input {:type "password"
:name "pass"
:on-change (>reset! pass)}]]
[:div
[:span "Server"]
[:input {:type "text"
:name "server"
:on-change (>reset! server)
:value @server}]]
[:div
[:button {:on-click #(dispatch [::events/authenticate @user @pass @server])} "Submit"]]])))
;; single album
(defn song-item [songs song]
(let [artist-id (:artistId song)]
[:div
[:a
(when artist-id {:href (url-for ::routes/artist-view {:id artist-id})})
(:artist song)]
" - "
[:a
{:href "#" :on-click (fn [e]
(.preventDefault e)
(dispatch [::events/play-songs songs song]))}
(:title song)]]))
(defn album-detail [content] (defn album-detail [content]
[:div [:div
[:h2 (str (:artist content) " - " (:name content))] [:h2.title (str (:artist content) " - " (:name content))]
(let [songs (:song content)] [song/listing (:song content)]])
[:ul (for [[idx song] (map-indexed vector songs)]
[:li {:key idx} [song-item songs song]])])])
;; single artist
(defn album-item [album]
(let [{:keys [artist artistId name coverArt year id]} album]
[:div
;; link to artist page
[:a {:href (url-for ::routes/artist-view {:id artistId})} artist]
" - "
;; link to album
[:a {:href (url-for ::routes/album-view {:id id})} name] (when year (str " (" year ")"))]))
(defn artist-detail [content] (defn artist-detail [content]
[:div [:div
[:h2 (:name content)] [:h2.title (:name content)]
[:ul (for [[idx album] (map-indexed vector (:album content))] [album/listing (:album content)]])
[:li {:key idx} [album-item album]])]])
;; TODO: album-list shouldn't know about the structure of content and should just get a list
(defn most-recent [content] (defn most-recent [content]
[:div [:div
[:h2 "Recently played"] [:h2.title "Recently played"]
[:ul (for [[idx album] (map-indexed vector (:album content))] [album/listing (:album content)]])
[:li {:key idx} [album-item album]])]])
;; top navigation
(defn content-type
"Helper to see what kind of server response"
[content]
(cond
(and (vector? (:album content)) (:id content)) :artist
(vector? (:song content)) :album
:else :unknown-content))
(defmulti breadcrumbs content-type)
(defmethod breadcrumbs :default [content]
[:div [:span "Start"]])
(defmethod breadcrumbs :artist [content]
[:div
[:span [:a {:href (url-for ::routes/main)} "Start"]]
[:span " · " (:name content)]])
(defmethod breadcrumbs :album [content]
[:div
[:span [:a {:href (url-for ::routes/main)} "Start"]]
[:span " · " [:a {:href (url-for ::routes/artist-view {:id (:artistId content)})} (:artist content)]]
[:span " · " (:name content)]])
;; currently playing / coming next / audio controls...
(defn current-song-info [{:keys [item status]}]
[:div
[:b "Currently playing: "]
[:div (:artist item) " - " (:title item)]
[:div (:current-time status) "s / " (:duration item) "s"]])
(defn playback-controls []
[:div
[:button {:on-click #(dispatch [::events/previous-song])} "previous"]
[:button {:on-click #(dispatch [::events/toggle-play-pause])} "play / pause"]
[:button {:on-click #(dispatch [::events/next-song])} "next"]
[:label [:input {:type "checkbox"}] "shuffle"]
[:label [:input {:type "checkbox"}] "repeat"]])
(defn bottom-bar []
[:div
(if-let [currently-playing @(subscribe [::subs/currently-playing])]
[current-song-info currently-playing]
[:span "Currently no song selected"])
[playback-controls]])
;; putting everything together ;; putting everything together
@ -140,19 +34,19 @@
(let [login @(subscribe [::subs/login]) (let [login @(subscribe [::subs/login])
content @(subscribe [::subs/current-content])] content @(subscribe [::subs/current-content])]
[:div [:div
[:span (str "Currently logged in as " (:u login))] [:section.section>div.container
[:div.level
[:div.level-left [:span (str "Currently logged in as " (:u login))]]
[:div.level-right [:a {:on-click #(dispatch [::events/initialize-db]) :href "#"} "Logout"]]]
[breadcrumbs content] [breadcrumbs content]
(case route (case route
::routes/main [most-recent content] ::routes/main [most-recent content]
::routes/artist-view [artist-detail content] ::routes/artist-view [artist-detail content]
::routes/album-view [album-detail content]) ::routes/album-view [album-detail content])]
[:a {:on-click #(dispatch [::events/initialize-db]) :href "#"} "Logout"]
[bottom-bar]])) [bottom-bar]]))
(defn main-panel [] (defn main-panel []
(let [[route params query] @(subscribe [::subs/current-route])] (let [[route params query] @(subscribe [::subs/current-route])]
[:div
[:h1 "Airsonic"]
(case route (case route
::routes/login [login-form] ::routes/login [login-form]
[app route params query])])) [app route params query])))

View file

@ -0,0 +1,23 @@
(ns airsonic-ui.views.album
(:require [airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.views.cover :refer [cover]]))
(defn preview [album]
(let [{:keys [artist artistId name coverArt id]} album]
[:article.card.album-preview
[:div.card-image
[:a {:href (url-for ::routes/album-view {:id id})} [cover album 256]]]
[:div.card-content
;; link to album
[:div.title.is-5
[:a {:href (url-for ::routes/album-view {:id id})} name]]
;; link to artist page
[:div.subtitle.is-6 [:a {:href (url-for ::routes/artist-view {:id artistId})} artist]]]]))
(defn listing [albums]
;; always show 5 in a row
[:div
(for [albums (partition-all 5 albums)]
[:div.columns
(for [[idx album] (map-indexed vector albums)]
[:div.column {:key idx} [preview album]])])])

View file

@ -0,0 +1,41 @@
(ns airsonic-ui.views.bottom-bar
(:require [re-frame.core :refer [dispatch subscribe]]
[airsonic-ui.events :as events]
[airsonic-ui.subs :as subs]
[airsonic-ui.views.cover :refer [cover]]))
;; currently playing / coming next / audio controls...
(defn current-song-info [{:keys [item status]}]
[:article
[:div (:artist item) " - " (:title item)]
[:progress.progress.is-tiny {:value (:current-time status)
:max (:duration item)}]])
(defn playback-controls []
[:div.field.has-addons
(let [buttons [["previous" ::events/previous-song]
["play / pause" ::events/toggle-play-pause]
["next" ::events/next-song]]]
(map (fn [[label event]]
[:p.control>button.button.is-light {:on-click #(dispatch [event])} label])
buttons))])
(def logo-url "https://airsonic.github.io/airsonic-ui/assets/images/logo/airsonic-dark-350x100.png")
(defn bottom-bar []
(let [currently-playing @(subscribe [::subs/currently-playing])]
[:nav.navbar.is-fixed-bottom
[:div.navbar-brand
[:div.navbar-item
[:img {:src logo-url}]]]
[:div.navbar-menu.is-active
(if currently-playing
;; show song info
[:section.level
[:div.level-left>article.media
[:div.media-left [cover (:item currently-playing) 48]]
[:div.media-content [current-song-info currently-playing]]]
[:div.level-right [playback-controls]]]
;; not playing anything
[:span "Currently no song selected"])]]))

View file

@ -0,0 +1,37 @@
(ns airsonic-ui.views.breadcrumbs
(:require [airsonic-ui.routes :as routes :refer [url-for]]))
;; Breadcrumbs are implemented in such a way that they provide a stringent
;; hierarchy no matter how you came to the url. They should allow easy
;; navigation upwards that hierarchy (e.g. album -> artist)
(defn content-type
"Helper to see what kind of server response"
[content]
(cond
(and (vector? (:album content)) (:id content)) :artist
(vector? (:song content)) :album
:else :unknown-content))
(defn- bulma-breadcrumbs [& items]
[:nav.breadcrumb {:aria-label "breadcrumbs"}
[:ul
(for [[idx [href label]] (map-indexed vector (butlast items))]
[:li {:key idx} [:a {:href href} label]])
[:li.is-active>a (last items)]]])
(defmulti breadcrumbs content-type)
(defmethod breadcrumbs :default [content]
[bulma-breadcrumbs "Start"])
(defmethod breadcrumbs :artist [content]
[bulma-breadcrumbs
[(url-for ::routes/main) "Start"]
(:name content)])
(defmethod breadcrumbs :album [content]
[bulma-breadcrumbs
[(url-for ::routes/main) "Start"]
[(url-for ::routes/artist-view {:id (:artistId content)}) (:artist content)]
(:name content)])

View file

@ -0,0 +1,15 @@
(ns airsonic-ui.views.cover
(:require [re-frame.core :refer [subscribe]]
[airsonic-ui.subs :as subs]
[airsonic-ui.api :as api]))
;; FIXME: The direct dependency on these subs is a bit ugly
(defn cover
[item size]
(let [server @(subscribe [::subs/server])
login @(subscribe [::subs/login])
url (partial api/cover-url server login item)]
[:figure {:class-name (str "image is-" size "x" size)}
[:img {:src (url size)
:srcset (str (url size) ", " (url (* 2 size)) " 2x")}]]))

View file

@ -0,0 +1,42 @@
(ns airsonic-ui.views.login
(:require [reagent.core :as r]
[re-frame.core :refer [dispatch]]
[airsonic-ui.events :as events]))
(defn- >reset!
"Sends an event's target values to the given atom"
[atom]
#(reset! atom (.. % -target -value)))
;; login form
(defn login-form []
(let [user (r/atom "")
pass (r/atom "")
server (r/atom (.. js/window -location -origin))
submit (fn [e]
(.preventDefault e)
(dispatch [::events/authenticate @user @pass @server]))]
(fn []
[:section.hero.is-fullheight>div.hero-body
[:div.container.has-text-centered>div.column.is-4.is-offset-4
[:h3.title.has-text-grey "Airsonic"]
[:p.subtitle.has-text-grey "Please login to proceed"]
[:div.box
[:form {:on-submit submit}
[:div.field>div.control
[:input.input.is-large {:type "text"
:name "user"
:placeholder "Username"
:on-change (>reset! user)}]]
[:div.field>div.control
[:input.input.is-large {:type "password"
:name "pass"
:placeholder "Password"
:on-change (>reset! pass)}]]
[:div.field>div.control
[:input.input.is-large {:type "text"
:name "server"
:on-change (>reset! server)
:value @server}]]
[:button.button.is-block.is-info.is-large.is-fullwidth {:type "submit"} "Submit"]]]]])))

View file

@ -0,0 +1,23 @@
(ns airsonic-ui.views.song
(:require [re-frame.core :refer [dispatch]]
[airsonic-ui.events :as events]
[airsonic-ui.routes :as routes :refer [url-for]]))
(defn item [songs song]
(let [artist-id (:artistId song)]
[:div
[:a
(when artist-id {:href (url-for ::routes/artist-view {:id artist-id})})
(:artist song)]
" - "
[:a
{:href "#" :on-click (fn [e]
(.preventDefault e)
(dispatch [::events/play-songs songs song]))}
(:title song)]]))
;; FIXME: This is very similar to album-listing
(defn listing [songs]
[:ul (for [[idx song] (map-indexed vector songs)]
[:li {:key idx} [item songs song]])])