From c8849810db719a278b602a8216bb9df0d96ed3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arne=20Schl=C3=BCter?= Date: Sun, 3 Jun 2018 17:05:52 +0200 Subject: [PATCH] Polish and lipstick (#7) * Add dark sidebar * Add generated covers for items that have none * Fix small spacing issue with generated covers * Set up different sidebar sections and improve styling of bottom bar * Add open-iconic and use icons for playback control buttons * Make sure sidebar always extends to complete height * Simplify album listing view function, add text-overflow to thumbs * Use better identifier for generated covers Makes sure that covers look the same, no matter if generated from an album or individual track * Move shadow-cljs to devDependencies * Display all album titles in a table * Make progress bar take up all available space --- package-lock.json | 18 ++++++ package.json | 15 +++-- src/cljs/airsonic_ui/routes.cljs | 3 +- src/cljs/airsonic_ui/subs.cljs | 18 +++++- src/cljs/airsonic_ui/views.cljs | 39 ++++++++--- src/cljs/airsonic_ui/views/album.cljs | 8 +-- src/cljs/airsonic_ui/views/bottom_bar.cljs | 34 ++++++---- src/cljs/airsonic_ui/views/cover.cljs | 62 ++++++++++++++++-- src/cljs/airsonic_ui/views/icon.cljs | 4 ++ src/cljs/airsonic_ui/views/song.cljs | 12 +++- src/sass/app.sass | 75 +++++++++++++++++++--- 11 files changed, 236 insertions(+), 52 deletions(-) create mode 100644 src/cljs/airsonic_ui/views/icon.cljs diff --git a/package-lock.json b/package-lock.json index d741b7d..4fa7d60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,14 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@hugojosefson/color-hash": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@hugojosefson/color-hash/-/color-hash-2.0.3.tgz", + "integrity": "sha512-ASaDCIwQmyeH6eXdG1Nf2zMOr85Ljp13/8qBSPtYkY1hAr6URRAPG+15i2ogXh/caSolZ4mGfP7MwHPLm/V2Dw==", + "requires": { + "string-hash": "^1.1.3" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4600,6 +4608,11 @@ "wrappy": "1" } }, + "open-iconic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/open-iconic/-/open-iconic-1.1.1.tgz", + "integrity": "sha1-nc/Ix808Yc20ojaxo0eJTJetwMY=" + }, "optimist": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", @@ -5844,6 +5857,11 @@ } } }, + "string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=" + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", diff --git a/package.json b/package.json index c9dca08..dbf6150 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "build:cljs": "shadow-cljs release app", "build:html": "sed 's/\"\\/app\\//\".\\/app\\//g' src/html/index.html > public/index.html", "build:sass": "node-sass --output-style compressed src/sass/app.sass public/app/style.css", - "build": "rm -r public/*; run-p build:*; ", - "deploy": "npm run build && gh-pages -d public", + "build": "rm -r public/*; run-p copy:* build:*", + "copy:icons": "cp -R node_modules/open-iconic/font/fonts public", + "deploy": "npm run build && gh-pages -d public -m \"Deploying $(git rev-parse --short HEAD)\"", "dev:cljs": "shadow-cljs watch app test", "dev:html": "sed 's/\"\\.\\/app\\//\"\\/app\\//g' src/html/index.html > public/index.html", "dev:sass": "npm run build:sass; node-sass -w src/sass/app.sass public/app/style.css", "dev:test": "karma start --reporters growl,progress --auto-watch", - "dev": "npm-run-all test:compile -p dev:*", + "dev": "npm-run-all copy:* test:compile -p dev:*", "test": "run-s test:compile test:run", "test:compile": "shadow-cljs compile test", "test:run": "karma start --single-run" @@ -25,11 +26,12 @@ "url": "git://github.com/heyarne/airsonic-ui.git" }, "dependencies": { + "@hugojosefson/color-hash": "^2.0.3", "bulma": "^0.7.1", "create-react-class": "^15.6.3", + "open-iconic": "^1.1.1", "react": "^16.3.2", - "react-dom": "^16.3.2", - "shadow-cljs": "^2.3.19" + "react-dom": "^16.3.2" }, "devDependencies": { "gh-pages": "^1.1.0", @@ -41,6 +43,7 @@ "npm-run-all": "^4.1.2", "react-flip-move": "^3.0.1", "react-highlight.js": "^1.0.7", - "sass": "^1.3.2" + "sass": "^1.3.2", + "shadow-cljs": "^2.3.19" } } diff --git a/src/cljs/airsonic_ui/routes.cljs b/src/cljs/airsonic_ui/routes.cljs index 654705f..60a9a4c 100644 --- a/src/cljs/airsonic_ui/routes.cljs +++ b/src/cljs/airsonic_ui/routes.cljs @@ -28,7 +28,8 @@ (defmethod route-data ::main [route-id params query] - [:api-request "getAlbumList2" :albumList2 {:type "recent"}]) + [:api-request "getAlbumList2" :albumList2 {:type "recent" + :size 18}]) (defmethod route-data ::artist-view [route-id params query] diff --git a/src/cljs/airsonic_ui/subs.cljs b/src/cljs/airsonic_ui/subs.cljs index a83f1f0..5cfb1a2 100644 --- a/src/cljs/airsonic_ui/subs.cljs +++ b/src/cljs/airsonic_ui/subs.cljs @@ -9,6 +9,11 @@ (fn [db] (select-keys (:credentials db) [:u :p]))) +(re-frame/reg-sub + ::user + (fn [{:keys [credentials]}] + {:name (:u credentials)})) + (re-frame/reg-sub ::server (fn [db] @@ -28,10 +33,19 @@ (re-frame/reg-sub ::current-content (fn [db] - (-> db :response))) + (db :response))) (re-frame/reg-sub ; returns info on the current song as is (basically the metadata you can read from the file system) ::currently-playing (fn [db] - (-> db :currently-playing))) + (db :currently-playing))) + +(re-frame/reg-sub + ::is-playing? + (fn [query-v _] + [(re-frame/subscribe [::currently-playing])]) + (fn [[currently-playing]] + (let [status (:status currently-playing)] + (and (not (:paused? status)) + (not (:ended? status)))))) diff --git a/src/cljs/airsonic_ui/views.cljs b/src/cljs/airsonic_ui/views.cljs index fef34f4..1aa7d6a 100644 --- a/src/cljs/airsonic_ui/views.cljs +++ b/src/cljs/airsonic_ui/views.cljs @@ -28,21 +28,40 @@ [:h2.title "Recently played"] [album/listing (:album content)]]) +(defn sidebar [user] + [:aside.menu.section + [:p.menu-label "Music"] + [:ul.menu-list + [:li [:a "By artist"]] + [:li [:a "Top rated"]] + [:li [:a "Most played"]]] + [:p.menu-label "Playlists"] + [:p.menu-label "Shares"] + [:p.menu-label "Podcasts"] + [:p.menu-label "User area"] + [:ul.menu-list + [:li [:a "Settings"]] + ;; FIXME: Create proper logout event + [:li [:a + {:on-click #(dispatch [::events/initialize-db]) :href "#"} + (str "Logout (" (:name user) ")")]]]]) + ;; putting everything together (defn app [route params query] - (let [login @(subscribe [::subs/login]) + (let [user @(subscribe [::subs/user]) content @(subscribe [::subs/current-content])] [:div - [: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] - (case route - ::routes/main [most-recent content] - ::routes/artist-view [artist-detail content] - ::routes/album-view [album-detail content])] + [:main.columns + [:div.column.is-2.sidebar + [sidebar user]] + [:div.column + [:section.section + [breadcrumbs content] + (case route + ::routes/main [most-recent content] + ::routes/artist-view [artist-detail content] + ::routes/album-view [album-detail content])]]] [bottom-bar]])) (defn main-panel [] diff --git a/src/cljs/airsonic_ui/views/album.cljs b/src/cljs/airsonic_ui/views/album.cljs index 1353294..945db62 100644 --- a/src/cljs/airsonic_ui/views/album.cljs +++ b/src/cljs/airsonic_ui/views/album.cljs @@ -17,7 +17,7 @@ (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]])])]) + [: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-one-half-mobile + [preview album]])]]) diff --git a/src/cljs/airsonic_ui/views/bottom_bar.cljs b/src/cljs/airsonic_ui/views/bottom_bar.cljs index e26a619..bb7f3dc 100644 --- a/src/cljs/airsonic_ui/views/bottom_bar.cljs +++ b/src/cljs/airsonic_ui/views/bottom_bar.cljs @@ -2,40 +2,46 @@ (:require [re-frame.core :refer [dispatch subscribe]] [airsonic-ui.events :as events] [airsonic-ui.subs :as subs] - [airsonic-ui.views.cover :refer [cover]])) + [airsonic-ui.views.cover :refer [cover]] + [airsonic-ui.views.icon :refer [icon]])) ;; currently playing / coming next / audio controls... (defn current-song-info [{:keys [item status]}] [:article [:div (:artist item) " - " (:title item)] + ;; FIXME: Sometimes items don't have a duration [:progress.progress.is-tiny {:value (:current-time status) - :max (:duration item)}]]) + :max (:duration item)}]]) -(defn playback-controls [] +(defn playback-controls [is-playing?] + ;; TODO: Toggle play pause icon based on playback status [: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]) + (let [buttons [[:media-step-backward ::events/previous-song] + [(if is-playing? :media-pause :media-play) ::events/toggle-play-pause] + [:media-step-forward ::events/next-song]]] + (map (fn [[icon-glyph event]] + ^{:key icon-glyph} [:p.control>button.button.is-light + {:on-click #(dispatch [event])} + [icon icon-glyph]]) buttons))]) -(def logo-url "https://airsonic.github.io/airsonic-ui/assets/images/logo/airsonic-dark-350x100.png") + (def logo-url "https://airsonic.github.io/airsonic-ui/assets/images/logo/airsonic-light-350x100.png") (defn bottom-bar [] - (let [currently-playing @(subscribe [::subs/currently-playing])] - [:nav.navbar.is-fixed-bottom + (let [currently-playing @(subscribe [::subs/currently-playing]) + is-playing? @(subscribe [::subs/is-playing?])] + [:nav.navbar.is-fixed-bottom.playback-area [:div.navbar-brand [:div.navbar-item [:img {:src logo-url}]]] [:div.navbar-menu.is-active (if currently-playing ;; show song info - [:section.level + [:section.level.audio-interaction [: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]]] + [:div.level-right [playback-controls is-playing?]]] ;; not playing anything - [:span "Currently no song selected"])]])) + [:p.idle-notification "Currently no song selected"])]])) diff --git a/src/cljs/airsonic_ui/views/cover.cljs b/src/cljs/airsonic_ui/views/cover.cljs index bf0731d..7835978 100644 --- a/src/cljs/airsonic_ui/views/cover.cljs +++ b/src/cljs/airsonic_ui/views/cover.cljs @@ -1,15 +1,69 @@ (ns airsonic-ui.views.cover - (:require [re-frame.core :refer [subscribe]] + (:require [clojure.string :as str] + [re-frame.core :refer [subscribe]] + [reagent.core :as reagent] [airsonic-ui.subs :as subs] - [airsonic-ui.utils.api :as api])) + [airsonic-ui.utils.api :as api] + ["@hugojosefson/color-hash" :as ColorHash])) + +(def color-hash (ColorHash.)) + +(defn palette + "Generate a hsl palette of two colors that's unique for a given item" + [item] + (let [identifier (str (:artistId item) "-" (or (:albumId item) (:id item))) + [h s l] (js->clj (.hsl color-hash identifier)) + s (str (* 100 s) "%") + l (str (* 100 l) "%")] + (->> + [[h s l] + [(mod (+ h (* h 0.3) 10) 360) s l]] + (map #(str "hsl(" (str/join "," %) ")"))))) ;; FIXME: The direct dependency on these subs is a bit ugly +(defn generate-cover [canvas item] + (let [ctx (.getContext canvas "2d") + size (.-clientWidth canvas) + [a b] (palette item) + pad (* 0.02 size) + gradient (doto (.createLinearGradient ctx pad 0 (- size pad) size) + (.addColorStop 0 a) + (.addColorStop 1 b))] + (set! (.-fillStyle ctx) gradient) + (.fillRect ctx 0 0 size size))) + +(defn missing-cover + [item size] + (let [dom-node (reagent/atom nil)] + (reagent/create-class + {:component-did-update + (fn [this] + (let [canvas @dom-node] + (set! (.. canvas -style -width) "100%") + (set! (. canvas -width) (.-offsetWidth canvas)) + (set! (. canvas -height) (.-offsetWidth canvas)) + (generate-cover canvas item))) + + :component-did-mount + (fn [this] + (reset! dom-node (reagent/dom-node this))) + + :reagent-render + (fn [] + @dom-node + [:canvas.missing-cover])}))) + +(defn has-cover? [item] + (:coverArt item)) + (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")}]])) + (if (has-cover? item) + [:img {:src (url size) + :srcSet (str (url size) ", " (url (* 2 size)) " 2x")}] + [missing-cover item size])])) diff --git a/src/cljs/airsonic_ui/views/icon.cljs b/src/cljs/airsonic_ui/views/icon.cljs new file mode 100644 index 0000000..5fd2841 --- /dev/null +++ b/src/cljs/airsonic_ui/views/icon.cljs @@ -0,0 +1,4 @@ +(ns airsonic-ui.views.icon) + +(defn icon [glyph] + [:span.icon [:span.oi {:data-glyph (name glyph)}]]) diff --git a/src/cljs/airsonic_ui/views/song.cljs b/src/cljs/airsonic_ui/views/song.cljs index 0c4c241..98f7f7d 100644 --- a/src/cljs/airsonic_ui/views/song.cljs +++ b/src/cljs/airsonic_ui/views/song.cljs @@ -1,7 +1,8 @@ (ns airsonic-ui.views.song (:require [re-frame.core :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]])) (defn item [songs song] (let [artist-id (:artistId song)] @@ -19,5 +20,10 @@ ;; 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]])]) + [:table.table.is-striped.is-hoverable.is-fullwidth>tbody + (for [[idx song] (map-indexed vector songs)] + ^{:key idx} [:tr + [:td.grow [item songs song]] + ;; FIXME: Not implemented yet + [:td>a {:title "Play next"} [icon :plus]] + [:td>a {:title "Play last"} [icon :arrow-thick-right]]])]) diff --git a/src/sass/app.sass b/src/sass/app.sass index b9bbd35..ff7972c 100644 --- a/src/sass/app.sass +++ b/src/sass/app.sass @@ -1,16 +1,75 @@ @import "../../node_modules/bulma/bulma" +@import "../../node_modules/open-iconic/font/css/open-iconic.scss" + +// area holding content & side navi +#app + main + margin-bottom: 0 + +// navi on the left side +.sidebar + min-height: 100vh + background: $dark + a + color: $light + +.has-navbar-fixed-bottom .sidebar + // 2.5 = 3.25 ($navbar-height) - 0.75 ($padding) + min-height: calc(100vh - 2.5rem) + +// bottom bar +.playback-area + background: $dark + color: $light + + .navbar-menu + align-items: center + + .audio-interaction + flex-grow: 1 + + .level-left + flex-grow: 1 + flex-shrink: 0 + .level-right + flex-grow: 0 + flex-shrink: 1 + padding-left: .5rem + padding-left: .5rem + padding-right: .5rem + + .media + flex-grow: 1 + align-items: center + + progress + width: 100% .progress.is-tiny - height: 0.25rem + height: .25rem +// cover images .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 + .missing-cover + display: block + +.album-preview + .title, + .subtitle + overflow: hidden + white-space: nowrap + text-overflow: ellipsis + + .image.is-256x256 + width: auto + height: auto + max-width: 256px + max-height: 256px + +// occurs in album detail view +.table + .grow + width: 100%