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

Mobile improvements (#42)

* Implement variadic url parameters

* Trying to make the audio player more mobile friendly

All good but progress bar is missing

* Implement progress bar with html elements, fixes #39

* Add duration text next to progress bar

* Simplify audio player structure

* Make albums more comfortably browsable on mobile

* Implement responsive generated covers with SVG

* Restrict progress bar to 100% max-width

* Make search results somewhat usable on mobile

* Implement progress bar with svg
This commit is contained in:
Arne Schlüter 2019-01-23 14:09:11 +01:00 committed by GitHub
commit a75cdca9e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 281 additions and 232 deletions

28
package-lock.json generated
View file

@ -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": {

View file

@ -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)))

View file

@ -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 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- ratio->width [ratio]
(str (.toFixed (min 100 (* 100 ratio)) 2) "%"))
(defn current-song-info [song status]
(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 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 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-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 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"])]))

View file

@ -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)]

View file

@ -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])

View file

@ -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))))

View file

@ -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]

View file

@ -17,87 +17,114 @@
.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
.idle-notification
color: $light
.audio-interaction
flex-grow: 1
.media-left
margin-right: 0
.level-left
flex-grow: 1
flex-shrink: 0
.level-right
display: flex
.button-group
margin: 0 .5rem
+ .button-group
margin-left: 0
=tablet
flex-grow: 0
flex-shrink: 1
padding-left: .5rem
padding-right: .5rem
.media
flex-grow: 1
align-items: center
.current-song-info
// first clear some of that navigation styling
background-color: $dark
color: $dark-invert
min-height: 64px
display: flex
align-items: center
.current-name,
.current-progress
padding: .5rem
// now off to the contents
.current-name
width: 30%
font-size: .8rem
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
// when no song is playing
.idle-notification
color: inherit
.current-progress
// ... or with all the bells and whistles
.audio-interaction
display: flex
flex-grow: 1
position: relative
align-items: center
.buffered-part
position: absolute
top: .5rem
left: calc(.5rem + 5px)
height: 1rem
cursor: pointer
.playback-info
// shows cover and current track
align-items: center
flex-grow: 1
flex-basis: 25%
color: inherit
.current-progress-canvas
display: block
.media-left
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
flex-basis: 75%
height: 1rem
.progress-info-text
color: $dark-invert
font-size: $size-7
flex-shrink: 0
flex-grow: 0
svg
overflow: visible
.progress-bars
margin-left: .6rem
margin-right: .6rem
position: relative
flex-grow: 1
.complete-song-bar,
.buffered-part-bar,
.played-back-bar
height: 1rem
.complete-song-bar
width: 100%
rect
fill: rgb(93,93,93)
.buffered-part-bar
rect
fill: rgb(143,143,143)
.click-dummy
cursor: pointer
fill: transparent
.played-back-bar
pointer-events: none
circle,
rect
fill: $dark-invert
// buttons to control current playback and playlist behavior
.playback-controls,
.playback-mode-controls
flex-shrink: 0
padding-right: .6rem
.playback-controls
padding-left: .6rem
// preview card for album or artist listings
.preview-card
.card-content > div,
@ -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

View file

@ -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)]

View file

@ -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)))))