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": { "dependencies": {
"util": { "util": {
"version": "0.10.3", "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=", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -482,7 +482,7 @@
}, },
"browserify-aes": { "browserify-aes": {
"version": "1.2.0", "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==", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -527,7 +527,7 @@
}, },
"browserify-rsa": { "browserify-rsa": {
"version": "4.0.1", "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=", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -561,7 +561,7 @@
}, },
"buffer": { "buffer": {
"version": "4.9.1", "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=", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -947,7 +947,7 @@
}, },
"create-hash": { "create-hash": {
"version": "1.2.0", "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==", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -960,7 +960,7 @@
}, },
"create-hmac": { "create-hmac": {
"version": "1.1.7", "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==", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -1154,7 +1154,7 @@
}, },
"diffie-hellman": { "diffie-hellman": {
"version": "5.0.3", "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==", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -1353,7 +1353,7 @@
}, },
"events": { "events": {
"version": "1.1.1", "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=", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=",
"dev": true "dev": true
}, },
@ -2610,7 +2610,7 @@
}, },
"http-errors": { "http-errors": {
"version": "1.6.3", "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=", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -3414,7 +3414,7 @@
}, },
"media-typer": { "media-typer": {
"version": "0.3.0", "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=", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
"dev": true "dev": true
}, },
@ -4084,7 +4084,7 @@
}, },
"parse-asn1": { "parse-asn1": {
"version": "5.1.1", "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==", "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -4694,7 +4694,7 @@
}, },
"safe-regex": { "safe-regex": {
"version": "1.1.0", "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=", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -4805,7 +4805,7 @@
}, },
"sha.js": { "sha.js": {
"version": "2.4.11", "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==", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -5222,7 +5222,7 @@
}, },
"stream-browserify": { "stream-browserify": {
"version": "2.0.1", "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=", "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=",
"dev": true, "dev": true,
"requires": { "requires": {

View file

@ -6,16 +6,29 @@
:c "airsonic-ui-cljs" :c "airsonic-ui-cljs"
:v "1.15.0"}) :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) (def ^:private encode js/encodeURIComponent)
(defn url (defn url
"Returns an absolute url to an API endpoint" "Returns an absolute url to an API endpoint"
[credentials endpoint params] [credentials endpoint params]
(let [server (:server credentials) (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)))) (map (fn [[k v]] (str (encode (name k)) "=" (encode v))))
(str/join "&"))] (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] (defn stream-url [credentials song-or-episode]
;; podcasts have a stream-id, normal songs just use their id ;; podcasts have a stream-id, normal songs just use their id
@ -63,4 +76,3 @@
#{:artistId :name :songCount :artist} :album #{:artistId :name :songCount :artist} :album
#{:id :name :albumCount} :artist #{:id :name :albumCount} :artist
:unknown))) :unknown)))

View file

