From 568d3d8e81a62a74e5ef7a645fd1f3c06480e6db Mon Sep 17 00:00:00 2001 From: Miguel Ángel Moreno Date: Sat, 28 Oct 2023 21:58:25 +0200 Subject: feat(frontend): add play queue component --- src/frontend/tubo/components/audio_player.cljs | 65 ++++++++------ src/frontend/tubo/components/play_queue.cljs | 117 +++++++++++++++++++++++++ src/frontend/tubo/events.cljs | 23 ++++- src/frontend/tubo/subs.cljs | 38 +++++++- src/frontend/tubo/views.cljs | 2 + 5 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 src/frontend/tubo/components/play_queue.cljs (limited to 'src/frontend') diff --git a/src/frontend/tubo/components/audio_player.cljs b/src/frontend/tubo/components/audio_player.cljs index fac2cf1..f4945c8 100644 --- a/src/frontend/tubo/components/audio_player.cljs +++ b/src/frontend/tubo/components/audio_player.cljs @@ -9,32 +9,36 @@ (defn player [] - (let [!player (r/atom nil) - !elapsed-time (r/atom 0) - !autoplay? (r/atom true) + (let [!autoplay? (r/atom true) !volume-level (r/atom 100)] (fn [] (let [media-queue @(rf/subscribe [:media-queue]) media-queue-pos @(rf/subscribe [:media-queue-pos]) - {:keys [uploader-name uploader-url + {:keys [uploader-name uploader-url thumbnail-url name stream url service-color] :as current-stream} @(rf/subscribe [:media-queue-stream]) show-audio-player? @(rf/subscribe [:show-audio-player]) show-audio-player-loading? @(rf/subscribe [:show-audio-player-loading]) + show-media-queue? @(rf/subscribe [:show-media-queue]) is-window-visible @(rf/subscribe [:is-window-visible]) loop-file? @(rf/subscribe [:loop-file]) - loop-playlist? @(rf/subscribe [:loop-playlist])] + loop-playlist? @(rf/subscribe [:loop-playlist]) + !elapsed-time @(rf/subscribe [:elapsed-time]) + !player @(rf/subscribe [:player])] (when show-audio-player? - [:div.sticky.bottom-0.z-50.bg-white.dark:bg-neutral-900.px-3.py-5.sm:p-5.absolute.box-border.m-0 - {:style {:borderColor service-color :borderTopWidth "2px" :borderStyle "solid"}} + [:div.sticky.bottom-0.z-40.bg-white.dark:bg-neutral-900.px-3.py-5.sm:p-5.absolute.box-border.m-0 + {:style {:borderTop (str "2px solid " service-color) :display (when show-media-queue? "none")}} [:div.flex.items-center.justify-between [:div.flex.items-center - [:div.flex.flex-col + [:div {:style {:height "40px" :width "70px" :maxWidth "70px" :minWidth "70px"}} + [:img.min-h-full.max-h-full.object-cover.min-w-full.max-w-full.w-full {:src thumbnail-url}]] + [:div.flex.flex-col.px-4 [:a.text-xs.line-clamp-1 {:href (rfe/href :tubo.routes/stream nil {:url url})} name] [:a.text-xs.pt-2.text-neutral-600.dark:text-neutral-300.line-clamp-1 {:href (rfe/href :tubo.routes/channel nil {:url uploader-url})} uploader-name]] [:audio - {:src stream :ref #(reset! !player %) + {:src stream + :ref #(reset! !player %) :loop loop-file? :on-time-update #(when (and @!player (> (.-readyState @!player) 0)) (reset! !elapsed-time (.-currentTime @!player))) @@ -51,7 +55,10 @@ (set! (.-src @!player) (:stream (nth media-queue idx))) (.play @!player))))}]] [:div.flex - [:button.focus:outline-none.mx-1.sm:mx-2 + [:button:focus:ring-transparent.mx-2.cursor-pointer + {:on-click #(rf/dispatch [::events/toggle-media-queue])} + [:i.fa-solid.fa-list]] + [:button.hidden.ml:block.focus:outline-none.mx-2 {:class (when-not (and media-queue (not= media-queue-pos 0)) "opacity-50 cursor-auto") :on-click (when (and media-queue (not= media-queue-pos 0)) @@ -60,7 +67,10 @@ (- media-queue-pos 1)]) (reset! !elapsed-time 0)))} [:i.fa-solid.fa-backward-step]] - [:button.focus:outline-none.mx-1.sm:mx-2 + [:button.hidden.ml:block.focus:outline-none.mx-2 + {:on-click #(set! (.-currentTime @!player) (- @!elapsed-time 5))} + [:i.fa-solid.fa-backward]] + [:button.focus:outline-none.mx-2 {:on-click #(when-let [player @!player] (if (.-paused player) (.play player) @@ -72,7 +82,10 @@ [:i.fa-solid.fa-play] [:i.fa-solid.fa-pause])) [:i.fa-solid.fa-play])] - [:button.focus:ring-transparent.mx-1.sm:mx-2 + [:button.hidden.ml:block.focus:outline-none.mx-2 + {:on-click #(set! (.-currentTime @!player) (+ @!elapsed-time 5))} + [:i.fa-solid.fa-forward]] + [:button.hidden.ml:block.focus:ring-transparent.mx-2 {:class (when-not (and media-queue (< (+ media-queue-pos 1) (count media-queue))) "opacity-50 cursor-auto") :on-click (when (and media-queue (< (+ media-queue-pos 1) (count media-queue))) @@ -81,14 +94,9 @@ (+ media-queue-pos 1)]) (reset! !elapsed-time 0)))} [:i.fa-solid.fa-forward-step]] - [:div.flex - [:div.mx-2.hidden.sm:flex - [:span (if @!elapsed-time (util/format-duration @!elapsed-time) "00:00")] - [:span.mx-2 "/"] - [:span (if (and @!player (> (.-readyState @!player) 0)) - (util/format-duration (.-duration @!player)) - "00:00")]] - [:input.mx-2.w-20.ml:w-80.bg-gray-200.rounded-lg.cursor-pointer.focus:outline-none + [:div.flex.items-center + [:span.hidden.ml:block.mx-2 (if @!elapsed-time (util/format-duration @!elapsed-time) "00:00")] + [:input.hidden.ml:block.mx-2.w-20.ml:w-56.bg-gray-200.rounded-lg.cursor-pointer.focus:outline-none.h-1 {:type "range" :on-input #(reset! !elapsed-time (.. % -target -value)) :on-change #(and @!player (> (.-readyState @!player) 0) @@ -98,30 +106,33 @@ (.floor js/Math (.-duration @!player)) 100) :value @!elapsed-time}] - [:button.focus:ring-transparent.mx-1.sm:mx-2 + [:span.hidden.ml:block.mx-2 (if (and @!player (> (.-readyState @!player) 0)) + (util/format-duration (.-duration @!player)) + "00:00")] + [:button.hidden.ml:flex.focus:ring-transparent.mx-2 {:on-click #(rf/dispatch [::events/toggle-loop-file])} [:i.fa-solid.fa-repeat {:style {:color (when loop-file? service-color)}}]] - [:button.focus:ring-transparent.mx-1.sm:mx-2 + [:button.hidden.ml:flex.focus:ring-transparent.mx-2 {:on-click #(rf/dispatch [::events/toggle-loop-playlist])} [:i.fa-solid.fa-retweet {:style {:color (when loop-playlist? service-color)}}]] - [:div.hidden.sm:flex - [:button.focus:outline-none.mx-1.sm:mx-2 + [:div.hidden.ml:flex.items-center + [:button.focus:outline-none.mx-2 {:on-click #(when-let [player @!player] (set! (.-muted player) (not (.-muted player))))} (if (and @!player (.-muted @!player)) [:i.fa-solid.fa-volume-xmark] [:i.fa-solid.fa-volume-low])] - [:input.w-20.bg-gray-200.rounded-lg.cursor-pointer.focus:outline-none + [:input.w-20.bg-gray-200.rounded-lg.cursor-pointer.focus:outline-none.h-1.range-sm.mx-2 {:type "range" :on-input #(do (reset! !volume-level (.. % -target -value)) (and @!player (> (.-readyState @!player) 0) (set! (.-volume @!player) (/ @!volume-level 100)))) :style {:accentColor service-color} :max 100 - :value @!volume-level}]]] - [:div.mx-1.sm:mx-2 + :value (if (and @!player (.-muted @!player)) 0 @!volume-level)}]]] + [:div.mx-2 [:i.fa-solid.fa-close.cursor-pointer {:on-click (fn [] (rf/dispatch [::events/toggle-audio-player]) diff --git a/src/frontend/tubo/components/play_queue.cljs b/src/frontend/tubo/components/play_queue.cljs new file mode 100644 index 0000000..98ce513 --- /dev/null +++ b/src/frontend/tubo/components/play_queue.cljs @@ -0,0 +1,117 @@ +(ns tubo.components.play-queue + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.items :as items] + [tubo.events :as events] + [tubo.util :as util])) + +(defn queue + [] + (let [show-media-queue @(rf/subscribe [:show-media-queue]) + media-queue @(rf/subscribe [:media-queue]) + media-queue-pos @(rf/subscribe [:media-queue-pos]) + {:keys [uploader-name uploader-url + name stream url service-color] :as current-stream} @(rf/subscribe [:media-queue-stream]) + !elapsed-time @(rf/subscribe [:elapsed-time]) + !player @(rf/subscribe [:player]) + loop-file? @(rf/subscribe [:loop-file]) + loop-playlist? @(rf/subscribe [:loop-playlist])] + (when (and show-media-queue media-queue) + [:div.fixed.flex.flex-col.items-center.px-5.py-2.min-w-full.w-full.z-30 + {:style {:minHeight "calc(100vh - 56px)" :height "calc(100vh - 56px)"} + :class "dark:bg-neutral-900/90 bg-white/90 backdrop-blur"} + [:div.flex.justify-between.pl-4.items-center.w-full.shrink-0 + {:class "ml:w-4/5 xl:w-3/5"} + [:h1.text-2xl.font-bold.py-6 "Play Queue"] + [:div.mx-2 + [:i.fa-solid.fa-close.cursor-pointer + {:on-click #(rf/dispatch [::events/toggle-media-queue])}]]] + [:div.flex.flex-col.p-4.w-full.overflow-y-auto.flex-auto + {:class "ml:w-4/5 xl:w-3/5"} + [:div + (for [[i {:keys [uploader-name uploader-url name duration + stream url service-color thumbnail-url]}] (map-indexed vector media-queue)] + (let [service-name (case service-color + "#cc0000" "YouTube" + "#ff7700" "SoundCloud" + "#333333" "media.ccc.de" + "#F2690D" "PeerTube" + "#629aa9" "Bandcamp")] + [:div.flex.w-full.rounded.px-2.cursor-pointer + {:key i + :class (when (= i media-queue-pos) "bg-[#f0f0f0] dark:bg-stone-800") + :on-click #(do + (rf/dispatch [::events/change-media-queue-pos i]) + (reset! !elapsed-time 0))} + [:div.xs:w-56.items-center + [items/thumbnail thumbnail-url nil url name duration]] + [:div.flex.flex-col.px-4.py-2.w-full + [:h1.pb-4.line-clamp-2 name] + [:h3.text-neutral-600.dark:text-neutral-300.text-sm + [:span.pr-2 uploader-name] + [:span {:dangerouslySetInnerHTML {:__html "•"}}] + [:span.pl-2 service-name]]]]))]] + [:div.flex.flex-col.p-4.w-full.shrink-0 + {:class "ml:w-4/5 xl:w-3/5"} + [:div.flex.flex-col.w-full.py-2 + [:a.text-md.line-clamp-1 + {:href (rfe/href :tubo.routes/stream nil {:url url})} name] + [:a.text-sm.pt-2.text-neutral-600.dark:text-neutral-300.line-clamp-1 + {:href (rfe/href :tubo.routes/channel nil {:url uploader-url})} uploader-name]] + [:div.flex.flex-auto.py-2.w-full.items-center + [:span (if @!elapsed-time (util/format-duration @!elapsed-time) "00:00")] + [:input.mx-2.bg-gray-200.rounded-lg.cursor-pointer.focus:outline-none.w-full.h-1 + {:type "range" + :on-input #(reset! !elapsed-time (.. % -target -value)) + :on-change #(and @!player (> (.-readyState @!player) 0) + (set! (.-currentTime @!player) @!elapsed-time)) + :style {:accentColor service-color} + :max (if (and @!player (> (.-readyState @!player) 0)) + (.floor js/Math (.-duration @!player)) + 100) + :value @!elapsed-time}] + [:span (if (and @!player (> (.-readyState @!player) 0)) + (util/format-duration (.-duration @!player)) + "00:00")]] + [:div.flex.justify-center.items-center + [:button.focus:ring-transparent.mx-2 + {:on-click #(rf/dispatch [::events/toggle-loop-file])} + [:i.fa-solid.fa-repeat + {:style {:color (when loop-file? service-color)}}]] + [:button.focus:outline-none.mx-2.text-xl + {:class (when-not (and media-queue (not= media-queue-pos 0)) + "opacity-50 cursor-auto") + :on-click (when (and media-queue (not= media-queue-pos 0)) + #(do + (rf/dispatch [::events/change-media-queue-pos + (- media-queue-pos 1)]) + (reset! !elapsed-time 0)))} + [:i.fa-solid.fa-backward-step]] + [:button.focus:outline-none.mx-2.text-xl + {:on-click #(set! (.-currentTime @!player) (- @!elapsed-time 5))} + [:i.fa-solid.fa-backward]] + [:button.focus:outline-none.mx-2.text-3xl + {:on-click #(when-let [player @!player] + (if (.-paused player) + (.play player) + (.pause player)))} + (if (and @!player (.-paused @!player)) + [:i.fa-solid.fa-play] + [:i.fa-solid.fa-pause])] + [:button.focus:outline-none.mx-2.text-xl + {:on-click #(set! (.-currentTime @!player) (+ @!elapsed-time 5))} + [:i.fa-solid.fa-forward]] + [:button.focus:ring-transparent.mx-2.text-xl + {:class (when-not (and media-queue (< (+ media-queue-pos 1) (count media-queue))) + "opacity-50 cursor-auto") + :on-click (when (and media-queue (< (+ media-queue-pos 1) (count media-queue))) + #(do + (rf/dispatch [::events/change-media-queue-pos + (+ media-queue-pos 1)]) + (reset! !elapsed-time 0)))} + [:i.fa-solid.fa-forward-step]] + [:button.focus:ring-transparent.mx-2 + {:on-click #(rf/dispatch [::events/toggle-loop-playlist])} + [:i.fa-solid.fa-retweet + {:style {:color (when loop-playlist? service-color)}}]]]]]))) diff --git a/src/frontend/tubo/events.cljs b/src/frontend/tubo/events.cljs index 54b1a37..2d5b549 100644 --- a/src/frontend/tubo/events.cljs +++ b/src/frontend/tubo/events.cljs @@ -45,15 +45,27 @@ (fn [_] (.back js/window.history))) +(rf/reg-fx + ::body-overflow! + (fn [active] + (set! (.. js/document.body -style -overflow) (if active "hidden" "auto")))) + (rf/reg-event-fx ::history-back (fn [_ _] {::history-back! nil})) -(rf/reg-event-db +(rf/reg-event-fx ::toggle-mobile-nav - (fn [db _] - (assoc db :show-mobile-nav (not (:show-mobile-nav db))))) + (fn [{:keys [db]} _] + {:db (assoc db :show-mobile-nav (not (:show-mobile-nav db))) + ::body-overflow! (not (:show-mobile-nav db))})) + +(rf/reg-event-fx + ::toggle-media-queue + (fn [{:keys [db]} _] + {:db (assoc db :show-media-queue (not (:show-media-queue db))) + ::body-overflow! (not (:show-media-queue db))})) (rf/reg-event-fx ::toggle-loop-file @@ -77,8 +89,11 @@ match (assoc new-match :controllers controllers)] {:db (-> db (assoc :current-match match) + (assoc :show-media-queue false) + (assoc :show-mobile-nav false) (assoc :show-pagination-loading false)) - ::scroll-to-top nil}))) + ::scroll-to-top nil + ::body-overflow! false}))) (rf/reg-event-fx ::navigate diff --git a/src/frontend/tubo/subs.cljs b/src/frontend/tubo/subs.cljs index 1ca0067..ed51d60 100644 --- a/src/frontend/tubo/subs.cljs +++ b/src/frontend/tubo/subs.cljs @@ -3,13 +3,13 @@ [reagent.core :as r] [re-frame.core :as rf])) -(defonce is-window-visible +(defonce !is-window-visible (let [a (r/atom true)] (.addEventListener js/window "focus" #(reset! a true)) (.addEventListener js/window "blur" #(reset! a false)) a)) -(defonce scroll-distance +(defonce !scroll-distance (let [a (r/atom 0) compute-scroll-distance #(when (> (.-scrollY js/window) 0) (reset! a (+ (.-scrollY js/window) (.-innerHeight js/window))))] @@ -17,15 +17,28 @@ (.addEventListener js/window "touchmove" compute-scroll-distance) a)) +(def !elapsed-time (r/atom 0)) +(def !player (r/atom nil)) + (rf/reg-sub :is-window-visible (fn [_ _] - @is-window-visible)) + @!is-window-visible)) (rf/reg-sub :scrolled-to-bottom (fn [_ _] - (> (+ @scroll-distance 35) (.-scrollHeight js/document.body)))) + (> (+ @!scroll-distance 35) (.-scrollHeight js/document.body)))) + +(rf/reg-sub + :elapsed-time + (fn [db _] + !elapsed-time)) + +(rf/reg-sub + :player + (fn [db _] + !player)) (rf/reg-sub :http-response @@ -79,6 +92,18 @@ 3 "#F2690D" 4 "#629aa9"))) +(rf/reg-sub + :service-name + (fn [_] + (rf/subscribe [:service-id])) + (fn [id _] + (case id + 0 "YouTube" + 1 "SoundCloud" + 2 "media.ccc.de" + 3 "PeerTube" + 4 "Bandcamp"))) + (rf/reg-sub :services (fn [db _] @@ -146,6 +171,11 @@ (fn [db _] (:show-mobile-nav db))) +(rf/reg-sub + :show-media-queue + (fn [db _] + (:show-media-queue db))) + (rf/reg-sub :theme (fn [db _] diff --git a/src/frontend/tubo/views.cljs b/src/frontend/tubo/views.cljs index 5978808..736a60d 100644 --- a/src/frontend/tubo/views.cljs +++ b/src/frontend/tubo/views.cljs @@ -5,6 +5,7 @@ [reagent.core :as r] [tubo.components.navigation :as navigation] [tubo.components.audio-player :as player] + [tubo.components.play-queue :as queue] [tubo.events :as events] [tubo.routes :as routes])) @@ -152,4 +153,5 @@ (when-let [view (-> current-match :data :view)] [view current-match]) [footer] + [queue/queue] [player/player]]]])) -- cgit v1.2.3