diff --git a/package-lock.json b/package-lock.json index 1588dbc..d553c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,7 +207,7 @@ "dependencies": { "util": { "version": "0.10.3", - "resolved": "http://registry.npmjs.org/util/-/util-0.10.3.tgz", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", "dev": true, "requires": { @@ -482,7 +482,7 @@ }, "browserify-aes": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "dev": true, "requires": { @@ -527,7 +527,7 @@ }, "browserify-rsa": { "version": "4.0.1", - "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "dev": true, "requires": { @@ -561,7 +561,7 @@ }, "buffer": { "version": "4.9.1", - "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "dev": true, "requires": { @@ -947,7 +947,7 @@ }, "create-hash": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "dev": true, "requires": { @@ -960,7 +960,7 @@ }, "create-hmac": { "version": "1.1.7", - "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "dev": true, "requires": { @@ -1154,7 +1154,7 @@ }, "diffie-hellman": { "version": "5.0.3", - "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "dev": true, "requires": { @@ -1353,7 +1353,7 @@ }, "events": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", "dev": true }, @@ -2610,7 +2610,7 @@ }, "http-errors": { "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", "dev": true, "requires": { @@ -3414,7 +3414,7 @@ }, "media-typer": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "dev": true }, @@ -4084,7 +4084,7 @@ }, "parse-asn1": { "version": "5.1.1", - "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", "dev": true, "requires": { @@ -4694,7 +4694,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "requires": { @@ -4805,7 +4805,7 @@ }, "sha.js": { "version": "2.4.11", - "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "dev": true, "requires": { @@ -5222,7 +5222,7 @@ }, "stream-browserify": { "version": "2.0.1", - "resolved": "http://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", "dev": true, "requires": { diff --git a/src/cljs/airsonic_ui/api/helpers.cljs b/src/cljs/airsonic_ui/api/helpers.cljs index 6959015..8483861 100644 --- a/src/cljs/airsonic_ui/api/helpers.cljs +++ b/src/cljs/airsonic_ui/api/helpers.cljs @@ -6,16 +6,29 @@ :c "airsonic-ui-cljs" :v "1.15.0"}) +(defn- unroll-variadic-params + "Turns {:id [1 2 3], :foo :bar} into [[:id 1] [:id 2] [:id 3] [:foo :bar]]" + [params] + (->> + (map (fn [[k vs]] + (if (sequential? vs) + (map (fn [v] [k v]) vs) + [k vs])) params) + (flatten) + (partition 2))) + (def ^:private encode js/encodeURIComponent) (defn url "Returns an absolute url to an API endpoint" [credentials endpoint params] (let [server (:server credentials) - query (->> (merge default-params (select-keys credentials [:u :p]) params) + auth (select-keys credentials [:u :p]) + query (->> (merge default-params auth params) + (unroll-variadic-params) (map (fn [[k v]] (str (encode (name k)) "=" (encode v)))) (str/join "&"))] - (str server (when-not (str/ends-with? server "/") "/") "rest/" endpoint "?" query))) + (str (str/replace server #"/+$" "") "/rest/" endpoint "?" query))) (defn stream-url [credentials song-or-episode] ;; podcasts have a stream-id, normal songs just use their id @@ -63,4 +76,3 @@ #{:artistId :name :songCount :artist} :album #{:id :name :albumCount} :artist :unknown))) - diff --git a/src/cljs/airsonic_ui/components/audio_player/views.cljs b/src/cljs/airsonic_ui/components/audio_player/views.cljs index 7f94cac..4959634 100644 --- a/src/cljs/airsonic_ui/components/audio_player/views.cljs +++ b/src/cljs/airsonic_ui/components/audio_player/views.cljs @@ -1,114 +1,88 @@ (ns airsonic-ui.components.audio-player.views (:require [re-frame.core :refer [subscribe dispatch]] [airsonic-ui.routes :as routes] - [airsonic-ui.components.highres-canvas.views :refer [canvas]] - [airsonic-ui.helpers :refer [add-classes muted-dispatch]] + [airsonic-ui.helpers :as h] [airsonic-ui.views.cover :refer [cover]] [airsonic-ui.views.icon :refer [icon]])) ;; currently playing / coming next / audio controls... -;; FIXME: Sometimes items don't have a duration - -(def progress-bar-color "rgb(93,93,93)") -(def progress-bar-color-buffered "rgb(143,143,143)") -(def progress-bar-color-active "whitesmoke") - -(defn draw-progress [ctx current-time buffered duration] - (let [width (.. ctx -canvas -clientWidth) - height (.. ctx -canvas -clientHeight) - padding 5 - buffered-x (+ padding (* (- width (* 2 padding)) (min 1 (/ buffered duration)))) - current-x (+ padding (* (- width (* 2 padding)) (min 1 (/ current-time duration))))] - ;; vertically center everything - (.translate ctx 0.5 (+ (Math/ceil (/ height 2)) 0.5)) - ;; draw complete bar - (set! (.-strokeStyle ctx) progress-bar-color) - (doto ctx - (.beginPath) - (.moveTo padding 0) - (.lineTo (- width (* 2 padding)) 0) - (.stroke)) - ;; draw the buffered part - (set! (.-strokeStyle ctx) progress-bar-color-buffered) - (doto ctx - (.beginPath) - (.moveTo padding 0) - (.lineTo buffered-x 0) - (.stroke)) - ;; draw the part that's already played - (set! (.-strokeStyle ctx) progress-bar-color-active) - (doto ctx - (.beginPath) - (.moveTo padding 0) - (.lineTo current-x 0) - (.stroke)) - ;; draw a dot marking the current time - (set! (.-fillStyle ctx) progress-bar-color-active) - (doto ctx - (.beginPath) - (.arc current-x 0 (/ padding 2) 0 (* Math/PI 2)) - (.fill)))) - -(defn current-progress [current-time buffered duration] - [canvas {:class "current-progress-canvas" - :draw #(draw-progress % current-time buffered duration)}]) - -;; FIXME: It's ugly to have the canvas padding and styling scattered everywhere (sass, drawing code above, and here) (defn seek "Calculates the position of the click and sets current playback accordingly" [ev] - (let [x (- (.. ev -nativeEvent -pageX) - (.. ev -target getBoundingClientRect -left)) - width (- (.. ev -target -nextElementSibling -clientWidth) 10)] ;; <- 10 = 2 * canvas-padding - (dispatch [:audio-player/seek (/ x width)]))) + (let [x-ratio (/ (.. ev -nativeEvent -layerX) + (.. ev -target -parentElement getBoundingClientRect -width))] + (dispatch [:audio-player/seek x-ratio]))) + +(defn- ratio->width [ratio] + (str (.toFixed (min 100 (* 100 ratio)) 2) "%")) -(defn buffered-part - [buffered duration] - (let [width (min 100 (* (/ buffered duration) 100))] - [:div.buffered-part {:on-click seek - :style {:width (str "calc(" width "% - 1rem - 10px)")}}])) +(defn progress-bars [buffered-width played-width] + [:svg.progress-bars {:aria-hidden "true"} + [:svg.complete-song-bar + [:rect {:x 0, :y "50%", :width "100%", :height 1}]] + [:svg.buffered-part-bar + [:rect.click-dummy {:on-click seek + :x 0, :y 0, :width buffered-width, :height "100%"}] + [:rect {:x 0, :y "50%", :width buffered-width, :height 1}]] + [:svg.played-back-bar + [:rect {:x 0, :y "50%", :width played-width, :height 1}] + [:circle {:cx played-width, :cy "50%", :r 2.5}]]]) -(defn current-song-info [song status] +(defn progress-indicators [song status] (let [current-time (:current-time status) buffered (:buffered status) - duration (:duration song)] - [:article.current-song-info - [:div.current-name (:artist song) [:br] (:title song)] - [:div.current-progress - [buffered-part buffered duration] - [current-progress current-time buffered duration]]])) + duration (:duration song) + progress-text (str (h/format-duration current-time :brief? true) + " / " + (h/format-duration duration :brief? true)) + buffered-width (ratio->width (/ buffered duration)) + played-width (ratio->width (/ current-time duration))] + [:article.progress-indicators + [progress-bars buffered-width played-width] + [:div.progress-info-text.duration-text progress-text]])) + +(defn playback-info [song status] + [:a.playback-info.media + {:href (routes/url-for ::routes/current-queue) + :title "Go to current queue"} + [:div.media-left [cover song 64]] + [:div.media-content + [:div.artist-and-title + [:span.artist(:artist song)] + [:span.song-title (:title song)]]]]) -(defn song-controls [is-playing?] - [:div.field.has-addons - (let [buttons [[:media-step-backward :audio-player/previous-song] - [(if is-playing? :media-pause :media-play) :audio-player/toggle-play-pause] - [:media-step-forward :audio-player/next-song]] - title {:media-step-backward "Previous" - :media-play "Play" - :media-pause "Pause" - :media-step-forward "Next"}] - (for [[icon-glyph event] buttons] - ^{:key icon-glyph} [:p.control [:button.button.is-light - {:on-click (muted-dispatch [event]) - :title (title icon-glyph)} - [icon icon-glyph]]]))]) +(defn playback-controls [is-playing?] + [:div.playback-controls + [:div.field.has-addons + (let [buttons [[:media-step-backward :audio-player/previous-song] + [(if is-playing? :media-pause :media-play) :audio-player/toggle-play-pause] + [:media-step-forward :audio-player/next-song]] + title {:media-step-backward "Previous" + :media-play "Play" + :media-pause "Pause" + :media-step-forward "Next"}] + (for [[icon-glyph event] buttons] + ^{:key icon-glyph} [:p.control [:button.button.is-light + {:on-click (h/muted-dispatch [event]) + :title (title icon-glyph)} + [icon icon-glyph]]]))]]) (defn- toggle-shuffle [playback-mode] - (muted-dispatch [:audio-player/set-playback-mode (if (= playback-mode :shuffled) + (h/muted-dispatch [:audio-player/set-playback-mode (if (= playback-mode :shuffled) :linear :shuffled)])) (defn- toggle-repeat-mode [current-mode] (let [modes (cycle '(:repeat-none :repeat-all :repeat-single)) next-mode (->> (drop-while (partial not= current-mode) modes) (second))] - (muted-dispatch [:audio-player/set-repeat-mode next-mode]))) + (h/muted-dispatch [:audio-player/set-repeat-mode next-mode]))) (defn playback-mode-controls [playlist] (let [{:keys [repeat-mode playback-mode]} playlist button :p.control>button.button.is-light - shuffle-button (add-classes button (when (= playback-mode :shuffled) :is-primary)) - repeat-button (add-classes button (case repeat-mode + shuffle-button (h/add-classes button (when (= playback-mode :shuffled) :is-primary)) + repeat-button (h/add-classes button (case repeat-mode :repeat-single :is-info :repeat-all :is-primary nil)) @@ -116,28 +90,25 @@ :repeat-all "Repeating current queue, click to repeat current track" :repeat-single "Repeating current track, click to repeat none" "Click to repeat current queue")] - [:div.field.has-addons - ^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode) - :title "Shuffle"} [icon :random]] - ^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode) - :title repeat-title} [icon :loop]]])) + [:div.playback-mode-controls + [:div.button-group>div.field.has-addons + ^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode) + :title "Shuffle"} [icon :random]] + ^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode) + :title repeat-title} [icon :loop]]]])) (defn audio-player [] (let [current-song @(subscribe [:audio/current-song]) playlist @(subscribe [:audio/playlist]) playback-status @(subscribe [:audio/playback-status]) is-playing? @(subscribe [:audio/is-playing?])] - [:nav.navbar.is-fixed-bottom.audio-player - [:div.navbar-menu.is-active - (if current-song - ;; show song info - [:section.level.audio-interaction - [:div.level-left>article.media - [:div.media-left [cover current-song 48]] - [:div.media-content [current-song-info current-song playback-status]]] - [:div.level-right - [:div.button-group [:p.control>a.button.is-light {:href (routes/url-for ::routes/current-queue) :title "Go to current queue"} [icon :menu]]] - [:div.button-group [song-controls is-playing?]] - [:div.button-group [playback-mode-controls playlist]]]] - ;; not playing anything - [:p.navbar-item.idle-notification "No audio playing"])]])) + [:nav.audio-player + (if current-song + ;; show song info, controls, progress bar, etc. + [:section.audio-interaction + [playback-info current-song playback-status] + [progress-indicators current-song playback-status] + [playback-controls is-playing?] + [playback-mode-controls playlist]] + ;; not playing anything + [:p.navbar-item.idle-notification "No audio playing"])])) diff --git a/src/cljs/airsonic_ui/components/collection/views.cljs b/src/cljs/airsonic_ui/components/collection/views.cljs index afaa427..ce98c0e 100644 --- a/src/cljs/airsonic_ui/components/collection/views.cljs +++ b/src/cljs/airsonic_ui/components/collection/views.cljs @@ -40,7 +40,7 @@ [:div [:section.hero.is-small>div.hero-body [:div.container - [:article.media + [:article.collection-header.media [:div.media-left [cover album 128]] [:div.media-content [:h2.title (:name album)] diff --git a/src/cljs/airsonic_ui/components/search/views.cljs b/src/cljs/airsonic_ui/components/search/views.cljs index f703e8b..bd3fdeb 100644 --- a/src/cljs/airsonic_ui/components/search/views.cljs +++ b/src/cljs/airsonic_ui/components/search/views.cljs @@ -19,28 +19,27 @@ :default-value search-term :placeholder "Search"}]]]))) +(defn result-cards [items] + [:div.columns.is-multiline.is-mobile + (for [[url item] items] + ^{:key url} [:div.column.is-one-fifth-tablet.is-one-third-mobile + [card item + :url-fn (constantly url) + :content [:div>a + {:href url, :title (:name item)} + (:name item)]]])]) + +(defn- artist-url [artist] + (url-for ::routes/artist.detail (select-keys artist [:id]))) (defn artist-results [{:keys [artist]}] - [:div.columns.is-multiline.is-mobile - (for [[idx artist] (map-indexed vector artist)] - (let [url #(url-for ::routes/artist.detail (select-keys % [:id]))] - ^{:key idx} [:div.column.is-2 - [card artist - :url-fn url - :content [:div>a - {:href (url artist), :title (:name artist)} - (:name artist)]]]))]) + [result-cards (map (juxt artist-url identity) artist)]) + +(defn- album-url [album] + (url-for ::routes/album.detail (select-keys album [:id]))) (defn album-results [{:keys [album]}] - [:div.columns.is-multiline.is-mobile - (for [[idx album] (map-indexed vector album)] - (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 - :content [:div>a - {:href (url album), :title title} - title]]]))]) + [result-cards (map (juxt album-url identity) album)]) (defn song-results [{:keys [song]}] [song/listing song]) diff --git a/src/cljs/airsonic_ui/helpers.cljs b/src/cljs/airsonic_ui/helpers.cljs index 046801c..6ed4015 100644 --- a/src/cljs/airsonic_ui/helpers.cljs +++ b/src/cljs/airsonic_ui/helpers.cljs @@ -1,7 +1,8 @@ (ns airsonic-ui.helpers "Assorted helper functions" (:require [re-frame.core :as rf] - [clojure.string :as str])) + [clojure.string :as str]) + (:import [goog.string format])) (defn find-where "Returns the the first item in `coll` with its index for which `(p song)` @@ -35,11 +36,22 @@ (str/lower-case) (keyword))) -(defn format-duration [seconds] - (let [hours (quot seconds 3600) - minutes (quot (rem seconds 3600) 60) - seconds (rem seconds 60)] - (-> (cond-> "" - (> hours 0) (str hours "h ") - (> minutes 0) (str minutes "m ")) - (str seconds "s")))) +(defn- brief-duration [hours minutes seconds] + (str (when (> hours 0) + (format "%02d:" hours)) + (format "%02d:%02d" minutes seconds))) + +(defn- long-duration [hours minutes seconds] + (str/trim + (cond-> "" + (> hours 0) (str hours "h ") + (> minutes 0) (str minutes "m ") + (> seconds 0) (str seconds "s")))) + +(defn format-duration [seconds & {:keys [brief?]}] + (let [hours (Math/round (quot seconds 3600)) + minutes (Math/round (quot (rem seconds 3600) 60)) + seconds (Math/round (rem seconds 60))] + (if brief? + (brief-duration hours minutes seconds) + (long-duration hours minutes seconds)))) diff --git a/src/cljs/airsonic_ui/views/cover.cljs b/src/cljs/airsonic_ui/views/cover.cljs index 873ba50..626fc01 100644 --- a/src/cljs/airsonic_ui/views/cover.cljs +++ b/src/cljs/airsonic_ui/views/cover.cljs @@ -1,7 +1,6 @@ (ns airsonic-ui.views.cover (:require [re-frame.core :refer [subscribe]] [airsonic-ui.subs :as subs] - [airsonic-ui.components.highres-canvas.views :refer [canvas]] ["@hugojosefson/color-hash" :as ColorHash])) (def color-hash (ColorHash.)) @@ -10,36 +9,28 @@ (str "hsl(" h "," (* 100 s) "%," (* 100 l) "%)")) (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))] + "Generate a unique hsl palette of two colors" + [identifier] + (let [[h s l] (js->clj (.hsl color-hash identifier))] [(hsl->css h s l) (hsl->css (mod (+ h (* h 0.3) 10) 360) s l)])) -(defn generate-cover [ctx item] - (let [[a b] (palette item) - size (.. ctx -canvas -offsetWidth) - pad (* 0.02 size) - gradient (doto (.createLinearGradient ctx pad 0 (- size pad) size) - (.addColorStop 0 a) - (.addColorStop 1 b))] - (set! (.. ctx -canvas -height) (.. ctx -canvas -width)) - (set! (.. ctx -canvas -style -height) (.. ctx -canvas -style -width)) - ;; we have to re-scale everything because resizing messes with the content - (.scale ctx (.-devicePixelRatio js/window) (.-devicePixelRatio js/window)) - (set! (.-fillStyle ctx) gradient) - (.fillRect ctx 0 0 (.. ctx -canvas -width) (.. ctx -canvas -height)))) - (defn missing-cover [item size] - [canvas {:class "missing-cover" - :draw #(generate-cover % item)}]) + (let [identifier (str (:artistId item) "-" (or (:albumId item) (:id item))) + [color-a color-b] (palette identifier)] + [:svg.missing-cover {:viewBox "0 0 256 256" + :xmlns "http://www.w3.org/2000/svg"} + [:defs [:linearGradient {:id (str "cover-gradient-" identifier) + :x1 0, :y1 0, + :x2 1, :y2 1} + [:stop {:offset "2%", :stop-color color-a}] + [:stop {:offset "98%", :stop-color color-b}]]] + [:rect {:x 0, :y 0, :width 256, :height 256 + :fill (str "url(#cover-gradient-" identifier ")")}]])) (defn has-cover? [item] - (:coverArt item)) - -;; FIXME: The direct dependency on these subs is a bit ugly + (some? (:coverArt item))) (defn cover [item size] diff --git a/src/sass/app.sass b/src/sass/app.sass index 627497b..059a292 100644 --- a/src/sass/app.sass +++ b/src/sass/app.sass @@ -17,86 +17,113 @@ .loader +loader -// 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 +.has-navbar-fixed-bottom + padding-bottom: 64px + .audio-player - .navbar-menu - color: $light - background: $dark - align-items: center + +navbar-fixed + bottom: 0 + // first clear some of that navigation styling + background-color: $dark + color: $dark-invert + min-height: 64px + display: flex + align-items: center + + // now off to the contents + + // when no song is playing .idle-notification - color: $light + color: inherit + // ... or with all the bells and whistles .audio-interaction + display: flex + flex-grow: 1 + align-items: center + + .playback-info + // shows cover and current track + align-items: center flex-grow: 1 + flex-basis: 25% + color: inherit .media-left - margin-right: 0 + margin-right: .6rem + + .artist-and-title + margin-right: .6rem + + .artist, + .song-title + display: block + white-space: nowrap + width: 100% + max-width: 100% + overflow: hidden + text-overflow: ellipsis + + .progress-indicators + // hide progress bar on mobile + display: none + +tablet + display: flex - .level-left - flex-grow: 1 + flex-basis: 75% + height: 1rem + + .progress-info-text + color: $dark-invert + font-size: $size-7 flex-shrink: 0 + flex-grow: 0 - .level-right - display: flex + svg + overflow: visible - .button-group - margin: 0 .5rem + .progress-bars + margin-left: .6rem + margin-right: .6rem + position: relative + flex-grow: 1 - + .button-group - margin-left: 0 + .complete-song-bar, + .buffered-part-bar, + .played-back-bar + height: 1rem - =tablet - flex-grow: 0 - flex-shrink: 1 - padding-left: .5rem - padding-right: .5rem + .complete-song-bar + width: 100% - .media - flex-grow: 1 - align-items: center + rect + fill: rgb(93,93,93) -.current-song-info - display: flex - align-items: center + .buffered-part-bar + rect + fill: rgb(143,143,143) - .current-name, - .current-progress - padding: .5rem + .click-dummy + cursor: pointer + fill: transparent - .current-name - width: 30% - font-size: .8rem - white-space: nowrap - text-overflow: ellipsis - overflow: hidden + .played-back-bar + pointer-events: none - .current-progress - flex-grow: 1 - position: relative + circle, + rect + fill: $dark-invert - .buffered-part - position: absolute - top: .5rem - left: calc(.5rem + 5px) - height: 1rem - cursor: pointer + // buttons to control current playback and playlist behavior + .playback-controls, + .playback-mode-controls + flex-shrink: 0 + padding-right: .6rem - .current-progress-canvas - display: block - height: 1rem - width: 100% + .playback-controls + padding-left: .6rem // preview card for album or artist listings .preview-card @@ -121,15 +148,15 @@ &.is-48x48 .missing-cover width: 48px - height: 48px + height: auto &.is-128x128 .missing-cover width: 128px - height: 128px + height: auto &.is-256x256 .missing-cover width: 256px - height: 256px + height: auto // occurs in album detail view .table @@ -218,6 +245,20 @@ margin-bottom: 1rem .album.detail + .collection-header + display: block + + .media-left + margin-right: 0 + margin-bottom: 1rem + + +tablet + display: flex + + .media-left + margin-right: 1rem + margin-bottom: 0 + .collection-info list-style: none diff --git a/test/cljs/airsonic_ui/api/helpers_test.cljs b/test/cljs/airsonic_ui/api/helpers_test.cljs index 3c50ab3..53ea60f 100644 --- a/test/cljs/airsonic_ui/api/helpers_test.cljs +++ b/test/cljs/airsonic_ui/api/helpers_test.cljs @@ -26,6 +26,17 @@ encoded-str (js/encodeURIComponent query)] (is (str/includes? (api/url {:server "http://localhost"} "search3" {:query query}) encoded-str))))) +(deftest variadic-parameters + (testing "Should append list-like parameters correctly" + (is (= (count (re-seq #"id=" (api/url {:server "http://localost"} "test" {:id []}))) 0)) + (is (= (count (re-seq #"id=" (api/url {:server "http://localost"} "test" {:id [1]}))) 1)) + (is (= (count (re-seq #"id=" (api/url {:server "http://localost"} "test" {:id (range 10)}))) 10))) + (testing "Should keep the non-lists" + (let [mixed (api/url {:server "http://localost"} "test" {:id (range 5) :foo "bar"})] + (is (some? (re-find #"u=user" (api/url {:server "http://localhost"} "test" {:u "user"})))) + (is (and (some? (re-find #"foo=bar" mixed)) + (= (count (re-seq #"id=" mixed)) 5)))))) + (deftest stream-urls (testing "Should construct the url based on a song's id" (let [stream-url (api/stream-url {:server "http://localhost"} fixtures/song)] diff --git a/test/cljs/airsonic_ui/helpers_test.cljs b/test/cljs/airsonic_ui/helpers_test.cljs index 6925b45..42b2212 100644 --- a/test/cljs/airsonic_ui/helpers_test.cljs +++ b/test/cljs/airsonic_ui/helpers_test.cljs @@ -31,3 +31,15 @@ (is (= :hello-world (helpers/kebabify :HelloWorld))) (is (= :how-are-you (helpers/kebabify :howAreYou))) (is (= :foobar (helpers/kebabify :foobar))))) + +(deftest format-duration + (testing "Should format hours, minutes and seconds" + (is (= "1h" (helpers/format-duration 3600))) + (is (= "59m" (helpers/format-duration (* 59 60)))) + (is (= "1m" (helpers/format-duration 60))) + (is (= "5s" (helpers/format-duration 5)))) + (testing "Should respect the :brief? option" + (is (= "01:00:00" (helpers/format-duration 3600 :brief? true))) + (is (= "59:00" (helpers/format-duration (* 59 60) :brief? true))) + (is (= "01:00" (helpers/format-duration 60 :brief? true))) + (is (= "00:05" (helpers/format-duration 5 :brief? true)))))