@ -1,114 +1,88 @@
(ns airsonic-ui.components.audio-player.views (ns airsonic-ui.components.audio-player.views
(:require [re-frame.core :refer [subscribe dispatch]] (:require [re-frame.core :refer [subscribe dispatch]]
[airsonic-ui.routes :as routes] [airsonic-ui.routes :as routes]
[airsonic-ui.components.highres-canvas.views :refer [canvas]] [airsonic-ui.helpers :as h]
[airsonic-ui.helpers :refer [add-classes muted-dispatch]]
[airsonic-ui.views.cover :refer [cover]] [airsonic-ui.views.cover :refer [cover]]
[airsonic-ui.views.icon :refer [icon]])) [airsonic-ui.views.icon :refer [icon]]))
;; currently playing / coming next / audio controls... ;; 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 (defn seek
"Calculates the position of the click and sets current playback accordingly" "Calculates the position of the click and sets current playback accordingly"
[ev] [ev]
(let [x (- (.. ev -nativeEvent -pageX) (let [x-ratio (/ (.. ev -nativeEvent -layerX)
(.. ev -target getBoundingClientRect -left)) (.. ev -target -parentElement getBoundingClientRect -width))]
width (- (.. ev -target -nextElementSibling -clientWidth) 10)] ;; <- 10 = 2 * canvas-padding (dispatch [:audio-player/seek x-ratio])))
(dispatch [:audio-player/seek (/ x width)])))
(defn buffered-part (defn- ratio->width [ratio]
[buffered duration] (str (.toFixed (min 100 (* 100 ratio)) 2) "%"))
(let [width (min 100 (* (/ buffered duration) 100))]
[:div.buffered-part {:on-click seek
:style {:width (str "calc(" width "% - 1rem - 10px)")}}]))
(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) (let [current-time (:current-time status)
buffered (:buffered status) buffered (:buffered status)
duration (:duration song)] duration (:duration song)
[:article.current-song-info progress-text (str (h/format-duration current-time :brief? true)
[:div.current-name (:artist song) [:br] (:title song)] " / "
[:div.current-progress (h/format-duration duration :brief? true))
[buffered-part buffered duration] buffered-width (ratio->width (/ buffered duration))
[current-progress current-time 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?] (defn playback-info [song status]
[:div.field.has-addons [:a.playback-info.media
(let [buttons [[:media-step-backward :audio-player/previous-song] {:href (routes/url-for ::routes/current-queue)
[(if is-playing? :media-pause :media-play) :audio-player/toggle-play-pause] :title "Go to current queue"}
[:media-step-forward :audio-player/next-song]] [:div.media-left [cover song 64]]
title {:media-step-backward "Previous" [:div.media-content
:media-play "Play" [:div.artist-and-title
:media-pause "Pause" [:span.artist(:artist song)]
:media-step-forward "Next"}] [:span.song-title (:title song)]]]])
(for [[icon-glyph event] buttons]
^{:key icon-glyph} [:p.control [:button.button.is-light (defn playback-controls [is-playing?]
{:on-click (muted-dispatch [event]) [:div.playback-controls
:title (title icon-glyph)} [:div.field.has-addons
[icon icon-glyph]]]))]) (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] (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)])) :linear :shuffled)]))
(defn- toggle-repeat-mode [current-mode] (defn- toggle-repeat-mode [current-mode]
(let [modes (cycle '(:repeat-none :repeat-all :repeat-single)) (let [modes (cycle '(:repeat-none :repeat-all :repeat-single))
next-mode (->> (drop-while (partial not= current-mode) modes) next-mode (->> (drop-while (partial not= current-mode) modes)
(second))] (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] (defn playback-mode-controls [playlist]
(let [{:keys [repeat-mode playback-mode]} playlist (let [{:keys [repeat-mode playback-mode]} playlist
button :p.control>button.button.is-light button :p.control>button.button.is-light
shuffle-button (add-classes button (when (= playback-mode :shuffled) :is-primary)) shuffle-button (h/add-classes button (when (= playback-mode :shuffled) :is-primary))
repeat-button (add-classes button (case repeat-mode repeat-button (h/add-classes button (case repeat-mode
:repeat-single :is-info :repeat-single :is-info
:repeat-all :is-primary :repeat-all :is-primary
nil)) nil))
@ -116,28 +90,25 @@
:repeat-all "Repeating current queue, click to repeat current track" :repeat-all "Repeating current queue, click to repeat current track"
:repeat-single "Repeating current track, click to repeat none" :repeat-single "Repeating current track, click to repeat none"
"Click to repeat current queue")] "Click to repeat current queue")]
[:div.field.has-addons [:div.playback-mode-controls
^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode) [:div.button-group>div.field.has-addons
:title "Shuffle"} [icon :random]] ^{:key :shuffle-button} [shuffle-button {:on-click (toggle-shuffle playback-mode)
^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode) :title "Shuffle"} [icon :random]]
:title repeat-title} [icon :loop]]])) ^{:key :repeat-button} [repeat-button {:on-click (toggle-repeat-mode repeat-mode)
:title repeat-title} [icon :loop]]]]))
(defn audio-player [] (defn audio-player []
(let [current-song @(subscribe [:audio/current-song]) (let [current-song @(subscribe [:audio/current-song])
playlist @(subscribe [:audio/playlist]) playlist @(subscribe [:audio/playlist])
playback-status @(subscribe [:audio/playback-status]) playback-status @(subscribe [:audio/playback-status])
is-playing? @(subscribe [:audio/is-playing?])] is-playing? @(subscribe [:audio/is-playing?])]
[:nav.navbar.is-fixed-bottom.audio-player [:nav.audio-player
[:div.navbar-menu.is-active (if current-song
(if current-song ;; show song info, controls, progress bar, etc.
;; show song info [:section.audio-interaction
[:section.level.audio-interaction [playback-info current-song playback-status]
[:div.level-left>article.media [progress-indicators current-song playback-status]
[:div.media-left [cover current-song 48]] [playback-controls is-playing?]
[:div.media-content [current-song-info current-song playback-status]]] [playback-mode-controls playlist]]
[:div.level-right ;; not playing anything
[:div.button-group [:p.control>a.button.is-light {:href (routes/url-for ::routes/current-queue) :title "Go to current queue"} [icon :menu]]] [:p.navbar-item.idle-notification "No audio playing"])]))
[: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"])]]))

View file

@ -40,7 +40,7 @@
[:div [:div
[:section.hero.is-small>div.hero-body [:section.hero.is-small>div.hero-body
[:div.container [:div.container
[:article.media [:article.collection-header.media
[:div.media-left [cover album 128]] [:div.media-left [cover album 128]]
[:div.media-content [:div.media-content
[:h2.title (:name album)] [:h2.title (:name album)]

View file

@ -19,28 +19,27 @@
:default-value search-term :default-value search-term
:placeholder "Search"}]]]))) :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]}] (defn artist-results [{:keys [artist]}]
[:div.columns.is-multiline.is-mobile [result-cards (map (juxt artist-url identity) artist)])
(for [[idx artist] (map-indexed vector artist)]
(let [url #(url-for ::routes/artist.detail (select-keys % [:id]))] (defn- album-url [album]
^{:key idx} [:div.column.is-2 (url-for ::routes/album.detail (select-keys album [:id])))
[card artist
:url-fn url
:content [:div>a
{:href (url artist), :title (:name artist)}
(:name artist)]]]))])
(defn album-results [{:keys [album]}] (defn album-results [{:keys [album]}]
[:div.columns.is-multiline.is-mobile [result-cards (map (juxt album-url identity) album)])
(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]]]))])
(defn song-results [{:keys [song]}] (defn song-results [{:keys [song]}]
[song/listing song]) [song/listing song])

View file

@ -1,7 +1,8 @@
(ns airsonic-ui.helpers (ns airsonic-ui.helpers
"Assorted helper functions" "Assorted helper functions"
(:require [re-frame.core :as rf] (:require [re-frame.core :as rf]
[clojure.string :as str])) [clojure.string :as str])
(:import [goog.string format]))
(defn find-where (defn find-where
"Returns the the first item in `coll` with its index for which `(p song)` "Returns the the first item in `coll` with its index for which `(p song)`
@ -35,11 +36,22 @@
(str/lower-case) (str/lower-case)
(keyword))) (keyword)))
(defn format-duration [seconds] (defn- brief-duration [hours minutes seconds]
(let [hours (quot seconds 3600) (str (when (> hours 0)
minutes (quot (rem seconds 3600) 60) (format "%02d:" hours))
seconds (rem seconds 60)] (format "%02d:%02d" minutes seconds)))
(-> (cond-> ""
(> hours 0) (str hours "h ") (defn- long-duration [hours minutes seconds]
(> minutes 0) (str minutes "m ")) (str/trim
(str seconds "s")))) (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 (ns airsonic-ui.views.cover
(:require [re-frame.core :refer [subscribe]] (:require [re-frame.core :refer [subscribe]]
[airsonic-ui.subs :as subs] [airsonic-ui.subs :as subs]
[airsonic-ui.components.highres-canvas.views :refer [canvas]]
["@hugojosefson/color-hash" :as ColorHash])) ["@hugojosefson/color-hash" :as ColorHash]))
(def color-hash (ColorHash.)) (def color-hash (ColorHash.))
@ -10,36 +9,28 @@
(str "hsl(" h "," (* 100 s) "%," (* 100 l) "%)")) (str "hsl(" h "," (* 100 s) "%," (* 100 l) "%)"))
(defn palette (defn palette
"Generate a hsl palette of two colors that's unique for a given item" "Generate a unique hsl palette of two colors"
[item] [identifier]
(let [identifier (str (:artistId item) "-" (or (:albumId item) (:id item))) (let [[h s l] (js->clj (.hsl color-hash identifier))]
[h s l] (js->clj (.hsl color-hash identifier))]
[(hsl->css h s l) [(hsl->css h s l)
(hsl->css (mod (+ h (* h 0.3) 10) 360) 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 (defn missing-cover
[item size] [item size]
[canvas {:class "missing-cover" (let [identifier (str (:artistId item) "-" (or (:albumId item) (:id item)))
:draw #(generate-cover % 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] (defn has-cover? [item]
(:coverArt item)) (some? (:coverArt item)))
;; FIXME: The direct dependency on these subs is a bit ugly
(defn cover (defn cover
[item size] [item size]

View file

@ -17,87 +17,114 @@
.loader .loader
+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 // bottom bar
.has-navbar-fixed-bottom
padding-bottom: 64px
.audio-player .audio-player
.navbar-menu +navbar-fixed
color: $light bottom: 0
background: $dark
align-items: center
.idle-notification // first clear some of that navigation styling
color: $light background-color: $dark
color: $dark-invert
.audio-interaction min-height: 64px
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
display: flex display: flex
align-items: center align-items: center
.current-name, // now off to the contents
.current-progress
padding: .5rem
.current-name // when no song is playing
width: 30% .idle-notification
font-size: .8rem color: inherit
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
.current-progress // ... or with all the bells and whistles
.audio-interaction
display: flex
flex-grow: 1 flex-grow: 1
position: relative align-items: center
.buffered-part .playback-info
position: absolute // shows cover and current track
top: .5rem align-items: center
left: calc(.5rem + 5px) flex-grow: 1
height: 1rem flex-basis: 25%
cursor: pointer color: inherit
.current-progress-canvas .media-left
display: block 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 height: 1rem
.complete-song-bar
width: 100% 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 for album or artist listings
.preview-card .preview-card
.card-content > div, .card-content > div,
@ -121,15 +148,15 @@
&.is-48x48 .missing-cover &.is-48x48 .missing-cover
width: 48px width: 48px
height: 48px height: auto
&.is-128x128 .missing-cover &.is-128x128 .missing-cover
width: 128px width: 128px
height: 128px height: auto
&.is-256x256 .missing-cover &.is-256x256 .missing-cover
width: 256px width: 256px
height: 256px height: auto
// occurs in album detail view // occurs in album detail view
.table .table
@ -218,6 +245,20 @@
margin-bottom: 1rem margin-bottom: 1rem
.album.detail .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 .collection-info
list-style: none list-style: none

View file

@ -26,6 +26,17 @@
encoded-str (js/encodeURIComponent query)] encoded-str (js/encodeURIComponent query)]
(is (str/includes? (api/url {:server "http://localhost"} "search3" {:query query}) encoded-str))))) (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 (deftest stream-urls
(testing "Should construct the url based on a song's id" (testing "Should construct the url based on a song's id"
(let [stream-url (api/stream-url {:server "http://localhost"} fixtures/song)] (let [stream-url (api/stream-url {:server "http://localhost"} fixtures/song)]

View file

@ -31,3 +31,15 @@
(is (= :hello-world (helpers/kebabify :HelloWorld))) (is (= :hello-world (helpers/kebabify :HelloWorld)))
(is (= :how-are-you (helpers/kebabify :howAreYou))) (is (= :how-are-you (helpers/kebabify :howAreYou)))
(is (= :foobar (helpers/kebabify :foobar))))) (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)))))