From af5351fdb5325db4aff75faa85a5bb97fe0d1364 Mon Sep 17 00:00:00 2001 From: Miguel Ángel Moreno Date: Wed, 29 May 2024 10:41:21 +0200 Subject: refactor(frontend): modularize the app into standalone panels --- src/frontend/tubo/bookmarks/events.cljs | 235 +++++ src/frontend/tubo/bookmarks/modals.cljs | 44 + src/frontend/tubo/bookmarks/subs.cljs | 8 + src/frontend/tubo/bookmarks/views.cljs | 66 ++ src/frontend/tubo/channel/events.cljs | 52 + src/frontend/tubo/channel/subs.cljs | 8 + src/frontend/tubo/channel/views.cljs | 47 + src/frontend/tubo/comments/events.cljs | 55 + src/frontend/tubo/comments/views.cljs | 81 ++ src/frontend/tubo/components/audio_player.cljs | 157 --- src/frontend/tubo/components/comments.cljs | 78 -- src/frontend/tubo/components/items.cljs | 136 +-- src/frontend/tubo/components/layout.cljs | 93 +- src/frontend/tubo/components/modal.cljs | 33 - src/frontend/tubo/components/modals/bookmarks.cljs | 46 - src/frontend/tubo/components/navigation.cljs | 172 +--- src/frontend/tubo/components/notification.cljs | 28 - src/frontend/tubo/components/play_queue.cljs | 137 --- src/frontend/tubo/components/player.cljs | 74 +- src/frontend/tubo/components/video_player.cljs | 39 - src/frontend/tubo/core.cljs | 4 +- src/frontend/tubo/events.cljs | 1057 ++------------------ src/frontend/tubo/kiosks/events.cljs | 87 ++ src/frontend/tubo/kiosks/subs.cljs | 13 + src/frontend/tubo/kiosks/views.cljs | 43 + src/frontend/tubo/modals/events.cljs | 36 + src/frontend/tubo/modals/subs.cljs | 8 + src/frontend/tubo/modals/views.cljs | 34 + src/frontend/tubo/notifications/events.cljs | 28 + src/frontend/tubo/notifications/subs.cljs | 8 + src/frontend/tubo/notifications/views.cljs | 27 + src/frontend/tubo/player/events.cljs | 248 +++++ src/frontend/tubo/player/subs.cljs | 52 + src/frontend/tubo/player/views.cljs | 182 ++++ src/frontend/tubo/playlist/events.cljs | 46 + src/frontend/tubo/playlist/subs.cljs | 8 + src/frontend/tubo/playlist/views.cljs | 40 + src/frontend/tubo/queue/events.cljs | 75 ++ src/frontend/tubo/queue/subs.cljs | 25 + src/frontend/tubo/queue/views.cljs | 152 +++ src/frontend/tubo/routes.cljs | 85 +- src/frontend/tubo/search/events.cljs | 64 ++ src/frontend/tubo/search/subs.cljs | 18 + src/frontend/tubo/search/views.cljs | 61 ++ src/frontend/tubo/services/events.cljs | 29 + src/frontend/tubo/services/subs.cljs | 28 + src/frontend/tubo/services/views.cljs | 20 + src/frontend/tubo/settings/events.cljs | 60 ++ src/frontend/tubo/settings/subs.cljs | 8 + src/frontend/tubo/settings/views.cljs | 35 + src/frontend/tubo/stream/events.cljs | 32 + src/frontend/tubo/stream/subs.cljs | 8 + src/frontend/tubo/stream/views.cljs | 174 ++++ src/frontend/tubo/subs.cljs | 194 +--- src/frontend/tubo/utils.cljs | 26 +- src/frontend/tubo/views.cljs | 20 +- src/frontend/tubo/views/bookmarks.cljs | 79 -- src/frontend/tubo/views/channel.cljs | 47 - src/frontend/tubo/views/kiosk.cljs | 20 - src/frontend/tubo/views/playlist.cljs | 43 - src/frontend/tubo/views/search.cljs | 20 - src/frontend/tubo/views/settings.cljs | 33 - src/frontend/tubo/views/stream.cljs | 145 --- 63 files changed, 2615 insertions(+), 2396 deletions(-) create mode 100644 src/frontend/tubo/bookmarks/events.cljs create mode 100644 src/frontend/tubo/bookmarks/modals.cljs create mode 100644 src/frontend/tubo/bookmarks/subs.cljs create mode 100644 src/frontend/tubo/bookmarks/views.cljs create mode 100644 src/frontend/tubo/channel/events.cljs create mode 100644 src/frontend/tubo/channel/subs.cljs create mode 100644 src/frontend/tubo/channel/views.cljs create mode 100644 src/frontend/tubo/comments/events.cljs create mode 100644 src/frontend/tubo/comments/views.cljs delete mode 100644 src/frontend/tubo/components/audio_player.cljs delete mode 100644 src/frontend/tubo/components/comments.cljs delete mode 100644 src/frontend/tubo/components/modal.cljs delete mode 100644 src/frontend/tubo/components/modals/bookmarks.cljs delete mode 100644 src/frontend/tubo/components/notification.cljs delete mode 100644 src/frontend/tubo/components/play_queue.cljs delete mode 100644 src/frontend/tubo/components/video_player.cljs create mode 100644 src/frontend/tubo/kiosks/events.cljs create mode 100644 src/frontend/tubo/kiosks/subs.cljs create mode 100644 src/frontend/tubo/kiosks/views.cljs create mode 100644 src/frontend/tubo/modals/events.cljs create mode 100644 src/frontend/tubo/modals/subs.cljs create mode 100644 src/frontend/tubo/modals/views.cljs create mode 100644 src/frontend/tubo/notifications/events.cljs create mode 100644 src/frontend/tubo/notifications/subs.cljs create mode 100644 src/frontend/tubo/notifications/views.cljs create mode 100644 src/frontend/tubo/player/events.cljs create mode 100644 src/frontend/tubo/player/subs.cljs create mode 100644 src/frontend/tubo/player/views.cljs create mode 100644 src/frontend/tubo/playlist/events.cljs create mode 100644 src/frontend/tubo/playlist/subs.cljs create mode 100644 src/frontend/tubo/playlist/views.cljs create mode 100644 src/frontend/tubo/queue/events.cljs create mode 100644 src/frontend/tubo/queue/subs.cljs create mode 100644 src/frontend/tubo/queue/views.cljs create mode 100644 src/frontend/tubo/search/events.cljs create mode 100644 src/frontend/tubo/search/subs.cljs create mode 100644 src/frontend/tubo/search/views.cljs create mode 100644 src/frontend/tubo/services/events.cljs create mode 100644 src/frontend/tubo/services/subs.cljs create mode 100644 src/frontend/tubo/services/views.cljs create mode 100644 src/frontend/tubo/settings/events.cljs create mode 100644 src/frontend/tubo/settings/subs.cljs create mode 100644 src/frontend/tubo/settings/views.cljs create mode 100644 src/frontend/tubo/stream/events.cljs create mode 100644 src/frontend/tubo/stream/subs.cljs create mode 100644 src/frontend/tubo/stream/views.cljs delete mode 100644 src/frontend/tubo/views/bookmarks.cljs delete mode 100644 src/frontend/tubo/views/channel.cljs delete mode 100644 src/frontend/tubo/views/kiosk.cljs delete mode 100644 src/frontend/tubo/views/playlist.cljs delete mode 100644 src/frontend/tubo/views/search.cljs delete mode 100644 src/frontend/tubo/views/settings.cljs delete mode 100644 src/frontend/tubo/views/stream.cljs diff --git a/src/frontend/tubo/bookmarks/events.cljs b/src/frontend/tubo/bookmarks/events.cljs new file mode 100644 index 0000000..e0f2f2b --- /dev/null +++ b/src/frontend/tubo/bookmarks/events.cljs @@ -0,0 +1,235 @@ +(ns tubo.bookmarks.events + (:require + [nano-id.core :refer [nano-id]] + [promesa.core :as p] + [re-frame.core :as rf] + [tubo.bookmarks.modals :as modals])) + +(rf/reg-event-fx + :bookmarks/add + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ bookmark notify?]] + (let [updated-db (update db :bookmarks conj + (if (:id bookmark) + bookmark + (assoc bookmark :id (nano-id))))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx [[:dispatch [:modals/close]] + (when notify? + [:dispatch [:notifications/add + {:status-text + (str "Added playlist \"" (:name bookmark) "\"") + :failure :success}]])]}))) + +(rf/reg-event-fx + :bookmarks/remove + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ id notify?]] + (let [bookmark (first (filter #(= (:id %) id) (:bookmarks db))) + updated-db (update db :bookmarks + #(into [] (remove (fn [bookmark] + (= (:id bookmark) id)) %)))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx (if notify? + [[:dispatch [:notifications/add + {:status-text + (str "Removed playlist \"" (:name bookmark) "\"") + :failure :success}]]] + [])}))) + +(rf/reg-event-fx + :bookmarks/clear + (fn [{:keys [db]} _] + {:fx (conj (into + (map (fn [bookmark] + [:dispatch [:bookmarks/remove (:id bookmark)]]) + (rest (:bookmarks db))) + (map (fn [item] + [:dispatch [:likes/remove item]]) + (:items (first (:bookmarks db))))) + [:dispatch [:notifications/add + {:status-text "Cleared all playlists" + :failure :success}]])})) + +(rf/reg-event-fx + :likes/add-n + (fn [_ [_ items]] + {:fx (conj (map (fn [item] + [:dispatch [:likes/add item]]) items) + [:dispatch [:notifications/add + {:status-text (str "Added " (count items) + " items to likes") + :failure :success}]])})) + +(rf/reg-event-fx + :likes/add + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ item notify?]] + (when-not (some #(= (:url %) (:url item)) + (-> db :bookmarks first :items)) + (let [updated-db (update-in db [:bookmarks 0 :items] + #(into [] (conj (into [] %1) %2)) + (assoc item :bookmark-id + (-> db :bookmarks first :id)))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx (if notify? + [[:dispatch [:notifications/add + {:status-text "Added to favorites" + :failure :success}]]] + [])})))) + +(rf/reg-event-fx + :likes/remove + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ item notify?]] + (let [updated-db (update-in db [:bookmarks 0 :items] + (fn [items] + (remove #(= (:url %) (:url item)) items)))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx (if notify? + [[:dispatch [:notifications/add + {:status-text "Removed from favorites" + :failure :success}]]] + [])}))) + +(rf/reg-event-fx + :bookmark/add-n + (fn [_ [_ bookmark items]] + {:fx (conj (map (fn [item] + [:dispatch [:bookmark/add bookmark item]]) items) + [:dispatch [:notifications/add + {:status-text (str "Added " (count items) + " items to playlist \"" + (:name bookmark) "\"") + :failure :success}]])})) + +(rf/reg-event-fx + :bookmark/add + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ bookmark item notify?]] + (let [selected (first (filter #(= (:id %) (:id bookmark)) (:bookmarks db))) + pos (.indexOf (:bookmarks db) selected) + updated-db (if (some #(= (:url %) (:url item)) (:items selected)) + db + (update-in db [:bookmarks pos :items] + #(into [] (conj (into [] %1) %2)) + (assoc item :bookmark-id (:id bookmark))))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx [[:dispatch [:modals/close]] + (when notify? + [:dispatch [:notifications/add + {:status-text (str "Added to playlist \"" + (:name selected) "\"") + :failure :success}]])]}))) + +(rf/reg-event-fx + :bookmark/remove + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ bookmark]] + (let [selected (first (filter #(= (:id %) (:bookmark-id bookmark)) + (:bookmarks db))) + pos (.indexOf (:bookmarks db) selected) + updated-db (update-in db [:bookmarks pos :items] + #(remove (fn [item] + (= (:url item) (:url bookmark))) + %))] + {:db updated-db + :store (assoc store :bookmarks (:bookmarks updated-db)) + :fx [[:dispatch [:notifications/add + {:status-text (str "Removed from playlist \"" + (:name selected) "\"") + :failure :success}]]]}))) + +(rf/reg-event-fx + :bookmarks/add-imported + (fn [{:keys [db]} [_ bookmarks]] + {:fx (conj (map-indexed (fn [i bookmark] + (if (= i 0) + [:dispatch [:likes/add-n (:items bookmark)]] + [:dispatch [:bookmarks/add bookmark]])) + bookmarks) + [:dispatch [:notifications/add + {:status-text "Imported playlists successfully" + :failure :success}]])})) + +(defn fetch-imported-bookmarks-items + [bookmarks] + (-> #(-> (p/all (map (fn [stream] + (p/then (js/fetch + (str "/api/v1/streams/" + (js/encodeURIComponent stream))) + (fn [res] (.json res)))) + (:items %))) + (p/then (fn [results] + (assoc % :items results)))) + (map bookmarks) + p/all)) + +(rf/reg-event-fx + :bookmarks/process-import + (fn [{:keys [db]} [_ bookmarks]] + {:promise + {:call #(-> (fetch-imported-bookmarks-items bookmarks) + (p/then (fn [res] + (js->clj res :keywordize-keys true)))) + :on-success-n [[:notifications/clear] + [:bookmarks/add-imported]]} + :fx [[:dispatch [:notifications/add + {:status-text "Importing playlists..." + :failure :success} + false]]]})) + +(rf/reg-fx + :bookmarks/import! + (fn [file] + (-> (.text file) + (p/then + #(let [res (js->clj (.parse js/JSON %) :keywordize-keys true)] + (if (= (:format res) "Tubo") + (rf/dispatch [:bookmarks/process-import (:playlists res)]) + (throw (js/Error. "Format not supported"))))) + (p/catch js/Error + (fn [error] + (rf/dispatch [:notifications/add + {:status-text (.-message error) + :failure :error}])))))) + +(rf/reg-event-fx + :bookmarks/import + (fn [{:keys [db]} [_ files]] + {:fx (map (fn [file] [:bookmarks/import! file]) files)})) + +(rf/reg-event-fx + :bookmarks/export + (fn [{:keys [db]} [_]] + {:file-download + {:name "playlists.json" + :mime-type "application/json" + :data (.stringify + js/JSON + (clj->js {:format "Tubo" + :version 1 + :playlists + (map (fn [bookmark] + {:name (:name bookmark) + :items (map :url (:items bookmark))}) + (:bookmarks db))}))} + :fx [[:dispatch [:notifications/add + {:status-text "Exported playlists" + :failure :success}]]]})) + +(rf/reg-event-fx + :bookmarks/fetch-page + (fn [_] + {:document-title "Bookmarked Playlists"})) + +(rf/reg-event-fx + :bookmark/fetch-page + (fn [{:keys [db]} [_ playlist-id]] + (let [playlist (first (filter #(= (:id %) playlist-id) (:bookmarks db)))] + {:document-title (:name playlist)}))) diff --git a/src/frontend/tubo/bookmarks/modals.cljs b/src/frontend/tubo/bookmarks/modals.cljs new file mode 100644 index 0000000..6edc1ce --- /dev/null +++ b/src/frontend/tubo/bookmarks/modals.cljs @@ -0,0 +1,44 @@ +(ns tubo.bookmarks.modals + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.layout :as layout] + [tubo.modals.views :as modals])) + +(defn bookmark-item + [{:keys [items id name] :as bookmark} item] + [:div.flex.w-full.h-24.rounded.px-2.cursor-pointer.hover:bg-gray-100.dark:hover:bg-stone-800 + {:on-click #(rf/dispatch [(if (vector? item) :bookmark/add-n :bookmark/add) bookmark item])} + [:div.w-24 + [layout/thumbnail (-> items first :thumbnail-url) nil name nil + :classes [:h-24 :py-2] :rounded? true]] + [:div.flex.flex-col.py-4.px-4 + [:h1.line-clamp-1.font-bold {:title name} name] + [:span.text-sm (str (count items) " streams")]]]) + +(defn add-bookmark + [item] + (let [!bookmark-name (r/atom "")] + (fn [] + [modals/modal-content "Create New Playlist?" + [layout/text-input "Title" :text-input @!bookmark-name + #(reset! !bookmark-name (.. % -target -value)) "Playlist name"] + [layout/secondary-button "Back" + #(rf/dispatch [:modals/close])] + [layout/primary-button "Create Playlist" + #(rf/dispatch [:bookmarks/add {:name @!bookmark-name} item true]) + [:i.fa-solid.fa-plus]]]))) + +(defn add-to-bookmark + [item] + (let [bookmarks @(rf/subscribe [:bookmarks])] + [modals/modal-content "Add to Playlist" + [:div.flex-auto + [:div.flex.justify-center.items-center.pb-4 + [layout/primary-button "Create New Playlist" + #(rf/dispatch [:modals/open [add-bookmark item]]) + [:i.fa-solid.fa-plus]]] + [:div.flex.flex-col.gap-y-2.pr-2 + (for [[i bookmark] (map-indexed vector bookmarks)] + ^{:key i} [bookmark-item bookmark item])]]])) diff --git a/src/frontend/tubo/bookmarks/subs.cljs b/src/frontend/tubo/bookmarks/subs.cljs new file mode 100644 index 0000000..1091cb1 --- /dev/null +++ b/src/frontend/tubo/bookmarks/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.bookmarks.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :bookmarks + (fn [db _] + (:bookmarks db))) diff --git a/src/frontend/tubo/bookmarks/views.cljs b/src/frontend/tubo/bookmarks/views.cljs new file mode 100644 index 0000000..51f2ef5 --- /dev/null +++ b/src/frontend/tubo/bookmarks/views.cljs @@ -0,0 +1,66 @@ +(ns tubo.bookmarks.views + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as modals] + [tubo.components.items :as items] + [tubo.components.layout :as layout])) + +(defn bookmarks + [] + (let [!menu-active? (r/atom nil)] + (fn [] + (let [color @(rf/subscribe [:service-color]) + bookmarks @(rf/subscribe [:bookmarks]) + items (map + #(assoc % + :url (rfe/href :bookmark-page nil {:id (:id %)}) + :thumbnail-url (-> % :items first :thumbnail-url) + :stream-count (count (:items %)) + :bookmark-id (:id %)) + bookmarks)] + [layout/content-container + [layout/content-header "Bookmarked Playlists" + [layout/popover-menu !menu-active? + [{:label "Add New" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-bookmark]])} + [:<> + [:input.hidden + {:id "file-selector" + :type "file" + :multiple "multiple" + :on-click #(reset! !menu-active? true) + :on-change #(rf/dispatch [:bookmarks/import (.. % -target -files)])}] + [:label.whitespace-nowrap.cursor-pointer.w-full.h-full.absolute.right-0.top-0 + {:for "file-selector"}] + [:span.text-xs.w-10.min-w-4.w-4.flex.items-center [:i.fa-solid.fa-file-import]] + [:span "Import"]] + {:label "Export" + :icon [:i.fa-solid.fa-file-export] + :on-click #(rf/dispatch [:bookmarks/export])} + {:label "Clear All" + :icon [:i.fa-solid.fa-trash] + :on-click #(rf/dispatch [:bookmarks/clear])}]]] + [items/related-streams items]])))) + +(defn bookmark + [] + (let [!menu-active? (r/atom nil)] + (fn [] + (let [bookmarks @(rf/subscribe [:bookmarks]) + service-color @(rf/subscribe [:service-color]) + {{:keys [id]} :query-params} @(rf/subscribe [:current-match]) + {:keys [items name]} (first (filter #(= (:id %) id) bookmarks))] + [layout/content-container + [layout/content-header name + (when-not (empty? items) + [layout/popover-menu !menu-active? + [{:label "Add to queue" + :icon [:i.fa-solid.fa-headphones] + :on-click #(rf/dispatch [:queue/add-n items])} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark items]])}]])] + [items/related-streams (map #(assoc % :type "stream" :bookmark-id id) items)]])))) diff --git a/src/frontend/tubo/channel/events.cljs b/src/frontend/tubo/channel/events.cljs new file mode 100644 index 0000000..99903d5 --- /dev/null +++ b/src/frontend/tubo/channel/events.cljs @@ -0,0 +1,52 @@ +(ns tubo.channel.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :channel/fetch + (fn [{:keys [db]} [_ uri on-success on-error]] + (api/get-request + (str "/channels/" (js/encodeURIComponent uri)) + on-success on-error))) + +(rf/reg-event-fx + :channel/load-page + (fn [{:keys [db]} [_ res]] + (let [channel-res (js->clj res :keywordize-keys true)] + {:db (assoc db :channel channel-res + :show-page-loading false) + :fx [[:dispatch [:services/fetch channel-res]] + [:document-title (:name channel-res)]]}))) + +(rf/reg-event-fx + :channel/fetch-page + (fn [{:keys [db]} [_ uri]] + {:fx [[:dispatch [:channel/fetch uri [:channel/load-page] [:bad-response]]]] + :db (assoc db :show-page-loading true)})) + +(rf/reg-event-db + :channel/load-paginated + (fn [db [_ res]] + (let [channel-res (js->clj res :keywordize-keys true)] + (if (empty? (:related-streams channel-res)) + (-> db + (assoc-in [:channel :next-page] nil) + (assoc :show-pagination-loading false)) + (-> db + (update-in [:channel :related-streams] #(apply conj %1 %2) + (:related-streams channel-res)) + (assoc-in [:channel :next-page] (:next-page channel-res)) + (assoc :show-pagination-loading false)))))) + +(rf/reg-event-fx + :channel/fetch-paginated + (fn [{:keys [db]} [_ uri next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request + (str "/channels/" (js/encodeURIComponent uri) ) + [:channel/load-paginated] [:bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) diff --git a/src/frontend/tubo/channel/subs.cljs b/src/frontend/tubo/channel/subs.cljs new file mode 100644 index 0000000..8c00d6d --- /dev/null +++ b/src/frontend/tubo/channel/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.channel.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :channel + (fn [db _] + (:channel db))) diff --git a/src/frontend/tubo/channel/views.cljs b/src/frontend/tubo/channel/views.cljs new file mode 100644 index 0000000..b799ef1 --- /dev/null +++ b/src/frontend/tubo/channel/views.cljs @@ -0,0 +1,47 @@ +(ns tubo.channel.views + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [tubo.bookmarks.modals :as modals] + [tubo.components.items :as items] + [tubo.components.layout :as layout])) + +(defn channel + [query-params] + (let [!menu-active? (r/atom nil) + !show-description? (r/atom false)] + (fn [{{:keys [url]} :query-params}] + (let [{:keys [banner avatar name description subscriber-count next-page + related-streams]} @(rf/subscribe [:channel]) + next-page-url (:url next-page) + service-color @(rf/subscribe [:service-color]) + scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom]) + page-loading? @(rf/subscribe [:show-page-loading])] + (when scrolled-to-bottom? + (rf/dispatch [:channel/fetch-paginated url next-page-url])) + [:<> + (when-not page-loading? + (when banner + [:div.flex.justify-center.h-24 + [:img.min-w-full.min-h-full.object-cover {:src banner}]])) + [layout/content-container + [:div.flex.items-center.justify-between + [:div.flex.items-center.my-4.mx-2 + [layout/uploader-avatar {:uploader-avatar avatar :uploader-name name}] + [:div.m-4 + [:h1.text-2xl.line-clamp-1.font-semibold {:title name} name] + (when subscriber-count + [:div.flex.my-2.items-center + [:i.fa-solid.fa-users.text-xs] + [:span.mx-2 (.toLocaleString subscriber-count)]])]] + (when related-streams + [layout/popover-menu !menu-active? + [{:label "Add to queue" + :icon [:i.fa-solid.fa-headphones] + :on-click #(rf/dispatch [:queue/add-n related-streams])} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark related-streams]])}]])] + [layout/show-more-container @!show-description? description + #(reset! !show-description? (not @!show-description?))] + [items/related-streams related-streams next-page-url]]])))) diff --git a/src/frontend/tubo/comments/events.cljs b/src/frontend/tubo/comments/events.cljs new file mode 100644 index 0000000..ab79997 --- /dev/null +++ b/src/frontend/tubo/comments/events.cljs @@ -0,0 +1,55 @@ +(ns tubo.comments.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :comments/fetch + (fn [{:keys [db]} [_ url on-success on-error params]] + (api/get-request (str "/comments/" (js/encodeURIComponent url)) + on-success on-error params))) + +(rf/reg-event-db + :comments/load-page + (fn [db [_ res]] + (-> db + (assoc-in [:stream :comments-page] (js->clj res :keywordize-keys true)) + (assoc-in [:stream :show-comments-loading] false)))) + +(rf/reg-event-fx + :comments/fetch-page + (fn [{:keys [db]} [_ url]] + {:fx [[:dispatch [:comments/fetch url + [:comments/load-page] [:bad-response]]]] + :db (-> db + (assoc-in [:stream :show-comments-loading] true) + (assoc-in [:stream :show-comments] true))})) + +(rf/reg-event-db + :comments/toggle-replies + (fn [db [_ comment-id]] + (update-in db [:stream :comments-page :comments] + (fn [comments] + (map #(if (= (:id %) comment-id) + (assoc % :show-replies (not (:show-replies %))) + %) + comments))))) + +(rf/reg-event-db + :comments/load-paginated + (fn [db [_ res]] + (-> db + (update-in [:stream :comments-page :comments] #(apply conj %1 %2) + (:comments (js->clj res :keywordize-keys true))) + (assoc-in [:stream :comments-page :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + :comments/fetch-paginated + (fn [{:keys [db]} [_ url next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading true) + :fx [[:dispatch [:comments/fetch url + [:comments/load-paginated] [:bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}]]]}))) diff --git a/src/frontend/tubo/comments/views.cljs b/src/frontend/tubo/comments/views.cljs new file mode 100644 index 0000000..e9a08a3 --- /dev/null +++ b/src/frontend/tubo/comments/views.cljs @@ -0,0 +1,81 @@ +(ns tubo.comments.views + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.layout :as layout] + [tubo.utils :as utils])) + +(defn comment-top-metadata + [{:keys [pinned? uploader-name uploader-url uploader-verified? stream-position]}] + [:div.flex.items-center + (when pinned? + [:i.fa-solid.fa-thumbtack.mr-2.text-xs]) + (when uploader-name + [:div.flex.items-stretch + [:a {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name} + [:h1.text-neutral-800.dark:text-gray-300.font-bold.line-clamp-1 uploader-name]] + (when stream-position + [:div.text-neutral-600.dark:text-neutral-300 + [:span.mx-2.text-xs.whitespace-nowrap (utils/format-duration stream-position)]])]) + (when uploader-verified? + [:i.fa-solid.fa-circle-check.ml-2])]) + +(defn comment-bottom-metadata + [{:keys [upload-date like-count hearted-by-uploader? author-avatar author-name]}] + [:div..flex.items-center.my-2 + [:div.mr-4 + [:p (utils/format-date-ago upload-date)]] + (when (and like-count (> like-count 0)) + [:div.flex.items-center.my-2 + [:i.fa-solid.fa-thumbs-up.text-xs] + [:p.mx-1 like-count]]) + (when hearted-by-uploader? + [:div.relative.w-4.h-4.mx-2 + [:i.fa-solid.fa-heart.absolute.-bottom-1.-right-1.text-xs.text-red-500] + [:img.rounded-full.object-covermax-w-full.min-h-full + {:src author-avatar :title (str author-name " hearted this comment")}]])]) + + +(defn comment-item + [{:keys [id text replies reply-count show-replies] :as comment}] + [:div.flex.gap-x-4.my-4 + [layout/uploader-avatar comment] + [:div + [comment-top-metadata comment] + [:div.my-2 + [:p {:dangerouslySetInnerHTML {:__html text} :class "[overflow-wrap:anywhere]"}]] + [comment-bottom-metadata comment] + [:div.flex.items-center.cursor-pointer + {:on-click #(rf/dispatch [:comments/toggle-replies id])} + (when replies + (if show-replies + [:<> + [:p.font-bold "Hide replies"] + [:i.fa-solid.fa-turn-up.mx-2.text-xs]] + [:<> + [:p.font-bold (str reply-count (if (= reply-count 1) " reply" " replies"))] + [:i.fa-solid.fa-turn-down.mx-2.text-xs]]))]]]) + +(defn comments + [{:keys [comments next-page disabled?]} {:keys [uploader-name uploader-avatar url]}] + (let [pagination-loading? @(rf/subscribe [:show-pagination-loading]) + service-color @(rf/subscribe [:service-color])] + [:div.flex.flex-col + [:div + (for [[i {:keys [replies show-replies] :as comment}] (map-indexed vector comments)] + [:div.flex.flex-col {:key i} + [:div.flex + [comment-item (assoc comment :author-name uploader-name :author-avatar uploader-avatar)]] + (when (and replies show-replies) + [:div {:style {:marginLeft "32px"}} + (for [[i reply] (map-indexed vector (:items replies))] + ^{:key i} [comment-item (assoc reply :author-name uploader-name :author-avatar uploader-avatar)])])])] + (when (:url next-page) + (if pagination-loading? + (layout/loading-icon service-color) + [:div.flex.justify-center + [layout/secondary-button + "Show more comments" + #(rf/dispatch [:comments/fetch-paginated url (:url next-page)]) + [:i.fa-solid.fa-plus]]]))])) diff --git a/src/frontend/tubo/components/audio_player.cljs b/src/frontend/tubo/components/audio_player.cljs deleted file mode 100644 index 5e46909..0000000 --- a/src/frontend/tubo/components/audio_player.cljs +++ /dev/null @@ -1,157 +0,0 @@ -(ns tubo.components.audio-player - (:require - [goog.object :as gobj] - [reagent.core :as r] - [reagent.dom :as rdom] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.layout :as layout] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.components.player :as player] - [tubo.events :as events] - [tubo.utils :as utils])) - -(defn audio-source - [!player] - (let [{:keys [stream]} @(rf/subscribe [:media-queue-stream]) - media-queue-pos @(rf/subscribe [:media-queue-pos])] - (r/create-class - {:display-name "AudioPlayer" - :component-did-mount - (fn [this] - (set! (.-onended (rdom/dom-node this)) - #(rf/dispatch [::events/change-media-queue-pos (+ media-queue-pos 1)])) - (when stream - (set! (.-src (rdom/dom-node this)) stream))) - :reagent-render - (fn [!player] - (let [!elapsed-time @(rf/subscribe [:elapsed-time]) - muted? @(rf/subscribe [:muted]) - volume-level @(rf/subscribe [:volume-level]) - loop-playback @(rf/subscribe [:loop-playback])] - [:audio - {:ref #(reset! !player %) - :loop (= loop-playback :stream) - :muted muted? - :on-loaded-data #(rf/dispatch [::events/player-start]) - :on-time-update #(reset! !elapsed-time (.-currentTime @!player)) - :on-pause #(rf/dispatch [::events/change-player-paused true]) - :on-play #(rf/dispatch [::events/change-player-paused false])}]))}))) - -(defn main-controls - [service-color] - (let [media-queue @(rf/subscribe [:media-queue]) - media-queue-pos @(rf/subscribe [:media-queue-pos]) - loading? @(rf/subscribe [:show-audio-player-loading]) - !elapsed-time @(rf/subscribe [:elapsed-time]) - !player @(rf/subscribe [:player]) - paused? @(rf/subscribe [:paused]) - player-ready? @(rf/subscribe [:player-ready]) - loop-playback @(rf/subscribe [:loop-playback])] - [:div.flex.flex-col.items-center.ml-auto - [:div.flex.justify-end - [player/loop-button loop-playback service-color] - [player/button - [:i.fa-solid.fa-backward-step] - #(when (and media-queue (not= media-queue-pos 0)) - (rf/dispatch [::events/change-media-queue-pos (- media-queue-pos 1)])) - :disabled? (not (and media-queue (not= media-queue-pos 0)))] - [player/button [:i.fa-solid.fa-backward] - #(rf/dispatch [::events/set-player-time (- @!elapsed-time 5)])] - [player/button - (if (or (not loading?) player-ready?) - (if paused? - [:i.fa-solid.fa-play] - [:i.fa-solid.fa-pause]) - [layout/loading-icon service-color "lg:text-2xl"]) - #(rf/dispatch [::events/set-player-paused (not paused?)]) - :show-on-mobile? true - :extra-classes "lg:text-2xl"] - [player/button [:i.fa-solid.fa-forward] - #(rf/dispatch [::events/set-player-time (+ @!elapsed-time 5)])] - [player/button [:i.fa-solid.fa-forward-step] - #(when (and media-queue (< (+ media-queue-pos 1) (count media-queue))) - (rf/dispatch [::events/change-media-queue-pos (+ media-queue-pos 1)])) - :disabled? (not (and media-queue (< (+ media-queue-pos 1) (count media-queue))))]] - [:div.hidden.lg:flex.items-center.text-sm - [:span.mx-2 - (if (and @!player @!elapsed-time) (utils/format-duration @!elapsed-time) "00:00")] - [:div.w-20.lg:w-64.mx-2.flex.items-center - [player/time-slider !player !elapsed-time service-color]] - [:span.mx-2 - (if (and @!player player-ready?) (utils/format-duration (.-duration @!player)) "00:00")]]])) - -(defn player - [] - (let [!menu-active? (r/atom nil)] - (fn [] - (let [{:keys - [uploader-name uploader-url thumbnail-url - name stream url service-id] :as current-stream} - @(rf/subscribe [:media-queue-stream]) - media-queue @(rf/subscribe [:media-queue]) - show-audio-player? @(rf/subscribe [:show-audio-player]) - show-media-queue? @(rf/subscribe [:show-media-queue]) - volume-level @(rf/subscribe [:volume-level]) - muted? @(rf/subscribe [:muted]) - bookmarks @(rf/subscribe [:bookmarks]) - !player @(rf/subscribe [:player]) - media-queue-pos @(rf/subscribe [:media-queue-pos]) - {:keys [theme]} @(rf/subscribe [:settings]) - auto-theme @(rf/subscribe [:auto-theme]) - service-color (and service-id (utils/get-service-color service-id)) - bg-color (str "rgba(" (if (or (and (= theme "auto") (= auto-theme :dark)) (= theme "dark")) - "23, 23, 23" "255, 255, 255") ", 0.95)") - liked? (some #(= (:url %) url) (-> bookmarks first :items))] - (when show-audio-player? - [:div.sticky.bottom-0.z-10.p-3.absolute.box-border.m-0.transition-all.ease-in.delay-0 - {:style - {:visibility (when show-media-queue? "hidden") - :opacity (if show-media-queue? 0 1) - :background-image (str "linear-gradient(0deg, " bg-color "," bg-color "), url(\"" thumbnail-url "\")") - :backgroundSize "cover" - :backgroundPosition "center" - :backgroundRepeat "no-repeat"}} - [:div.flex.items-center.justify-between - [:div.flex.items-center.lg:flex-1 - [: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-2 - [:a.text-xs.line-clamp-1 - {:href (rfe/href :tubo.routes/stream nil {:url url}) - :title name} - 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}) - :title uploader-name} - uploader-name]] - [audio-source !player]] - [main-controls service-color] - [:div.flex.lg:justify-end.lg:flex-1 - [player/volume-slider !player volume-level muted? service-color] - [player/button [:i.fa-solid.fa-list] #(rf/dispatch [::events/show-media-queue true]) - :show-on-mobile? true - :extra-classes "pl-4 pr-3"] - [layout/popover-menu !menu-active? - [{:label (if liked? "Remove favorite" "Favorite") - :icon [:i.fa-solid.fa-heart (when liked? {:style {:color service-color}})] - :on-click #(rf/dispatch [(if liked? ::events/remove-from-likes ::events/add-to-likes) current-stream])} - {:label "Play radio" - :icon [:i.fa-solid.fa-tower-cell] - :on-click #(rf/dispatch [::events/start-stream-radio current-stream])} - {:label "Add current to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal current-stream]])} - {:label "Add queue to playlist" - :icon [:i.fa-solid.fa-list] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal media-queue]])} - {:label "Remove from queue" - :icon [:i.fa-solid.fa-trash] - :on-click #(rf/dispatch [::events/remove-from-media-queue media-queue-pos])} - {:label "Close player" - :icon [:i.fa-solid.fa-close] - :on-click #(rf/dispatch [::events/dispose-audio-player])}] - :menu-styles {:bottom "30px" :top nil :right "30px"} - :extra-classes "pt-1 !pl-3 pr-3"]]]]))))) diff --git a/src/frontend/tubo/components/comments.cljs b/src/frontend/tubo/components/comments.cljs deleted file mode 100644 index 001fdee..0000000 --- a/src/frontend/tubo/components/comments.cljs +++ /dev/null @@ -1,78 +0,0 @@ -(ns tubo.components.comments - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.layout :as layout] - [tubo.events :as events] - [tubo.utils :as utils])) - -(defn comment-item - [{:keys [id text uploader-name uploader-avatar uploader-url stream-position - upload-date uploader-verified? like-count hearted-by-uploader? - pinned? replies reply-count key show-replies author-name author-avatar]}] - [:div.flex.my-4 {:key key} - (when uploader-avatar - [:div.flex.items-center.py-3.box-border.h-12 - (when uploader-url - [:div.w-12 - [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} - [:img.rounded-full.object-cover.min-w-full.min-h-full {:src uploader-avatar}]]])]) - [:div.ml-4 - [:div.flex.items-center - (when pinned? - [:i.fa-solid.fa-thumbtack.mr-2.text-xs]) - (when uploader-name - [:div.flex.items-stretch - [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} - [:h1.text-neutral-800.dark:text-gray-300.font-bold.line-clamp-1 uploader-name]] - (when stream-position - [:div.text-neutral-600.dark:text-neutral-300 - [:span.mx-2.text-xs.whitespace-nowrap (utils/format-duration stream-position)]])]) - (when uploader-verified? - [:i.fa-solid.fa-circle-check.ml-2])] - [:div.my-2 - [:p {:dangerouslySetInnerHTML {:__html text} :class "[overflow-wrap:anywhere]"}]] - [:div..flex.items-center.my-2 - [:div.mr-4 - [:p (utils/format-date-ago upload-date)]] - (when (and like-count (> like-count 0)) - [:div.flex.items-center.my-2 - [:i.fa-solid.fa-thumbs-up.text-xs] - [:p.mx-1 like-count]]) - (when hearted-by-uploader? - [:div.relative.w-4.h-4.mx-2 - [:i.fa-solid.fa-heart.absolute.-bottom-1.-right-1.text-xs.text-red-500] - [:img.rounded-full.object-covermax-w-full.min-h-full - {:src author-avatar :title (str author-name " hearted this comment")}]])] - [:div.flex.items-center.cursor-pointer - {:on-click #(rf/dispatch [::events/toggle-comment-replies id])} - (when replies - (if show-replies - [:<> - [:p.font-bold "Hide replies"] - [:i.fa-solid.fa-turn-up.mx-2.text-xs]] - [:<> - [:p.font-bold (str reply-count (if (= reply-count 1) " reply" " replies"))] - [:i.fa-solid.fa-turn-down.mx-2.text-xs]]))]]]) - -(defn comments - [{:keys [comments next-page disabled?]} author-name author-avatar url] - (let [pagination-loading? @(rf/subscribe [:show-pagination-loading]) - service-color @(rf/subscribe [:service-color])] - [:div.flex.flex-col - [:div - (for [[i {:keys [replies show-replies] :as comment}] (map-indexed vector comments)] - [:div.flex.flex-col {:key i} - [comment-item (assoc comment :key i :author-name author-name :author-avatar author-avatar)] - (when (and replies show-replies) - [:div {:style {:marginLeft "32px"}} - (for [[i reply] (map-indexed vector (:items replies))] - [comment-item (assoc reply :key i :author-name author-name :author-avatar author-avatar)])])])] - (when (:url next-page) - (if pagination-loading? - (layout/loading-icon service-color) - [:div.flex.justify-center - [layout/secondary-button - "Show more comments" - #(rf/dispatch [::events/comments-pagination url (:url next-page)]) - "fa-solid fa-plus"]]))])) diff --git a/src/frontend/tubo/components/items.cljs b/src/frontend/tubo/components/items.cljs index 82641b9..22fd432 100644 --- a/src/frontend/tubo/components/items.cljs +++ b/src/frontend/tubo/components/items.cljs @@ -1,100 +1,106 @@ (ns tubo.components.items (:require - [reagent.core :as r] [re-frame.core :as rf] + [reagent.core :as r] [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as bookmarks] [tubo.components.layout :as layout] - [tubo.components.modal :as modal] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.events :as events] + [tubo.modals.views :as modals] [tubo.utils :as utils])) -(defn item-content - [{:keys [audio-streams video-streams type service-id bookmark-id url] :as item} item-route bookmarks] - (let [!menu-active? (r/atom false)] - (fn [{:keys [type service-id url name thumbnail-url description subscriber-count - stream-count verified? uploader-name uploader-url - uploader-avatar upload-date short-description view-count - duration audio-streams video-streams bookmark-id] :as item} item-route bookmarks] - (let [stream? (or (= type "stream") audio-streams video-streams) - liked? (some #(= (:url %) url) (-> bookmarks first :items)) - items (if stream? +(defn item-popover + [_ _] + (let [!menu-active? (r/atom nil)] + (fn [{:keys [service-id audio-streams video-streams type url bookmark-id uploader-url] :as item} bookmarks] + (let [liked? (some #(= (:url %) url) (-> bookmarks first :items)) + items (if (or (= type "stream") audio-streams video-streams) [{:label "Add to queue" :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/switch-to-audio-player item])} + :on-click #(rf/dispatch [:player/switch-to-background item])} {:label "Play radio" :icon [:i.fa-solid.fa-tower-cell] - :on-click #(rf/dispatch [::events/start-stream-radio item])} + :on-click #(rf/dispatch [:player/start-radio item])} {:label (if liked? "Remove favorite" "Favorite") - :icon [:i.fa-solid.fa-heart (when (and liked? service-id) {:style {:color (utils/get-service-color service-id)}})] - :on-click #(rf/dispatch [(if liked? ::events/remove-from-likes ::events/add-to-likes) item true])} + :icon [:i.fa-solid.fa-heart (when (and liked? service-id) + {:style {:color (utils/get-service-color service-id)}})] + :on-click #(rf/dispatch [(if liked? :likes/remove :likes/add) item true])} {:label "Add to playlist" :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal item]])} + :on-click #(rf/dispatch [:modals/open [bookmarks/add-to-bookmark item]])} (when (some #(= (:url %) url) (:items (first (filter #(= (:id %) bookmark-id) bookmarks)))) {:label "Remove from playlist" :icon [:i.fa-solid.fa-trash] - :on-click #(rf/dispatch [::events/remove-from-bookmark-list item])})] + :on-click #(rf/dispatch [:bookmark/remove item])}) + {:label "Show channel details" + :icon [:i.fa-solid.fa-user] + :on-click #(rf/dispatch [:navigate + {:name :channel-page + :params {} + :query {:url uploader-url}}])}] [(when (and bookmarks (some #(= (:id %) bookmark-id) (rest bookmarks))) {:label "Remove playlist" :icon [:i.fa-solid.fa-trash] - :on-click #(rf/dispatch [::events/remove-bookmark-list bookmark-id true])})])] - [:<> - (when name - [:div.flex.items-center.my-2 - [:a {:href item-route :title name} - [:h1.line-clamp-2.my-1 {:class "[overflow-wrap:anywhere]"} name]] - (when (and verified? (not uploader-url)) - [:i.fa-solid.fa-circle-check.pl-2])]) - [:div.flex.justify-between - [:div.flex.items-center.my-2 - (if uploader-url - [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} - [:h1.line-clamp-1.text-neutral-800.dark:text-gray-300.font-semibold.pr-2.break-all - {:class "[overflow-wrap:anywhere]"} - uploader-name]] - [:h1.line-clamp-1.text-neutral-800.dark:text-gray-300.font-bold.pr-2 - {:title uploader-name} - uploader-name]) - (when (and uploader-url verified?) - [:i.fa-solid.fa-circle-check])] - (when-not (empty? (remove nil? items)) - [layout/popover-menu !menu-active? items])] - (when (and subscriber-count (not stream?)) - [:div.flex.items-center - [:i.fa-solid.fa-users.text-xs] - [:p.mx-2 (utils/format-quantity subscriber-count)]]) - (when stream-count - [:div.flex.items-center - [:i.fa-solid.fa-video.text-xs] - [:p.mx-2 (utils/format-quantity stream-count)]]) - [:div.flex.my-1.justify-between - [:p (utils/format-date-ago upload-date)] - (when view-count - [:div.flex.items-center.h-full.pl-2 - [:i.fa-solid.fa-eye.text-xs] - [:p.pl-1.5 (utils/format-quantity view-count)]])]])))) + :on-click #(rf/dispatch [:bookmarks/remove bookmark-id true])})])] + (when (not-empty (remove nil? items)) + [layout/popover-menu !menu-active? items]))))) + +(defn item-content + [{:keys [url name uploader-url uploader-name subscriber-count view-count stream-count verified?] :as item} route bookmarks] + [:div + (when name + [:div.flex.items-center.my-2 + [:a {:href route :title name} + [:h1.line-clamp-2.my-1 {:class "[overflow-wrap:anywhere]"} name]] + (when (and verified? (not uploader-url)) + [:i.fa-solid.fa-circle-check.pl-2])]) + [:div.flex.justify-between + [:div.flex.items-center.my-2 + (conj + (when uploader-url + [:a {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name + :key url}]) + [:h1.text-neutral-800.dark:text-gray-300.font-semibold.pr-2.line-clamp-1.break-all + {:class "[overflow-wrap:anywhere]" :title uploader-name :key url} + uploader-name]) + (when (and uploader-url verified?) + [:i.fa-solid.fa-circle-check])] + [item-popover item bookmarks]] + (when subscriber-count + [:div.flex.items-center + [:i.fa-solid.fa-users.text-xs] + [:span.mx-2 (utils/format-quantity subscriber-count)]]) + (when stream-count + [:div.flex.items-center + [:i.fa-solid.fa-video.text-xs] + [:span.mx-2 (utils/format-quantity stream-count)]]) + [:div.flex.my-1.justify-between + [:span (utils/format-date-ago (:upload-date item))] + (when view-count + [:div.flex.items-center.h-full.pl-2 + [:i.fa-solid.fa-eye.text-xs] + [:p.pl-1.5 (utils/format-quantity view-count)]])]]) (defn generic-item [{:keys [url name thumbnail-url duration] :as item} bookmarks] (let [item-url (case (:type item) - "stream" (rfe/href :tubo.routes/stream nil {:url url}) - "channel" (rfe/href :tubo.routes/channel nil {:url url}) - "playlist" (rfe/href :tubo.routes/playlist nil {:url url}) + "stream" (rfe/href :stream-page nil {:url url}) + "channel" (rfe/href :channel-page nil {:url url}) + "playlist" (rfe/href :playlist-page nil {:url url}) url)] [:div.w-full [:div.flex.flex-col.max-w-full.min-h-full.max-h-full - [layout/thumbnail thumbnail-url item-url name duration] + [layout/thumbnail thumbnail-url item-url name duration + :classes [:py-2 :h-44 "xs:h-28"] :rounded? true] [item-content item item-url bookmarks]]])) (defn related-streams [related-streams next-page-url] - (let [service-color @(rf/subscribe [:service-color]) + (let [service-color @(rf/subscribe [:service-color]) pagination-loading? @(rf/subscribe [:show-pagination-loading]) - bookmarks @(rf/subscribe [:bookmarks])] + bookmarks @(rf/subscribe [:bookmarks])] [:div.flex.flex-col.items-center.flex-auto.my-2.md:my-8 - [modal/modal] + [modals/modal] (if (empty? related-streams) [:div.flex.items-center.flex-auto.flex-col.justify-center.gap-y-4 [:i.fa-solid.fa-ghost.text-3xl] @@ -104,4 +110,4 @@ (for [[i item] (map-indexed vector related-streams)] ^{:key i} [generic-item item bookmarks])]) (when-not (empty? next-page-url) - [layout/loading-icon service-color "text-2xl" (when-not pagination-loading? "invisible")])])) + [layout/loading-icon service-color [:text-2xl (when-not pagination-loading? :invisible)]])])) diff --git a/src/frontend/tubo/components/layout.cljs b/src/frontend/tubo/components/layout.cljs index a695102..436ff83 100644 --- a/src/frontend/tubo/components/layout.cljs +++ b/src/frontend/tubo/components/layout.cljs @@ -1,22 +1,23 @@ (ns tubo.components.layout (:require - [reagent.core :as r] [re-frame.core :as rf] - [tubo.utils :as utils] - [svgreq.core :as svgreq])) + [reitit.frontend.easy :as rfe] + [reagent.core :as r] + [svgreq.core :as svgreq] + [tubo.utils :as utils])) (defn thumbnail - [thumbnail-url route name duration & {:keys [classes rounded?] :or {classes "h-44 xs:h-28" rounded? true}}] - [:div.flex.py-2.box-border {:class classes} + [thumbnail-url route name duration & {:keys [classes rounded?]}] + [:div.flex.box-border {:class classes} [:div.relative.min-w-full [:a.absolute.min-w-full.min-h-full.z-10 {:href route :title name}] (if thumbnail-url - [:img.object-cover.min-h-full.max-h-full.min-w-full {:src thumbnail-url :class (when rounded? "rounded")}] + [:img.object-cover.min-h-full.max-h-full.min-w-full {:src thumbnail-url :class (when rounded? :rounded)}] [:div.bg-gray-300.flex.min-h-full.min-w-full.justify-center.items-center.rounded [:i.fa-solid.fa-image.text-3xl.text-white]]) (when duration [:div.rounded.p-2.absolute {:style {:bottom 5 :right 5 :background "rgba(0,0,0,.7)" :zIndex "0"}} - [:p.text-white {:style {:fontSize "14px"}} + [:p.text-white.text-md (if (= duration 0) "LIVE" (utils/format-duration duration))]])]]) @@ -28,13 +29,14 @@ (js-obj "height" width "width" height))) (defn loading-icon - [service-color & styles] + [service-color & classes] [:div.w-full.flex.justify-center.items-center.flex-auto [:i.fas.fa-circle-notch.fa-spin - {:class (apply str (if (> (count styles) 1) (interpose " " styles) styles)) + {:class classes :style {:color service-color}}]]) -(defn focus-overlay [on-click active? transparent?] +(defn focus-overlay + [on-click active? transparent?] [:div.w-full.fixed.min-h-screen.right-0.top-0.transition-all.delay-75.ease-in-out.z-20 {:class (when-not transparent? "bg-black") :style {:visibility (when-not active? "hidden") @@ -48,49 +50,49 @@ [:div.flex.flex-col.flex-auto.items-center.px-5.py-4 (if page-loading? [loading-icon service-color "text-5xl"] - [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5 xl:w-3/5"} + [:div.flex.flex-col.flex-auto.w-full {:class ["lg:w-4/5" "xl:w-3/5"]} (map-indexed #(with-meta %2 {:key %1}) children)])])) (defn content-header [heading & children] [:div.flex.items-center.justify-between.mt-6 - [:h1.text-3xl.line-clamp-1.mr-6.font-semibold - {:title heading} - heading] + [:h1.text-3xl.line-clamp-1.mr-6.font-semibold {:title heading} heading] (map-indexed #(with-meta %2 {:key %1}) children)]) (defn uploader-avatar - [source name & url] - (let [image [:img.flex-auto.rounded-full.object-cover.max-w-full.min-h-full {:src source :alt name}]] - (when source - [:div.relative.w-12.xs:w-16.h-12.xs:h-16.flex-auto.flex.items-center.shrink-0 - (if url - [:a.flex-auto.flex.min-h-full.min-w-full.max-h-full.max-w-full {:href url :title name} image] - image)]))) + [{:keys [uploader-avatar uploader-name uploader-url]}] + (when uploader-avatar + [:div.relative.w-12.xs:w-16.h-12.xs:h-16.flex-auto.flex.items-center.shrink-0 + (conj + (when uploader-url + [:a.flex-auto.flex.min-h-full.min-w-full.max-h-full.max-w-full + {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name + :key uploader-url}]) + [:img.flex-auto.rounded-full.object-cover.max-w-full.min-h-full + {:src uploader-avatar :alt uploader-name :key uploader-name}])])) + +(defn button + [label on-click left-icon right-icon & {:keys [button-classes label-classes icon-classes]}] + [:button.px-4.rounded-3xl.py-1.outline-none.focus:ring-transparent.whitespace-nowrap + {:on-click on-click :class button-classes} + (when left-icon + (conj left-icon {:class (or icon-classes label-classes)})) + [:span.mx-2.font-bold.text-sm {:class label-classes} label] + (when right-icon + (conj right-icon {:class (or icon-classes label-classes)}))]) (defn primary-button [label on-click left-icon right-icon] - [:button.dark:bg-white.bg-stone-800.px-4.rounded-3xl.py-1.outline-none.focus:ring-transparent.whitespace-nowrap - {:on-click on-click} - (when left-icon - [:i.text-neutral-300.dark:text-neutral-800.text-sm - {:class left-icon}]) - [:span.mx-2.text-neutral-300.dark:text-neutral-900.font-bold.text-sm label] - (when right-icon - [:i.text-neutral-300.dark:text-neutral-800.text-sm - {:class right-icon}])]) + [button label on-click left-icon right-icon + :button-classes ["bg-stone-800" "dark:bg-white"] + :label-classes ["text-neutral-300" "dark:text-neutral-900"]]) (defn secondary-button [label on-click left-icon right-icon] - [:button.dark:bg-transparent.bg-neutral-100.px-4.rounded-3xl.py-1.border.border-neutral-300.dark:border-stone-700.outline-none.focus:ring-transparent.whitespace-nowrap - {:on-click on-click} - (when left-icon - [:i.text-neutral-500.dark:text-white.text-sm - {:class left-icon}]) - [:span.mx-2.text-neutral-500.dark:text-white.font-bold.text-sm label] - (when right-icon - [:i.text-neutral-500.dark:text-white.text-sm - {:class right-icon}])]) + [button label on-click left-icon right-icon + :button-classes ["bg-neutral-100" "dark:bg-transparent" "border" "border-neutral-300" "dark:border-stone-700"] + :label-classes ["text-neutral-500" "dark:text-white"]]) (defn generic-input [label & children] [:div.w-full.flex.justify-between.items-center.py-2.gap-x-4 @@ -129,7 +131,7 @@ (let [content [:<> [:span.text-xs.min-w-4.w-4.flex.justify-center.items-center icon] [:span.whitespace-nowrap label]] - classes ["relative ""flex" "items-center" "gap-x-3" "hover:bg-neutral-200" + classes ["relative" "flex" "items-center" "gap-x-3" "hover:bg-neutral-200" "dark:hover:bg-stone-800" "py-2" "px-3" "rounded"]] (if link [:a {:href (:route link) :target (when (:external? link) "_blank") @@ -154,7 +156,7 @@ [focus-overlay #(reset! !menu-active? false) @!menu-active? true] [:button.focus:outline-none.relative.pl-4 {:on-click #(reset! !menu-active? (not @!menu-active?)) - :class extra-classes} + :class extra-classes} [:i.fa-solid.fa-ellipsis-vertical] [menu @!menu-active? items menu-styles]]]) @@ -167,7 +169,7 @@ [:i.w-6 {:class left-icon}]) [:h2.mx-4.text-lg.w-24 label] [:i.fa-solid.fa-chevron-up.cursor-pointer.text-sm - {:class (if open? "fa-chevron-up" "fa-chevron-down") + {:class (if open? :fa-chevron-up :fa-chevron-down) :on-click on-open}]] right-button] (when open? @@ -175,12 +177,11 @@ (defn show-more-container [open? text on-open] - (let [!text-container (atom nil) + (let [!text-container (atom nil) !resize-observer (atom nil) - text-clamped? (r/atom nil)] + text-clamped? (r/atom nil)] (r/create-class - {:display-name "ShowMoreContainer" - :component-did-mount + {:component-did-mount (fn [_] (when @!text-container (.observe diff --git a/src/frontend/tubo/components/modal.cljs b/src/frontend/tubo/components/modal.cljs deleted file mode 100644 index 0624856..0000000 --- a/src/frontend/tubo/components/modal.cljs +++ /dev/null @@ -1,33 +0,0 @@ -(ns tubo.components.modal - (:require - [re-frame.core :as rf] - [tubo.components.layout :as layout])) - -(defn modal-content - [title body & extra-buttons] - [:div.bg-white.max-h-full.dark:bg-neutral-900.z-20.p-5.rounded.flex.gap-y-5.flex-col.border.border-neutral-300.dark:border-stone-700.flex-auto.shrink-0 - [:div.flex.justify-between.shrink-0 - [:h1.text-xl.font-semibold title] - [:button {:on-click #(rf/dispatch [:tubo.events/close-modal])} - [:i.fa-solid.fa-close]]] - [:div.flex-auto.overflow-y-auto body] - [:div.flex.justify-end.gap-x-3.shrink-0 - (if extra-buttons - (map-indexed #(with-meta %2 {:key %1}) extra-buttons) - [layout/primary-button "Ok" #(rf/dispatch [:tubo.events/close-modal])])]]) - -(defn modal-panel - [{:keys [child show?]}] - [:div.fixed.flex.flex-col.items-center.justify-center.w-full.z-20.top-0 - {:style {:minHeight "100dvh" :height "100dvh"}} - [layout/focus-overlay #(rf/dispatch [:tubo.events/close-modal]) show?] - [:div.flex.items-center.justify-center.max-h-full.flex-auto.shrink-0.p-5 - {:class "w-full sm:w-3/4 md:w-3/5 lg:w-1/2 xl:w-1/3"} - child]]) - -(defn modal - [] - (fn [] - (let [modal (rf/subscribe [:modal])] - (when (:show? @modal) - [modal-panel @modal])))) diff --git a/src/frontend/tubo/components/modals/bookmarks.cljs b/src/frontend/tubo/components/modals/bookmarks.cljs deleted file mode 100644 index befcfe8..0000000 --- a/src/frontend/tubo/components/modals/bookmarks.cljs +++ /dev/null @@ -1,46 +0,0 @@ -(ns tubo.components.modals.bookmarks - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.modal :as modal] - [tubo.components.layout :as layout])) - -(defn bookmark-list-item - [{:keys [items id name] :as bookmark} item] - [:div.flex.w-full.h-24.rounded.cursor-pointer.hover:bg-gray-100.dark:hover:bg-stone-800.px-2 - {:on-click #(rf/dispatch (if (vector? item) - [:tubo.events/add-related-streams-to-bookmark-list bookmark item] - [:tubo.events/add-to-bookmark-list bookmark item true]))} - [:div.w-24 - [layout/thumbnail (-> items first :thumbnail-url) nil name nil - :classes "h-24"]] - [:div.flex.flex-col.py-4.px-4 - [:h1.line-clamp-1.font-bold {:title name} name] - [:span.text-sm (str (count items) " streams")]]]) - -(defn add-bookmark-modal - [item] - (let [!bookmark-name (r/atom "")] - (fn [] - [modal/modal-content "Create New Playlist?" - [layout/text-input "Title" :text-input @!bookmark-name - #(reset! !bookmark-name (.. % -target -value)) "Playlist name"] - [layout/secondary-button "Back" - #(rf/dispatch [:tubo.events/back-to-bookmark-list-modal item])] - [layout/primary-button "Create Playlist" - #(rf/dispatch [:tubo.events/add-bookmark-list-and-back {:name @!bookmark-name} item]) - "fa-solid fa-plus"]]))) - -(defn add-to-bookmark-list-modal - [item] - (let [bookmarks @(rf/subscribe [:bookmarks])] - [modal/modal-content "Add to Playlist" - [:div.flex-auto - [:div.flex.justify-center.items-center.pb-4 - [layout/primary-button "Create New Playlist" - #(rf/dispatch [:tubo.events/open-modal [add-bookmark-modal item]]) - "fa-solid fa-plus"]] - [:div.flex.flex-col.gap-y-2.pr-2 - (for [[i bookmark] (map-indexed vector bookmarks)] - ^{:key i} [bookmark-list-item bookmark item])]]])) diff --git a/src/frontend/tubo/components/navigation.cljs b/src/frontend/tubo/components/navigation.cljs index f30f183..548e344 100644 --- a/src/frontend/tubo/components/navigation.cljs +++ b/src/frontend/tubo/components/navigation.cljs @@ -1,180 +1,88 @@ (ns tubo.components.navigation (:require + [re-frame.core :as rf] [reagent.core :as r] [reitit.frontend.easy :as rfe] - [re-frame.core :as rf] [tubo.components.layout :as layout] - [tubo.events :as events] - [tubo.routes :as routes])) - -(defn search-form [] - (let [!query (r/atom "") - !input (r/atom nil)] - (fn [] - (let [search-query @(rf/subscribe [:search-query]) - show-search-form? @(rf/subscribe [:show-search-form]) - service-id @(rf/subscribe [:service-id])] - [:form.relative.flex.items-center.text-white.ml-4 - {:class (when-not show-search-form? "hidden") - :on-submit - (fn [e] - (.preventDefault e) - (when-not (empty? @!query) - (rf/dispatch [::events/navigate - {:name ::routes/search - :params {} - :query {:q search-query :serviceId service-id}}])))} - [:div.flex - [:button.mx-2 - {:on-click #(rf/dispatch [::events/show-search-form false]) - :type "button"} - [:i.fa-solid.fa-arrow-left]] - [:input.bg-transparent.border-none.py-2.pr-6.mx-2.focus:ring-transparent.placeholder-white.sm:w-96.w-full - {:type "text" - :style {:paddingLeft 0} - :ref #(do (reset! !input %) - (when % - (.focus %))) - :default-value @!query - :on-change #(let [input (.. % -target -value)] - (when-not (empty? input) (rf/dispatch [::events/change-search-query input])) - (reset! !query input)) - :placeholder "Search"}] - [:button.mx-4 - {:type "submit"} - [:i.fa-solid.fa-search]] - [:button.mx-4.text-xs.absolute.right-8.top-3 - {:type "button" - :on-click #(when @!input - (set! (.-value @!input) "") - (reset! !query "") - (.focus @!input)) - :class (when (empty? @!query) "invisible")} - [:i.fa-solid.fa-circle-xmark]]]])))) - -(defn services-dropdown [services service-id service-color] - [:div.relative.flex.flex-col.items-center-justify-center.text-white.px-2 - {:style {:background service-color}} - [:div.w-full.box-border.z-10.lg:z-0 - [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.font-bold.w-full - {:on-change #(rf/dispatch [::events/change-service-kiosk (js/parseInt (.. % -target -value))]) - :value service-id - :style {:background "transparent"}} - (when services - (for [service services] - [:option.text-white.bg-neutral-900.border-none - {:value (:id service) :key (:id service)} - (-> service :info :name)]))]] - [:div.flex.items-center.justify-end.absolute.min-h-full.top-0.right-4.lg:right-0.z-0 - [:i.fa-solid.fa-caret-down]]]) - -(defn kiosk-active? - [{:keys [kiosk kiosk-id service-id default-service default-kiosk path]}] - (or (and (= kiosk-id kiosk)) - (and (= path "/kiosk") - (not kiosk-id) - (not= (js/parseInt service-id) - (:service-id default-service)) - (= default-kiosk kiosk)) - (and (or (= path "/") (= path "/kiosk")) - (not kiosk-id) - (= (:default-kiosk default-service) kiosk)))) + [tubo.kiosks.views :as kiosks] + [tubo.search.views :as search] + [tubo.services.views :as services])) -(defn kiosks-menu - [{:keys [kiosks service-id] :as kiosk-args}] - [:ul.flex.items-center.px-4.text-white - (for [kiosk kiosks] - [:li.px-3 {:key kiosk} - [:a {:href (rfe/href ::routes/kiosk nil {:serviceId service-id - :kioskId kiosk}) - :class (when (kiosk-active? (assoc kiosk-args :kiosk kiosk)) - "font-bold")} - kiosk]])]) - -(defn mobile-nav-item [route icon label & {:keys [new-tab? active?]}] +(defn mobile-nav-item + [route icon label & {:keys [new-tab? active?]}] [:li.px-5.py-2 [:a.flex {:href route :target (when new-tab? "_blank")} [:div.w-6.flex.justify-center.items-center.mr-4 - [:i.text-neutral-600.dark:text-neutral-300 {:class icon}]] + (conj icon {:class ["text-neutral-600" "dark:text-neutral-300"]})] [:span {:class (when active? "font-bold")} label]]]) (defn mobile-nav - [show-mobile-nav? service-color services available-kiosks {:keys [service-id] :as kiosk-args}] + [show-mobile-nav? service-color services available-kiosks & {:keys [service-id] :as kiosk-args}] [:<> - [layout/focus-overlay #(rf/dispatch [::events/toggle-mobile-nav]) show-mobile-nav?] + [layout/focus-overlay #(rf/dispatch [:toggle-mobile-nav]) show-mobile-nav?] [:div.fixed.overflow-x-hidden.min-h-screen.w-60.top-0.ease-in-out.delay-75.bg-white.dark:bg-neutral-900.z-20 - {:class (str "transition-[right] " (if show-mobile-nav? "right-0" "right-[-245px]"))} + {:class ["transition-[right]" (if show-mobile-nav? "right-0" "right-[-245px]")]} [:div.flex.justify-center.py-4.items-center.text-white {:style {:background service-color}} [layout/logo :height 75 :width 75] [:h3.text-3xl.font-bold "Tubo"]] - [services-dropdown services service-id service-color] + [services/services-dropdown services service-id service-color] [:div.relative.py-4 [:ul.flex.flex-col - (for [kiosk available-kiosks] - ^{:key kiosk} - [mobile-nav-item - (rfe/href ::routes/kiosk nil - {:serviceId service-id - :kioskId kiosk}) - "fa-solid fa-fire" kiosk - :active? (kiosk-active? (assoc kiosk-args :kiosk kiosk))])]] + (for [[i kiosk] (map-indexed vector available-kiosks)] + ^{:key i} [mobile-nav-item + (rfe/href :kiosk-page nil {:serviceId service-id :kioskId kiosk}) + [:i.fa-solid.fa-fire] kiosk + :active? (kiosks/kiosk-active? (assoc kiosk-args :kiosk kiosk))])]] [:div.relative.dark:border-neutral-800.border-gray-300.pt-4 {:class "border-t-[1px]"} [:ul.flex.flex-col - [mobile-nav-item (rfe/href ::routes/bookmarks) "fa-solid fa-bookmark" "Bookmarks"] - [mobile-nav-item (rfe/href ::routes/settings) "fa-solid fa-cog" "Settings"] - [mobile-nav-item "https://github.com/migalmoreno/tubo" - "fa-brands fa-github" "Source" :new-tab? true]]]]]) + [mobile-nav-item (rfe/href :bookmarks-page) [:i.fa-solid.fa-bookmark] "Bookmarks"] + [mobile-nav-item (rfe/href :settings-page) [:i.fa-solid.fa-cog] "Settings"]]]]]) (defn navbar - [{{:keys [serviceId kioskId]} :query-params path :path}] + [{{:keys [kioskId]} :query-params path :path}] (let [service-id @(rf/subscribe [:service-id]) service-color @(rf/subscribe [:service-color]) services @(rf/subscribe [:services]) - {:keys [theme default-service]} @(rf/subscribe [:settings]) - id (js/parseInt (or serviceId service-id)) show-mobile-nav? @(rf/subscribe [:show-mobile-nav]) show-search-form? @(rf/subscribe [:show-search-form]) + {:keys [default-service]} @(rf/subscribe [:settings]) {:keys [available-kiosks default-kiosk]} @(rf/subscribe [:kiosks])] [:nav.sticky.flex.items-center.px-2.h-14.top-0.z-20 {:style {:background service-color}} [:div.flex.flex-auto.items-center [:div.ml-2 - [:a.font-bold - {:href (rfe/href ::routes/home)} + [:a.font-bold {:href (rfe/href :homepage)} [layout/logo :height 35 :width 35]]] - [search-form] + [search/search-form] [:div.flex.flex-auto.justify-end.lg:justify-between - {:class (when show-search-form? "hidden")} + {:class (when show-search-form? :hidden)} [:div.hidden.lg:flex - [services-dropdown services service-id service-color] - [kiosks-menu - {:kiosks available-kiosks - :service-id service-id - :kiosk-id kioskId - :default-service default-service - :default-kiosk default-kiosk - :path path}]] + [services/services-dropdown services service-id service-color] + [kiosks/kiosks-menu + :kiosks available-kiosks + :service-id service-id + :kiosk-id kioskId + :default-service default-service + :default-kiosk default-kiosk + :path path]] [:div.flex.items-center.text-white.justify-end (when-not show-search-form? [:button.mx-3 - {:on-click #(rf/dispatch [::events/show-search-form true])} + {:on-click #(rf/dispatch [:search/show-form true])} [:i.fa-solid.fa-search]]) [:a.mx-3.hidden.lg:block - {:href (rfe/href ::routes/settings)} + {:href (rfe/href :settings-page)} [:i.fa-solid.fa-cog]] [:a.mx-3.hidden.lg:block - {:href (rfe/href ::routes/bookmarks)} + {:href (rfe/href :bookmarks-page)} [:i.fa-solid.fa-bookmark]] - [:a.mx-3.hidden.lg:block - {:href "https://github.com/migalmoreno/tubo" :target "_blank"} - [:i.fa-brands.fa-github]] [:button.mx-3.lg:hidden - {:on-click #(rf/dispatch [::events/toggle-mobile-nav])} + {:on-click #(rf/dispatch [:toggle-mobile-nav])} [:i.fa-solid.fa-bars]]] [mobile-nav show-mobile-nav? service-color services available-kiosks - {:kiosk-id kioskId - :service-id service-id - :default-service default-service - :default-kiosk default-kiosk - :path path}]]]])) + :kiosk-id kioskId + :service-id service-id + :default-service default-service + :default-kiosk default-kiosk + :path path]]]])) diff --git a/src/frontend/tubo/components/notification.cljs b/src/frontend/tubo/components/notification.cljs deleted file mode 100644 index 2f03cd4..0000000 --- a/src/frontend/tubo/components/notification.cljs +++ /dev/null @@ -1,28 +0,0 @@ -(ns tubo.components.notification - (:require - [re-frame.core :as rf] - [tubo.events :as events])) - -(defn notification-content - [{:keys [failure parse-error status status-text] :as notification}] - (when notification - [:div.py-4.pl-4.pr-8.rounded.backdrop-blur.flex.flex-col.justify-center.shadow.shadow-neutral-700 - {:class (clojure.string/join - "" (case failure - :success ["bg-green-600/90 text-white"] - :error ["bg-red-600/90 text-white"] - ["bg-neutral-300"]))} - [:button.text-lg.absolute.top-1.right-2 - {:on-click #(rf/dispatch [::events/remove-notification (:id notification)])} - [:i.fa-solid.fa-close]] - [:span.font-bold (str (when status (str status ": ")) status-text)] - (when parse-error - [:span.line-clamp-1 (:status-text parse-error)])])) - -(defn notifications-panel - [] - (fn [] - (let [notifications @(rf/subscribe [:notifications])] - [:div.fixed.flex.flex-col.items-end.gap-2.top-16.z-20.w-full.py-1.px-2 - (for [[i notification] (map-indexed vector notifications)] - ^{:key i} [notification-content notification])]))) diff --git a/src/frontend/tubo/components/play_queue.cljs b/src/frontend/tubo/components/play_queue.cljs deleted file mode 100644 index 9474a2c..0000000 --- a/src/frontend/tubo/components/play_queue.cljs +++ /dev/null @@ -1,137 +0,0 @@ -(ns tubo.components.play-queue - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.components.player :as player] - [tubo.events :as events] - [tubo.utils :as utils])) - -(defn play-queue-item - [item media-queue-pos i bookmarks] - (let [!menu-active? (r/atom false)] - (fn [{:keys [service-id uploader-name uploader-url name duration - stream url thumbnail-url] :as item} - media-queue-pos i bookmarks] - (let [liked? (some #(= (:url %) url) (-> bookmarks first :items)) - media-queue-pos @(rf/subscribe [:media-queue-pos]) - media-queue @(rf/subscribe [:media-queue])] - [:div.relative.w-full - {:ref #(when (= media-queue-pos i) - (rf/dispatch [::events/scroll-into-view %]))} - [:div.flex.cursor-pointer.py-2 - {:class (when (= i media-queue-pos) "bg-neutral-200 dark:bg-stone-800") - :on-click #(rf/dispatch [::events/change-media-queue-pos i])} - [:div.flex.items-center.justify-center.min-w-20.w-20.xs:min-w-28.xs:w-28 - [:span.font-bold.text-neutral-400.text-sm - (if (= i media-queue-pos) [:i.fa-solid.fa-play] (inc i))]] - [:div.w-36 - [layout/thumbnail thumbnail-url nil name duration :classes "h-16 !p-0" :rounded? false]] - [:div.flex.flex-col.pl-4.pr-12.w-full - [:h1.line-clamp-1.w-fit {:title name} name] - [:div.text-neutral-600.dark:text-neutral-300.text-sm.flex.flex-col.xs:flex-row - [:span.line-clamp-1 {:title uploader-name} uploader-name] - [:span.px-2.hidden.xs:inline-block {:dangerouslySetInnerHTML {:__html "•"}}] - [:span (utils/get-service-name service-id)]]]] - [:div.absolute.right-0.top-0.min-h-full.flex.items-center - [layout/popover-menu !menu-active? - [{:label (if liked? "Remove favorite" "Favorite") - :icon [:i.fa-solid.fa-heart (when liked? {:style {:color (utils/get-service-color service-id)}})] - :on-click #(rf/dispatch [(if liked? ::events/remove-from-likes ::events/add-to-likes) item])} - {:label "Play radio" - :icon [:i.fa-solid.fa-tower-cell] - :on-click #(rf/dispatch [::events/start-stream-radio item])} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal item]])} - {:label "Remove from queue" - :icon [:i.fa-solid.fa-trash] - :on-click #(rf/dispatch [::events/remove-from-media-queue i])}] - :menu-styles {:right "40px"} - :extra-classes "px-7 py-2"]]])))) - -(defn queue - [] - (let [show-media-queue @(rf/subscribe [:show-media-queue]) - loading? @(rf/subscribe [:show-audio-player-loading]) - paused? @(rf/subscribe [:paused]) - media-queue @(rf/subscribe [:media-queue]) - media-queue-pos @(rf/subscribe [:media-queue-pos]) - {:keys [uploader-name uploader-url name stream url service-id] - :as current-stream} @(rf/subscribe [:media-queue-stream]) - service-color (and service-id (utils/get-service-color service-id)) - !elapsed-time @(rf/subscribe [:elapsed-time]) - !player @(rf/subscribe [:player]) - loop-playback @(rf/subscribe [:loop-playback]) - player-ready? @(rf/subscribe [:player-ready]) - bookmarks @(rf/subscribe [:bookmarks])] - [:div.fixed.flex.flex-col.items-center.min-w-full.w-full.z-10.backdrop-blur - {:style {:minHeight "calc(100dvh - 56px)" - :height "calc(100dvh - 56px)" - :visibility (when-not show-media-queue "hidden") - :opacity (if show-media-queue 1 0)} - :class "dark:bg-neutral-900/90 bg-neutral-100/90 backdrop-blur"} - [layout/focus-overlay #(rf/dispatch [::events/show-media-queue false]) show-media-queue true] - [:div.z-20.w-full.flex.flex-col.flex-auto.h-full.lg:pt-5 - {:class "lg:w-4/5 xl:w-3/5"} - [:div.flex.flex-col.overflow-y-auto.flex-auto.gap-y-1 - (for [[i item] (map-indexed vector media-queue)] - ^{:key i} [play-queue-item item media-queue-pos i bookmarks])] - [:div.flex.flex-col.py-4.shrink-0.px-5 - [:div.flex.flex-col.py-2 - [:a.text-md.line-clamp-1.w-fit - {:href (rfe/href :tubo.routes/stream nil {:url url}) - :title name} - name] - [:a.text-sm.pt-2.text-neutral-600.dark:text-neutral-300.line-clamp-1.w-fit - {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) - :title uploader-name} - uploader-name]] - [:div.flex.flex-auto.py-2.w-full.items-center.text-sm - [:span.mr-4 (if (and @!elapsed-time @!player) (utils/format-duration @!elapsed-time) "00:00")] - [player/time-slider !player !elapsed-time service-color] - [:span.ml-4 (if (and player-ready? @!player) (utils/format-duration (.-duration @!player)) "00:00")]] - [:div.flex.justify-center.items-center - [player/loop-button loop-playback service-color true] - [player/button - [:i.fa-solid.fa-backward-step] - #(when (and media-queue (not= media-queue-pos 0)) - (rf/dispatch [::events/change-media-queue-pos - (- media-queue-pos 1)])) - :disabled? (not (and media-queue (not= media-queue-pos 0))) - :extra-classes "text-xl" - :show-on-mobile? true] - [player/button - [:i.fa-solid.fa-backward] - #(set! (.-currentTime @!player) (- @!elapsed-time 5)) - :extra-classes "text-xl" - :show-on-mobile? true] - [player/button - (if (or (not loading?) player-ready?) - (if paused? - [:i.fa-solid.fa-play] - [:i.fa-solid.fa-pause]) - [layout/loading-icon service-color "text-3xl"]) - #(rf/dispatch [::events/set-player-paused (not paused?)]) - :extra-classes "text-3xl" - :show-on-mobile? true] - [player/button - [:i.fa-solid.fa-forward] - #(set! (.-currentTime @!player) (+ @!elapsed-time 5)) - :extra-classes "text-xl" - :show-on-mobile? true] - [player/button - [:i.fa-solid.fa-forward-step] - #(when (and media-queue (< (+ media-queue-pos 1) (count media-queue))) - (rf/dispatch [::events/change-media-queue-pos - (+ media-queue-pos 1)])) - :disabled? (not (and media-queue (< (+ media-queue-pos 1) (count media-queue)))) - :extra-classes "text-xl" - :show-on-mobile? true] - [player/button [:i.fa-solid.fa-list] #(rf/dispatch [::events/show-media-queue false]) - :show-on-mobile? true - :extra-classes "pl-4 pr-3"]]]]])) diff --git a/src/frontend/tubo/components/player.cljs b/src/frontend/tubo/components/player.cljs index a093826..3d72e48 100644 --- a/src/frontend/tubo/components/player.cljs +++ b/src/frontend/tubo/components/player.cljs @@ -1,8 +1,7 @@ (ns tubo.components.player (:require [reagent.core :as r] - [re-frame.core :as rf] - [tubo.events :as events])) + [re-frame.core :as rf])) (defonce base-slider-classes ["h-2" "cursor-pointer" "appearance-none" "bg-neutral-300" "dark:bg-neutral-600" @@ -39,63 +38,64 @@ "#629aa9" ["[&::-webkit-slider-thumb]:bg-[#629aa9]" "[&::-moz-range-thumb]:bg-[#629aa9]"] ["[&::-webkit-slider-thumb]:bg-neutral-300" "[&::-moz-range-thumb]:bg-neutral-300"])) -(defn time-slider [player elapsed-time service-color] - (let [styles `(~@base-slider-classes - ~@(get-slider-bg-classes service-color) - ~@(get-slider-shadow-classes service-color))] +(defn time-slider [!player !elapsed-time service-color] + (let [styles (concat base-slider-classes + (get-slider-bg-classes service-color) + (get-slider-shadow-classes service-color))] [:input.w-full - {:class (clojure.string/join " " styles) + {:class styles :type "range" - :on-input #(reset! elapsed-time (.. % -target -value)) - :on-change #(and @player (> (.-readyState @player) 0) - (set! (.-currentTime @player) @elapsed-time)) - :max (if (and @player (> (.-readyState @player) 0)) - (.floor js/Math (.-duration @player)) + :on-input #(reset! !elapsed-time (.. % -target -value)) + :on-change #(and @!player (> (.-readyState @!player) 0) + (set! (.-currentTime @!player) @!elapsed-time)) + :max (if (and @!player (> (.-readyState @!player) 0)) + (.floor js/Math (.-duration @!player)) 100) - :value @elapsed-time}])) + :value @!elapsed-time}])) (defn button - [icon on-click & {:keys [disabled? show-on-mobile? extra-classes]}] + [& {:keys [icon on-click disabled? show-on-mobile? extra-classes]}] [:button.outline-none.focus:ring-transparent.px-2.pt-1 - {:class (let [classes (apply conj (when disabled? ["opacity-50" "cursor-auto"]) - (when-not show-on-mobile? ["hidden" "lg:block"]))] - (apply str (if (> (count extra-classes) 1) (interpose " " (conj classes extra-classes)) (interpose " " classes)))) + {:class (into (into (when disabled? [:opacity-50 :cursor-auto]) + (when-not show-on-mobile? [:hidden :lg:block])) + extra-classes) :on-click on-click} icon]) (defn loop-button - [loop-playback service-color show-on-mobile?] + [loop-playback color show-on-mobile?] [button - [:div.relative.flex.items-center - [:i.fa-solid.fa-repeat - {:style {:color (when loop-playback service-color)}}] - (when (= loop-playback :stream) - [:div.absolute.w-full.h-full.flex.justify-center.items-center.font-bold - {:class "text-[6px]" - :style {:color (when loop-playback service-color)}} - "1"])] - #(rf/dispatch [::events/toggle-loop-playback]) - :extra-classes "text-sm" + :icon [:div.relative.flex.items-center + [:i.fa-solid.fa-repeat + {:style {:color (when loop-playback color)}}] + (when (= loop-playback :stream) + [:div.absolute.w-full.h-full.flex.justify-center.items-center.font-bold + {:class "text-[6px]" + :style {:color (when loop-playback color)}} + "1"])] + :on-click #(rf/dispatch [:player/loop]) + :extra-classes [:text-sm] :show-on-mobile? show-on-mobile?]) -(defn volume-slider [player volume-level muted? service-color] +(defn volume-slider + [player volume-level muted? service-color] (let [show-slider? (r/atom nil)] (fn [player volume-level muted? service-color] - (let [styles `("rotate-[270deg]" - ~@base-slider-classes - ~@(get-slider-bg-classes service-color) - ~@(get-slider-shadow-classes service-color))] + (let [styles (concat ["rotate-[270deg]"] + base-slider-classes + (get-slider-bg-classes service-color) + (get-slider-shadow-classes service-color))] [:div.relative.flex.flex-col.justify-center.items-center {:on-mouse-over #(reset! show-slider? true) :on-mouse-out #(reset! show-slider? false)} [button - (if muted? [:i.fa-solid.fa-volume-xmark] [:i.fa-solid.fa-volume-low]) - #(rf/dispatch [::events/toggle-mute player]) - :extra-classes "pl-3 pr-2"] + :icon (if muted? [:i.fa-solid.fa-volume-xmark] [:i.fa-solid.fa-volume-low]) + :on-click #(rf/dispatch [:player/mute (not muted?) player]) + :extra-classes [:pl-3 :pr-2]] (when @show-slider? [:input.absolute.w-24.ml-2.m-1.bottom-16 {:class (clojure.string/join " " styles) :type "range" - :on-input #(rf/dispatch [::events/change-volume-level (.. % -target -value) player]) + :on-input #(rf/dispatch [:player/change-volume (.. % -target -value) player]) :max 100 :value volume-level}])])))) diff --git a/src/frontend/tubo/components/video_player.cljs b/src/frontend/tubo/components/video_player.cljs deleted file mode 100644 index bb974b3..0000000 --- a/src/frontend/tubo/components/video_player.cljs +++ /dev/null @@ -1,39 +0,0 @@ -(ns tubo.components.video-player - (:require - [re-frame.core :as rf] - [reagent.core :as r] - [reagent.dom :as rdom] - ["video.js" :as videojs] - ["videojs-mobile-ui"] - ["@silvermine/videojs-quality-selector" :as VideojsQualitySelector])) - -(defn player - [options] - (let [!player (atom nil) - service-color @(rf/subscribe [:service-color]) - {:keys [theme]} @(rf/subscribe [:settings])] - (r/create-class - {:display-name "VideoPlayer" - :component-did-mount - (fn [^videojs/VideoJsPlayer this] - (let [set-bg-color! #(set! (.. (.$ (.getChild ^videojs/VideoJsPlayer @!player "ControlBar") %) - -style - -background) - service-color)] - (VideojsQualitySelector videojs) - (reset! !player (videojs (rdom/dom-node this) (clj->js options))) - (set-bg-color! ".vjs-play-progress") - (set-bg-color! ".vjs-volume-level") - (set-bg-color! ".vjs-slider-bar") - (.ready @!player #(.mobileUi ^videojs/VideoJsPlayer @!player)) - (.on @!player "play" (fn [] - (.audioPosterMode - @!player - (clojure.string/includes? - (:label (first (filter #(= (:src %) (.src @!player)) - (:sources options)))) - "audio-only")))))) - :component-will-unmount #(when @!player (.dispose @!player)) - :reagent-render - (fn [options] - [:video-js.vjs-tubo.vjs-default-skin])}))) diff --git a/src/frontend/tubo/core.cljs b/src/frontend/tubo/core.cljs index cbc1ad5..2153e50 100644 --- a/src/frontend/tubo/core.cljs +++ b/src/frontend/tubo/core.cljs @@ -3,7 +3,7 @@ ["react-dom/client" :as rdom] [reagent.core :as r] [re-frame.core :as rf] - [tubo.events :as events] + [tubo.events] [tubo.routes :as routes] [tubo.subs] [tubo.views :as views])) @@ -18,5 +18,5 @@ (defn ^:export init [] - (rf/dispatch-sync [::events/initialize-db]) + (rf/dispatch-sync [:initialize-db]) (mount-root)) diff --git a/src/frontend/tubo/events.cljs b/src/frontend/tubo/events.cljs index ed73d4d..decdb23 100644 --- a/src/frontend/tubo/events.cljs +++ b/src/frontend/tubo/events.cljs @@ -2,268 +2,129 @@ (:require [akiroz.re-frame.storage :refer [reg-co-fx!]] [day8.re-frame.http-fx] - [goog.object :as gobj] [nano-id.core :refer [nano-id]] - [promesa.core :as p] [reagent.core :as r] [re-frame.core :as rf] [re-promise.core] [reitit.frontend.easy :as rfe] [reitit.frontend.controllers :as rfc] - [tubo.api :as api] - [tubo.components.modals.bookmarks :as bookmarks] - [vimsical.re-frame.cofx.inject :as inject])) + [tubo.bookmarks.events] + [tubo.channel.events] + [tubo.comments.events] + [tubo.kiosks.events] + [tubo.modals.events] + [tubo.notifications.events] + [tubo.player.events] + [tubo.playlist.events] + [tubo.queue.events] + [tubo.search.events] + [tubo.services.events] + [tubo.settings.events] + [tubo.stream.events])) (reg-co-fx! :tubo {:fx :store :cofx :store}) (rf/reg-event-fx - ::initialize-db + :initialize-db [(rf/inject-cofx :store)] (fn [{:keys [store]} _] - (let [{:keys [theme show-comments show-related show-description - media-queue media-queue-pos show-audio-player - loop-playback volume-level muted bookmarks - default-service default-service-kiosk service-id]} store] + (let [if-nil #(if (nil? %1) %2 %1)] {:db - {:search-query "" - :service-id (if (nil? service-id) 0 service-id) - :stream {} - :search-results [] - :services [] - :paused true - :loop-playback (if (nil? loop-playback) :playlist loop-playback) - :media-queue (if (nil? media-queue) [] media-queue) - :media-queue-pos (if (nil? media-queue-pos) 0 media-queue-pos) - :volume-level (if (nil? volume-level) 100 volume-level) - :bookmarks (if (nil? bookmarks) - [{:id (nano-id) - :name "Liked Streams" - :items []}] - bookmarks) - :muted (if (nil? muted) false muted) - :current-match nil - :show-audio-player (if (nil? show-audio-player) false show-audio-player) + {:paused true + :muted (:muted store) + :queue (:queue store) + :service-id (if-nil (:service-id store) 0) + :loop-playback (if-nil (:loop-playback store) :playlist) + :queue-pos (if-nil (:queue-pos store) 0) + :volume-level (if-nil (:volume-level store) 100) + :show-background-player (:show-background-player store) + :bookmarks + (if-nil (:bookmarks store) [{:id (nano-id) :name "Liked Streams"}]) :settings - {:theme (if (nil? theme) :auto theme) - :show-comments (if (nil? show-comments) true show-comments) - :show-related (if (nil? show-related) true show-related) - :show-description (if (nil? show-description) true show-description) - :default-service (if (nil? default-service) + {:theme (if-nil (:theme store) "auto") + :show-comments (if-nil (:show-comments store) true) + :show-related (if-nil (:show-related store) true) + :show-description (if-nil (:show-description store) true) + :default-service (if-nil (:default-service store) {:service-id 0 :id "YouTube" :default-kiosk "Trending" - :available-kiosks ["Trending"]} - default-service)}}}))) + :available-kiosks ["Trending"]})}}}))) (rf/reg-fx - ::scroll-to-top + :scroll-to-top (fn [_] (.scrollTo js/window #js {"top" 0 "behavior" "smooth"}))) (rf/reg-fx - ::history-go! - (fn [idx] - (.go js/window.history idx))) - -(rf/reg-fx - ::body-overflow! + :body-overflow (fn [active] (set! (.. js/document.body -style -overflow) (if active "hidden" "auto")))) (rf/reg-fx - ::scroll-into-view! - (fn [element] - (when element - (.scrollIntoView element (js-obj "behavior" "smooth"))))) - -(rf/reg-fx - ::document-title! + :document-title (fn [title] (set! (.-title js/document) (str title " - Tubo")))) (rf/reg-fx - ::player-volume - (fn [{:keys [player volume]}] - (when (and @player (> (.-readyState @player) 0)) - (set! (.-volume @player) (/ volume 100))))) - -(rf/reg-fx - ::player-mute - (fn [{:keys [player muted?]}] - (when (and @player (> (.-readyState @player) 0)) - (set! (.-muted @player) muted?)))) - -(rf/reg-fx - ::player-src - (fn [{:keys [player src current-pos]}] - (set! (.-src @player) src) - (set! (.-onended @player) #(rf/dispatch [::change-media-queue-pos (inc current-pos)])))) - -(rf/reg-fx - ::player-pause - (fn [{:keys [paused? player]}] - (when (and @player (> (.-readyState @player) 0)) - (if paused? - (.play @player) - (.pause @player))))) - -(rf/reg-fx - ::player-current-time - (fn [{:keys [time player]}] - (set! (.-currentTime @player) time))) - -(rf/reg-event-db - ::change-player-paused - (fn [db [_ val]] - (assoc db :paused val))) - -(rf/reg-event-fx - ::set-player-paused - [(rf/inject-cofx ::inject/sub [:player])] - (fn [{:keys [db player]} [_ paused?]] - {::player-pause {:paused? (not paused?) - :player player}})) - -(rf/reg-event-fx - ::player-start - [(rf/inject-cofx ::inject/sub [:player]) (rf/inject-cofx ::inject/sub [:elapsed-time])] - (fn [{:keys [db player]} _] - {:fx [[:dispatch [::change-player-paused true]] - [:dispatch [::set-player-paused false]] - [::player-volume {:player player :volume (:volume-level db)}]] - :db (assoc db :player-ready (and @player (> (.-readyState @player) 0)))})) + :scroll-into-view! + (fn [element] + (when element + (.scrollIntoView element (js-obj "behavior" "smooth"))))) (rf/reg-event-fx - ::set-player-time - [(rf/inject-cofx ::inject/sub [:player])] - (fn [{:keys [db player]} [_ time]] - {::player-current-time {:time time :player player}})) - -(rf/reg-fx - ::stream-metadata - (fn [metadata] - (when (gobj/containsKey js/navigator "mediaSession") - (set! (.-metadata js/navigator.mediaSession) (js/MediaMetadata. (clj->js metadata)))))) + :scroll-into-view + (fn [{:keys [db]} [_ element]] + {:scroll-into-view! element})) (rf/reg-fx - ::media-session - (fn [{:keys [current-pos player stream]}] - (when (gobj/containsKey js/navigator "mediaSession") - (let [updatePositionState - #(.setPositionState js/navigator.mediaSession - {:duration (.-duration @player) - :playbackRate (.-playbackRate @player) - :position (.-currentTime @player)})] - (.setActionHandler js/navigator.mediaSession "play" #(.play @player)) - (.setActionHandler js/navigator.mediaSession "pause" #(.pause @player)) - (.setActionHandler js/navigator.mediaSession "previoustrack" - #(rf/dispatch [::change-media-queue-pos (dec current-pos)])) - (.setActionHandler js/navigator.mediaSession "nexttrack" - #(rf/dispatch [::change-media-queue-pos (inc current-pos)])) - (.setActionHandler js/navigator.mediaSession "seekbackward" - (fn [^js/navigator.MediaSessionActionDetails details] - (set! (.-currentTime @player) - (- (.-currentTime @player) (or (.-seekOffset details) 10))) - (updatePositionState))) - (.setActionHandler js/navigator.mediaSession "seekforward" - (fn [^js/navigator.MediaSessionActionDetails details] - (set! (.-currentTime @player) - (+ (.-currentTime @player) (or (.-seekOffset details) 10))) - (updatePositionState))) - (.setActionHandler js/navigator.mediaSession "seekto" - (fn [^js/navigator.MediaSessionActionDetails details] - (set! (.-currentTime @player) (.-seekTime details)) - (updatePositionState))) - (.setActionHandler js/navigator.mediaSession "stop" - (fn [] - (.pause @player) - (set! (.-currentTime @player) 0))))))) + :history-go! + (fn [idx] + (.go js/window.history idx))) (rf/reg-event-fx - ::history-go + :history-go (fn [_ [_ idx]] - {::history-go! idx})) - -(rf/reg-event-db - ::show-search-form - (fn [db [_ show?]] - (when-not (= (-> db :current-match :path) "search") - (assoc db :show-search-form show?)))) - -(rf/reg-event-fx - ::toggle-mobile-nav - (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 - ::show-media-queue - (fn [{:keys [db]} [_ show?]] - {:db (assoc db :show-media-queue show?) - ::body-overflow! show?})) + {:history-go! idx})) -(rf/reg-event-fx - ::scroll-into-view - (fn [{:keys [db]} [_ element]] - {::scroll-into-view! element})) - -(rf/reg-event-fx - ::change-volume-level - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ value player]] - {:db (assoc db :volume-level value) - :store (assoc store :volume-level value) - ::player-volume {:player player :volume value}})) +(rf/reg-fx + :navigate! + (fn [{:keys [name params query]}] + (rfe/push-state name params query))) (rf/reg-event-fx - ::toggle-mute - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ player]] - {:db (assoc db :muted (not (:muted db))) - :store (assoc store :muted (not (:muted store))) - ::player-mute {:player player :muted? (not (:muted db))}})) + :navigate + (fn [_ [_ route]] + {:navigate! route})) (rf/reg-event-fx - ::toggle-loop-playback - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} _] - (let [loop-state (case (:loop-playback db) - :stream false - :playlist :stream - :playlist)] - {:db (assoc db :loop-playback loop-state) - :store (assoc store :loop-playback loop-state)}))) + :toggle-mobile-nav + (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 - ::navigated + :navigated (fn [{:keys [db]} [_ new-match]] - (let [old-match (:current-match db) + (let [old-match (:current-match db) controllers (rfc/apply-controllers (:controllers old-match) new-match) - match (assoc new-match :controllers controllers)] - {:db (-> db - (assoc :current-match match) - (assoc :show-mobile-nav false) - (assoc :show-pagination-loading false)) - ::scroll-to-top nil - ::body-overflow! false - :fx [[:dispatch [::show-media-queue false]] - [:dispatch [::get-services]] - [:dispatch [::get-kiosks (:service-id db)]]]}))) - -(rf/reg-event-fx - ::navigate - (fn [_ [_ route]] - {::navigate! route})) - -(rf/reg-fx - ::navigate! - (fn [{:keys [name params query]}] - (rfe/push-state name params query))) + match (assoc new-match :controllers controllers)] + {:db (-> db + (assoc :current-match match) + (assoc :show-mobile-nav false) + (assoc :show-pagination-loading false)) + :scroll-to-top nil + :body-overflow false + :fx [[:dispatch [:queue/show false]] + [:dispatch [:services/fetch-all + [:services/load] [:bad-response]]] + [:dispatch [:kiosks/fetch-all (:service-id db) + [:kiosks/load] [:bad-response]]]]}))) (defonce timeouts! (r/atom {})) (rf/reg-fx - ::timeout! + :timeout (fn [{:keys [id event time]}] (when-some [existing (get @timeouts! id)] (js/clearTimeout existing) @@ -273,244 +134,14 @@ (js/setTimeout #(rf/dispatch event) time))))) (rf/reg-event-fx - ::add-notification - (fn [{:keys [db]} [_ data time]] - (let [id (nano-id) - updated-db (update db :notifications #(into [] (conj %1 %2)) (assoc data :id id))] - {:db updated-db - :fx (if (false? time) - [] - [[::timeout! {:id id - :event [::remove-notification id] - :time (or time 2000)}]])}))) - -(rf/reg-event-db - ::remove-notification - (fn [db [_ id]] - (update db :notifications #(remove (fn [n] (= (:id n) id)) %)))) - -(rf/reg-event-db - ::clear-notifications - (fn [db _] - (dissoc db :notifications))) - -(rf/reg-event-fx - ::bad-response + :bad-response (fn [{:keys [db]} [_ res]] - {:fx [[:dispatch [::add-notification res]]]})) - -(rf/reg-event-db - ::change-search-query - (fn [db [_ res]] - (assoc db :search-query res))) - -(rf/reg-event-fx - ::change-service-id - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ service-id]] - {:db (assoc db :service-id service-id) - :store (assoc store :service-id service-id)})) - -(rf/reg-event-db - ::load-paginated-channel-results - (fn [db [_ res]] - (-> db - (update-in [:channel :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:channel :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::channel-pagination - (fn [{:keys [db]} [_ uri next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/channels/" (js/encodeURIComponent uri) ) - [::load-paginated-channel-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-paginated-playlist-results - (fn [db [_ res]] - (-> db - (update-in [:playlist :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:playlist :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::playlist-pagination - (fn [{:keys [db]} [_ uri next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/playlists/" (js/encodeURIComponent uri)) - [::load-paginated-playlist-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-paginated-search-results - (fn [db [_ res]] - (let [search-res (js->clj res :keywordize-keys true)] - (if (empty? (:items search-res)) - (-> db - (assoc-in [:search-results :next-page] nil) - (assoc :show-pagination-loading false)) - (-> db - (update-in [:search-results :items] #(apply conj %1 %2) - (:items search-res)) - (assoc-in [:search-results :next-page] (:next-page search-res)) - (assoc :show-pagination-loading false)))))) - -(rf/reg-event-fx - ::search-pagination - (fn [{:keys [db]} [_ query id next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/services/" id "/search") - [::load-paginated-search-results] [::bad-response] - {:q query - :nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-fx - ::add-to-media-queue - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ stream]] - (let [updated-db (update db :media-queue conj stream)] - {:db updated-db - :store (assoc store :media-queue (:media-queue updated-db))}))) - -(rf/reg-event-fx - ::remove-from-media-queue - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ pos]] - (let [updated-db (update db :media-queue #(into (subvec % 0 pos) (subvec % (inc pos)))) - media-queue-pos (:media-queue-pos db) - media-queue-length (count (:media-queue updated-db))] - {:db updated-db - :store (assoc store :media-queue (:media-queue updated-db)) - :fx (cond - (and (not (= media-queue-length 0)) - (or (< pos media-queue-pos) - (= pos media-queue-pos) - (= media-queue-pos media-queue-length))) - [[:dispatch [::change-media-queue-pos - (cond - (= pos media-queue-length) 0 - (= pos media-queue-pos) pos - :else (dec media-queue-pos))]]] - (= (count (:media-queue updated-db)) 0) - [[:dispatch [::dispose-audio-player]] - [:dispatch [::show-media-queue false]]] - :else [])}))) - -(rf/reg-event-fx - ::change-media-queue-pos - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ i]] - (let [idx (if (< i (count (:media-queue db))) - i - (when (= (:loop-playback db) :playlist) 0)) - stream (get (:media-queue db) idx)] - (when stream - {:db (-> db - (assoc :player-ready false) - (assoc :media-queue-pos idx) - (assoc-in [:media-queue idx :stream] "")) - :store (assoc store :media-queue-pos idx) - :fx [[:dispatch [::fetch-audio-player-stream (:url stream) idx true]]]})))) - -(rf/reg-event-fx - ::change-media-queue-stream - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ src idx]] - (let [update-entry #(assoc-in % [:media-queue idx :stream] src)] - {:db (update-entry db) - :store (update-entry store)}))) - -(rf/reg-event-fx - ::dispose-audio-player - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} _] - (let [remove-entries - (fn [elem] - (-> elem - (assoc :show-audio-player (not (:show-audio-player elem))) - (assoc :player-ready false) - (assoc :media-queue []) - (assoc :media-queue-pos 0)))] - {:db (remove-entries db) - :store (remove-entries store) - :fx [[:dispatch [::set-player-paused true]] - [:dispatch [::set-player-time 0]]]}))) - -(rf/reg-event-fx - ::switch-to-audio-player - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ stream]] - (let [updated-db (update db :media-queue conj stream) - idx (.indexOf (:media-queue updated-db) stream)] - {:db (-> updated-db - (assoc :show-audio-player true)) - :store (-> store - (assoc :show-audio-player true) - (assoc :media-queue (:media-queue updated-db))) - :fx [[:dispatch [::fetch-audio-player-stream (:url stream) idx (= (count (:media-queue db)) 0)]]]}))) - -(rf/reg-event-fx - ::start-stream-radio - (fn [{:keys [db]} [_ stream]] - {:fx [[:dispatch [::switch-to-audio-player stream]] - (when (not= (count (:media-queue db)) 0) - [:dispatch [::change-media-queue-pos (count (:media-queue db))]]) - [:dispatch [::fetch-audio-player-related-streams (:url stream)]]]})) - -(rf/reg-event-fx - ::enqueue-related-streams - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ streams]] - {:db (assoc db :show-audio-player true) - :store (assoc store :show-audio-player true) - :fx (conj (map (fn [s] [:dispatch [::add-to-media-queue s]]) streams) - [:dispatch [::fetch-audio-player-stream (-> streams first :url) - (count (:media-queue db)) (= (count (:media-queue db)) 0)]])})) - -(rf/reg-event-db - ::modal - (fn [db [_ data]] - (assoc db :modal data))) - -(rf/reg-event-fx - ::close-modal - (fn [{:keys [db]} _] - {:db (assoc db :modal {:show? false :child nil}) - ::body-overflow! false})) - -(rf/reg-event-fx - ::open-modal - (fn [_ [_ child]] - {:fx [[:dispatch [::modal {:show? true :child child}]]] - ::body-overflow! true})) - -(rf/reg-event-fx - ::add-bookmark-list-modal - (fn [_ [_ child]] - {:fx [[:dispatch [::open-modal child]]]})) + {:fx [[:dispatch [:notifications/add res]]]})) (rf/reg-fx - ::download-file! + :file-download (fn [{:keys [data name mime-type]}] - (let [file (.createObjectURL js/URL (js/Blob. (array data) {:type mime-type})) + (let [file (.createObjectURL js/URL (js/Blob. (array data) {:type mime-type})) !link (.createElement js/document "a")] (set! (.-href !link) file) (set! (.-download !link) name) @@ -518,543 +149,17 @@ (.remove !link)))) (rf/reg-event-fx - ::add-imported-bookmark-list - (fn [{:keys [db]} [_ index bookmark]] - {:fx (if (= index 0) - (map (fn [s] [:dispatch [::add-to-likes s]]) - (:items bookmark)) - [[:dispatch [::add-bookmark-list bookmark]]])})) - -(rf/reg-event-fx - ::add-streams-to-imported-bookmark-lists - (fn [{:keys [db]} [_ bookmarks]] - {:fx (conj (map-indexed (fn [i b] [:dispatch [::add-imported-bookmark-list i b]]) bookmarks) - [:dispatch [::add-notification - {:status-text "Imported playlists successfully" - :failure :success}]])})) - -(defn fetch-imported-playlists-streams - [bookmarks] - (-> #(-> (p/all (map (fn [stream] - (p/then (js/fetch - (str "/api/v1/streams/" (js/encodeURIComponent stream))) - (fn [res] (.json res)))) - (:items %))) - (p/then (fn [results] - (assoc % :items results)))) - (map bookmarks) - p/all)) - -(rf/reg-event-fx - ::add-imported-bookmark-lists - (fn [{:keys [db]} [_ bookmarks]] - {:promise {:call #(-> (fetch-imported-playlists-streams bookmarks) - (p/then (fn [res] (js->clj res :keywordize-keys true)))) - :on-success-n [[::clear-notifications] - [::add-streams-to-imported-bookmark-lists]]} - :fx [[:dispatch [::add-notification {:status-text "Importing playlists..." :failure :success} false]]]})) - -(rf/reg-fx - ::import-bookmark-list - (fn [file] - (-> (.text file) - (p/then - #(let [res (js->clj (.parse js/JSON %) :keywordize-keys true)] - (if (= (:format res) "Tubo") - (rf/dispatch [::add-imported-bookmark-lists (:playlists res)]) - (throw (js/Error. "Format not supported"))))) - (p/catch js/Error - (fn [error] - (rf/dispatch [::add-notification {:status-text (.-message error) :failure :error}])))))) - -(rf/reg-event-fx - ::import-bookmark-lists - (fn [{:keys [db]} [_ files]] - {:fx (map (fn [file] [::import-bookmark-list file]) files)})) - -(rf/reg-event-fx - ::export-bookmark-lists - (fn [{:keys [db]} [_]] - {::download-file! - {:name "playlists.json" - :mime-type "application/json" - :data (.stringify js/JSON (clj->js {:format "Tubo" - :version 1 - :playlists - (map (fn [bookmark] - {:name (:name bookmark) - :items (map :url (:items bookmark))}) - (:bookmarks db))}))} - :fx [[:dispatch [::add-notification {:status-text "Exported playlists" :failure :success}]]]})) - -(rf/reg-event-fx - ::add-bookmark-list - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ bookmark notify?]] - (let [updated-db (update db :bookmarks conj (if (:id bookmark) bookmark (assoc bookmark :id (nano-id))))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx (conj [[:dispatch [::close-modal]]] - (when notify? - [:dispatch [::add-notification {:status-text (str "Added playlist \"" (:name bookmark) "\"") - :failure :success}]]))}))) - -(rf/reg-event-fx - ::back-to-bookmark-list-modal - (fn [_ [_ item]] - {:fx [[:dispatch [::open-modal [bookmarks/add-to-bookmark-list-modal item]]]]})) - -(rf/reg-event-fx - ::add-bookmark-list-and-back - (fn [_ [_ bookmark item]] - {:fx [[:dispatch [::add-bookmark-list bookmark true]] - [:dispatch [::back-to-bookmark-list-modal item]]]})) - -(rf/reg-event-fx - ::remove-bookmark-list - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ id notify?]] - (let [bookmark (first (filter #(= (:id %) id) (:bookmarks db))) - updated-db (update db :bookmarks - #(into [] (remove (fn [bookmark] (= (:id bookmark) id)) %)))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx (if notify? - [[:dispatch [::add-notification - {:status-text (str "Removed playlist \"" (:name bookmark) "\"") - :failure :success}]]] - [])}))) - -(rf/reg-event-fx - ::clear-bookmark-lists - (fn [{:keys [db]} _] - {:fx (apply merge - (map (fn [b] [:dispatch [::remove-bookmark-list (:id b)]]) (rest (:bookmarks db))) - (conj - (map (fn [s] [:dispatch [::remove-from-likes s]]) (:items (first (:bookmarks db)))) - [:dispatch [::add-notification - {:status-text "Cleared all playlists" - :failure :success}]]))})) - -(rf/reg-event-fx - ::add-to-likes - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ bookmark notify?]] - (when-not (some #(= (:url %) (:url bookmark)) (-> db :bookmarks first :items)) - (let [updated-db (update-in db [:bookmarks 0 :items] #(into [] (conj (into [] %1) %2)) - (assoc bookmark :bookmark-id (-> db :bookmarks first :id)))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx (if notify? - [[:dispatch [::add-notification {:status-text "Added to favorites" :failure :success}]]] - [])})))) - -(rf/reg-event-fx - ::remove-from-likes - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ bookmark notify?]] - (let [updated-db (update-in db [:bookmarks 0 :items] #(remove (fn [item] (= (:url item) (:url bookmark))) %))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx (if notify? - [[:dispatch [::add-notification {:status-text "Removed from favorites" :failure :success}]]] - [])}))) - -(rf/reg-event-fx - ::add-related-streams-to-bookmark-list - (fn [_ [_ bookmark related-streams]] - {:fx (conj (map (fn [s] [:dispatch [::add-to-bookmark-list bookmark s]]) related-streams) - [:dispatch [::add-notification - {:status-text (str "Added " (count related-streams) - " items to playlist \"" - (:name bookmark) "\"") - :failure :success}]])})) - -(rf/reg-event-fx - ::add-to-bookmark-list - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ bookmark item notify?]] - (let [bookmark-list (first (filter #(= (:id %) (:id bookmark)) (:bookmarks db))) - pos (.indexOf (:bookmarks db) bookmark-list) - updated-db (if (some #(= (:url %) (:url item)) (:items bookmark-list)) - db - (update-in db [:bookmarks pos :items] #(into [] (conj (into [] %1) %2)) - (assoc item :bookmark-id (:id bookmark))))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx (conj - [[:dispatch [::close-modal]]] - (when notify? - [:dispatch [::add-notification {:status-text (str "Added to playlist \"" (:name bookmark-list) "\"") - :failure :success}]]))}))) - -(rf/reg-event-fx - ::remove-from-bookmark-list - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ bookmark]] - (let [bookmark-list (first (filter #(= (:id %) (:bookmark-id bookmark)) (:bookmarks db))) - pos (.indexOf (:bookmarks db) bookmark-list) - updated-db (update-in db [:bookmarks pos :items] #(remove (fn [item] (= (:url item) (:url bookmark))) %))] - {:db updated-db - :store (assoc store :bookmarks (:bookmarks updated-db)) - :fx [[:dispatch [::add-notification {:status-text (str "Removed from playlist \"" (:name bookmark-list) "\"") - :failure :success}]]]}))) - -(rf/reg-event-db - ::load-services - (fn [db [_ res]] - (assoc db :services (js->clj res :keywordize-keys true)))) - -(rf/reg-event-fx - ::set-service-styles - (fn [{:keys [db]} [_ res]] - {:db db - :fx [[:dispatch [::change-service-id (:service-id res)]] - [:dispatch [::get-kiosks (:service-id res)]]]})) - -(rf/reg-event-fx - ::get-services - (fn [{:keys [db]} _] - (api/get-request "/services" [::load-services] [::bad-response]))) - -(rf/reg-event-db - ::load-comments - (fn [db [_ res]] - (-> db - (assoc-in [:stream :comments-page] (js->clj res :keywordize-keys true)) - (assoc-in [:stream :show-comments-loading] false)))) - -(rf/reg-event-fx - ::get-comments - (fn [{:keys [db]} [_ url]] - (assoc - (api/get-request (str "/comments/" (js/encodeURIComponent url)) - [::load-comments] [::bad-response]) - :db (-> db - (assoc-in [:stream :show-comments-loading] true) - (assoc-in [:stream :show-comments] true))))) - -(rf/reg-event-db - ::toggle-stream-layout - (fn [db [_ layout]] - (assoc-in db [:stream layout] (not (-> db :stream layout))))) - -(rf/reg-event-db - ::toggle-comment-replies - (fn [db [_ comment-id]] - (update-in db [:stream :comments-page :comments] - (fn [comments] - (map #(if (= (:id %) comment-id) - (assoc % :show-replies (not (:show-replies %))) - %) - comments))))) - -(rf/reg-event-db - ::load-paginated-comments - (fn [db [_ res]] - (-> db - (update-in [:stream :comments-page :comments] #(apply conj %1 %2) - (:comments (js->clj res :keywordize-keys true))) - (assoc-in [:stream :comments-page :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::comments-pagination - (fn [{:keys [db]} [_ url next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request (str "/comments/" (js/encodeURIComponent url)) - [::load-paginated-comments] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-kiosks - (fn [db [_ res]] - (assoc db :kiosks (js->clj res :keywordize-keys true)))) - -(rf/reg-event-fx - ::get-kiosks - (fn [{:keys [db]} [_ id]] - (api/get-request (str "/services/" id "/kiosks") - [::load-kiosks] [::bad-response]))) - -(rf/reg-event-fx - ::load-kiosk - (fn [{:keys [db]} [_ res]] - (let [kiosk-res (js->clj res :keywordize-keys true)] - {:db (assoc db :kiosk kiosk-res - :show-page-loading false) - :fx [[:dispatch [::set-service-styles kiosk-res]] - [::document-title! (:id kiosk-res)]]}))) - -(rf/reg-event-fx - ::get-default-kiosk-page - (fn [{:keys [db]} [_ service-id]] - (let [default-kiosk-id (when (= (js/parseInt service-id) - (-> db :settings :default-service :service-id)) - (-> db :settings :default-service :default-kiosk))] - (if default-kiosk-id - {:fx [[:dispatch [::get-kiosk-page service-id default-kiosk-id]]]} - (assoc - (api/get-request (str "/services/" service-id "/default-kiosk") - [::load-kiosk] [::bad-response]) - :db (assoc db :show-page-loading true)))))) - -(rf/reg-event-fx - ::get-kiosk-page - (fn [{:keys [db]} [_ service-id kiosk-id]] - (if kiosk-id - (assoc - (api/get-request (str "/services/" service-id "/kiosks/" - (js/encodeURIComponent kiosk-id)) - [::load-kiosk] [::bad-response]) - :db (assoc db :show-page-loading true)) - {:fx [[:dispatch [::get-default-kiosk-page service-id]]]}))) - -(rf/reg-event-fx - ::change-service-kiosk - (fn [{:keys [db]} [_ service-id]] - {:fx [[:dispatch [::change-service-id service-id]] - [:dispatch - [::navigate {:name :tubo.routes/kiosk - :params {} - :query {:serviceId service-id}}]]]})) - -(rf/reg-event-db - ::load-paginated-kiosk-results - (fn [db [_ res]] - (-> db - (update-in [:kiosk :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:kiosk :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::kiosk-pagination - (fn [{:keys [db]} [_ service-id kiosk-id next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/services/" service-id "/kiosks/" - (js/encodeURIComponent kiosk-id)) - [::load-paginated-kiosk-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-fx - ::load-homepage + :load-homepage (fn [{:keys [db]} [_ res]] (let [updated-db (assoc db :services (js->clj res :keywordize-keys true)) service-id (:id (first (filter #(= (-> db :settings :default-service :id) (-> % :info :name)) (:services updated-db))))] - {:fx [[:dispatch [::get-default-kiosk-page service-id]] - [:dispatch [::change-service-id service-id]]]}))) - -(rf/reg-event-fx - ::get-homepage - (fn [{:keys [db]} _] - (api/get-request "/services" [::load-homepage] [::bad-response]))) - -(rf/reg-event-fx - ::load-audio-player-related-streams - (fn [{:keys [db]} [_ res]] - (let [stream-res (js->clj res :keywordize-keys true)] - {:fx [[:dispatch [::enqueue-related-streams (:related-streams stream-res)]]]}))) - -(rf/reg-event-fx - ::load-audio-player-stream - [(rf/inject-cofx ::inject/sub [:player])] - (fn [{:keys [db player]} [_ idx play? res]] - (let [stream-res (js->clj res :keywordize-keys true)] - {:db (assoc db :show-audio-player-loading false) - :fx (apply conj [[:dispatch [::change-media-queue-stream - (-> stream-res :audio-streams first :content) - idx]]] - (when play? - [[::player-src {:player player - :src (-> stream-res :audio-streams first :content) - :current-pos (:media-queue-pos db)}] - [::stream-metadata {:title (:name stream-res) - :artist (:uploader-name stream-res) - :artwork [{:src (:thumbnail-url stream-res)}]}] - [::media-session {:current-pos (:media-queue-pos db) :player player}]]))}))) - -(rf/reg-event-fx - ::load-stream-page - (fn [{:keys [db]} [_ res]] - (let [stream-res (js->clj res :keywordize-keys true)] - {:db (assoc db :stream stream-res - :show-page-loading false) - :fx [(when (and (-> db :settings :show-comments)) - [:dispatch [::get-comments (:url stream-res)]]) - [:dispatch [::set-service-styles stream-res]] - [::document-title! (:name stream-res)]]}))) - -(rf/reg-event-fx - ::fetch-stream-page - (fn [{:keys [db]} [_ uri]] - (api/get-request (str "/streams/" (js/encodeURIComponent uri)) - [::load-stream-page] [::bad-response]))) - -(rf/reg-event-fx - ::audio-player-stream-failure - (fn [{:keys [db]} [_ play? res]] - {:db (assoc db - :show-audio-player-loading false - :player-ready true) - :fx [[:dispatch [::bad-response res]] - (when play? - (if (> (-> db :media-queue count) 1) - [:dispatch [::change-media-queue-pos (-> db :media-queue-pos inc)]] - [:dispatch [::dispose-audio-player]]))]})) - -(rf/reg-event-fx - ::fetch-audio-player-related-streams - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request (str "/streams/" (js/encodeURIComponent uri)) - [::load-audio-player-related-streams] [::bad-response]) - :db (assoc db :show-audio-player-loading true)))) - -(rf/reg-event-fx - ::fetch-audio-player-stream - (fn [{:keys [db]} [_ uri idx play?]] - (assoc - (api/get-request (str "/streams/" (js/encodeURIComponent uri)) - [::load-audio-player-stream idx play?] - [::audio-player-stream-failure play?]) - :db (assoc db :show-audio-player-loading true)))) - -(rf/reg-event-fx - ::get-stream-page - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request (str "/streams/" (js/encodeURIComponent uri)) - [::load-stream-page] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-fx - ::load-channel - (fn [{:keys [db]} [_ res]] - (let [channel-res (js->clj res :keywordize-keys true)] - {:db (assoc db :channel channel-res - :show-page-loading false) - :fx [[:dispatch [::set-service-styles channel-res]] - [::document-title! (:name channel-res)]]}))) - -(rf/reg-event-fx - ::get-channel-page - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request - (str "/channels/" (js/encodeURIComponent uri)) - [::load-channel] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-fx - ::load-playlist - (fn [{:keys [db]} [_ res]] - (let [playlist-res (js->clj res :keywordize-keys true)] - {:db (assoc db :playlist playlist-res - :show-page-loading false) - :fx [[:dispatch [::set-service-styles playlist-res]] - [::document-title! (:name playlist-res)]]}))) - -(rf/reg-event-fx - ::get-playlist-page - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request (str "/playlists/" (js/encodeURIComponent uri)) - [::load-playlist] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-fx - ::load-search-results - (fn [{:keys [db]} [_ res]] - (let [search-res (js->clj res :keywordize-keys true)] - {:db (assoc db :search-results search-res - :show-page-loading false) - :fx [[:dispatch [::set-service-styles search-res]]]}))) - -(rf/reg-event-fx - ::get-search-page - (fn [{:keys [db]} [_ service-id query]] - (assoc - (api/get-request (str "/services/" service-id "/search") - [::load-search-results] [::bad-response] - {:q query}) - :db (assoc db :show-page-loading true - :show-search-form true) - :fx [[::document-title! (str "Search for \"" query "\"")]]))) - -(rf/reg-event-fx - ::change-setting - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ key val]] - {:db (assoc-in db [:settings key] val) - :store (assoc store key val)})) + {:fx [[:dispatch [:kiosks/fetch-default-page service-id]] + [:dispatch [:services/change-id service-id]]]}))) (rf/reg-event-fx - ::load-settings-kiosks - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ service-name service-id res]] - (let [kiosks-res (js->clj res :keywordize-keys true) - default-service-kiosk (-> db :settings :default-service :default-kiosk) - default-kiosk (if (some #(= % default-service-kiosk) (:available-kiosks kiosks-res)) - default-service-kiosk - (:default-kiosk kiosks-res))] - {:db (update-in db [:settings :default-service] assoc - :id service-name - :service-id service-id - :available-kiosks (:available-kiosks kiosks-res) - :default-kiosk default-kiosk) - :store (update-in store [:default-service] assoc - :id service-name - :service-id service-id - :available-kiosks (:available-kiosks kiosks-res) - :default-kiosk default-kiosk)}))) - -(rf/reg-event-fx - ::change-service-setting - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ val]] - (let [service-id (-> (filter #(= val (-> % :info :name)) (:services db)) - first - :id)] - (api/get-request (str "/services/" service-id "/kiosks") - [::load-settings-kiosks val service-id] [::bad-response])))) - -(rf/reg-event-fx - ::change-kiosk-setting - [(rf/inject-cofx :store)] - (fn [{:keys [db store]} [_ val]] - {:db (assoc-in db [:settings :default-service :default-kiosk] val) - :store (assoc-in store [:default-service :default-kiosk] val)})) - -(rf/reg-event-fx - ::get-settings-page + :fetch-homepage (fn [{:keys [db]} _] - (let [id (-> db :settings :default-service :id) - service-id (-> db :settings :default-service :service-id)] - (assoc - (api/get-request (str "/services/" service-id "/kiosks") - [::load-settings-kiosks id service-id] [::bad-response]) - ::document-title! "Settings")))) - -(rf/reg-event-fx - ::get-bookmarks-page - (fn [_] - {::document-title! "Bookmarks"})) - -(rf/reg-event-fx - ::get-bookmark-page - (fn [{:keys [db]} [_ playlist-id]] - (let [playlist (first (filter #(= (:id %) playlist-id) (:bookmarks db)))] - {::document-title! (:name playlist)}))) + {:fx [[:dispatch [:services/fetch-all [:load-homepage] [:bad-response]]]]})) diff --git a/src/frontend/tubo/kiosks/events.cljs b/src/frontend/tubo/kiosks/events.cljs new file mode 100644 index 0000000..c85fc51 --- /dev/null +++ b/src/frontend/tubo/kiosks/events.cljs @@ -0,0 +1,87 @@ +(ns tubo.kiosks.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-db + :kiosks/load + (fn [db [_ res]] + (assoc db :kiosks (js->clj res :keywordize-keys true)))) + +(rf/reg-event-fx + :kiosks/fetch + (fn [{:keys [db]} [_ service-id kiosk-id on-success on-error params]] + (api/get-request (str "/services/" service-id "/kiosks/" + (js/encodeURIComponent kiosk-id)) + on-success on-error params))) + +(rf/reg-event-fx + :kiosks/fetch-default + (fn [{:keys [db]} [_ service-id on-success on-error]] + (api/get-request (str "/services/" service-id "/default-kiosk") + on-success on-error))) + +(rf/reg-event-fx + :kiosks/fetch-all + (fn [{:keys [db]} [_ id on-success on-error]] + (api/get-request (str "/services/" id "/kiosks") + on-success on-error))) + +(rf/reg-event-fx + :kiosks/load-page + (fn [{:keys [db]} [_ res]] + (let [kiosk-res (js->clj res :keywordize-keys true)] + {:db (assoc db :kiosk kiosk-res + :show-page-loading false) + :fx [[:dispatch [:services/fetch kiosk-res]] + [:document-title (:id kiosk-res)]]}))) + +(rf/reg-event-fx + :kiosks/fetch-page + (fn [{:keys [db]} [_ service-id kiosk-id]] + {:db (assoc db :show-page-loading true) + :fx [[:dispatch (if kiosk-id + [:kiosks/fetch service-id kiosk-id + [:kiosks/load-page] [:bad-response]] + [:kiosks/fetch-default-page service-id])]]})) + +(rf/reg-event-fx + :kiosks/fetch-default-page + (fn [{:keys [db]} [_ service-id]] + (let [default-kiosk-id + (when (= (js/parseInt service-id) + (-> db :settings :default-service :service-id)) + (-> db :settings :default-service :default-kiosk))] + {:fx [[:dispatch (if default-kiosk-id + [:kiosks/fetch-page service-id default-kiosk-id] + [:kiosks/fetch-default service-id + [:kiosks/load-page] [:bad-response]])]]}))) + +(rf/reg-event-fx + :kiosks/change-page + (fn [{:keys [db]} [_ service-id]] + {:fx [[:dispatch [:services/change-id service-id]] + [:dispatch + [:navigate {:name :kiosk-page + :params {} + :query {:serviceId service-id}}]]]})) + +(rf/reg-event-db + :kiosks/load-paginated + (fn [db [_ res]] + (-> db + (update-in [:kiosk :related-streams] #(into %1 %2) + (:related-streams (js->clj res :keywordize-keys true))) + (assoc-in [:kiosk :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + :kiosks/fetch-paginated + (fn [{:keys [db]} [_ service-id kiosk-id next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + {:db (assoc db :show-pagination-loading true) + :fx [[:dispatch [:kiosks/fetch service-id kiosk-id + [:kiosks/load-paginated] [:bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}]]]}))) diff --git a/src/frontend/tubo/kiosks/subs.cljs b/src/frontend/tubo/kiosks/subs.cljs new file mode 100644 index 0000000..715ab1e --- /dev/null +++ b/src/frontend/tubo/kiosks/subs.cljs @@ -0,0 +1,13 @@ +(ns tubo.kiosks.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :kiosks + (fn [db _] + (:kiosks db))) + +(rf/reg-sub + :kiosk + (fn [db _] + (:kiosk db))) diff --git a/src/frontend/tubo/kiosks/views.cljs b/src/frontend/tubo/kiosks/views.cljs new file mode 100644 index 0000000..17e0e76 --- /dev/null +++ b/src/frontend/tubo/kiosks/views.cljs @@ -0,0 +1,43 @@ +(ns tubo.kiosks.views + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.items :as items] + [tubo.components.layout :as layout])) + +(defn kiosk-active? + [& {:keys [kiosk kiosk-id service-id default-service default-kiosk path]}] + (or (and (= kiosk-id kiosk)) + (and (= path "/kiosk") + (not kiosk-id) + (not= (js/parseInt service-id) + (:service-id default-service)) + (= default-kiosk kiosk)) + (and (or (= path "/") (= path "/kiosk")) + (not kiosk-id) + (= (:default-kiosk default-service) kiosk)))) + +(defn kiosks-menu + [& {:keys [kiosks service-id] :as kiosk-args}] + [:ul.flex.items-center.px-4.text-white + (for [kiosk kiosks] + [:li.px-3 {:key kiosk} + [:a {:href (rfe/href :kiosk-page nil {:serviceId service-id + :kioskId kiosk}) + :class (when (kiosk-active? (assoc kiosk-args :kiosk kiosk)) + :font-bold)} + kiosk]])]) + +(defn kiosk + [{{:keys [serviceId kioskId]} :query-params}] + (let [{:keys [id url related-streams + next-page]} @(rf/subscribe [:kiosk]) + next-page-url (:url next-page) + service-color @(rf/subscribe [:service-color]) + service-id (or @(rf/subscribe [:service-id]) serviceId) + scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] + (when scrolled-to-bottom? + (rf/dispatch [:kiosks/fetch-paginated service-id id next-page-url])) + [layout/content-container + [layout/content-header id] + [items/related-streams related-streams next-page-url]])) diff --git a/src/frontend/tubo/modals/events.cljs b/src/frontend/tubo/modals/events.cljs new file mode 100644 index 0000000..4a88a5b --- /dev/null +++ b/src/frontend/tubo/modals/events.cljs @@ -0,0 +1,36 @@ +(ns tubo.modals.events + (:require + [nano-id.core :refer [nano-id]] + [re-frame.core :as rf])) + +(rf/reg-event-db + :modals/add + (fn [db [_ data]] + (update db :modals #(into [] (conj (into [] %1) %2)) data))) + +(rf/reg-event-db + :modals/delete + (fn [db [_ id]] + (update db :modals #(remove (fn [modal] (= (:id modal) id)) %)))) + +(rf/reg-event-fx + :modals/hide + (fn [{:keys [db]} _] + {:db (update db :modals + #(map-indexed (fn [i modal] + (if (= i (- (count %) 1)) + (assoc modal :show? false :child nil))) + %)) + :body-overflow false})) + +(rf/reg-event-fx + :modals/close + (fn [{:keys [db]} _] + {:fx [[:dispatch [:modals/delete (-> (:modals db) last :id)]]] + :body-overflow false})) + +(rf/reg-event-fx + :modals/open + (fn [_ [_ child]] + {:fx [[:dispatch [:modals/add {:show? true :child child :id (nano-id)}]]] + :body-overflow true})) diff --git a/src/frontend/tubo/modals/subs.cljs b/src/frontend/tubo/modals/subs.cljs new file mode 100644 index 0000000..ea43f63 --- /dev/null +++ b/src/frontend/tubo/modals/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.modals.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :modals + (fn [db _] + (:modals db))) diff --git a/src/frontend/tubo/modals/views.cljs b/src/frontend/tubo/modals/views.cljs new file mode 100644 index 0000000..e4c3aef --- /dev/null +++ b/src/frontend/tubo/modals/views.cljs @@ -0,0 +1,34 @@ +(ns tubo.modals.views + (:require + [re-frame.core :as rf] + [tubo.components.layout :as layout])) + +(defn modal-content + [title body & extra-buttons] + [:div.bg-white.dark:bg-neutral-900.max-h-full.flex.flex-col.flex-auto.shrink-0.gap-y-5.border.border-neutral-300.dark:border-stone-700.rounded.p-5.z-20 + [:div.flex.justify-between.shrink-0 + [:h1.text-xl.font-semibold title] + [:button {:on-click #(rf/dispatch [:modals/close])} + [:i.fa-solid.fa-close]]] + [:div.flex-auto.overflow-y-auto body] + [:div.flex.justify-end.gap-x-3.shrink-0 + (if extra-buttons + (map-indexed #(with-meta %2 {:key %1}) extra-buttons) + [layout/primary-button "Ok" #(rf/dispatch [:modals/close])])]]) + +(defn modal-panel + [{:keys [child show?]}] + [:div.fixed.flex.flex-col.items-center.justify-center.w-full.z-20.top-0 + {:class ["min-h-[100dvh]" "h-[100dvh]"]} + [layout/focus-overlay #(rf/dispatch [:modals/close]) show?] + [:div.flex.items-center.justify-center.flex-auto.shrink-0.w-full.max-h-full.p-5 + {:class ["sm:w-3/4" "md:w-3/5" "lg:w-1/2" "xl:w-1/3"]} + child]]) + +(defn modal + [] + (fn [] + (let [modals @(rf/subscribe [:modals]) + visible-modal (last (filter :show? modals))] + (when visible-modal + [modal-panel visible-modal])))) diff --git a/src/frontend/tubo/notifications/events.cljs b/src/frontend/tubo/notifications/events.cljs new file mode 100644 index 0000000..2ecbf2e --- /dev/null +++ b/src/frontend/tubo/notifications/events.cljs @@ -0,0 +1,28 @@ +(ns tubo.notifications.events + (:require + [re-frame.core :as rf] + [nano-id.core :refer [nano-id]])) + +(rf/reg-event-fx + :notifications/add + (fn [{:keys [db]} [_ data time]] + (let [id (nano-id) + updated-db (update db :notifications #(into [] (conj %1 %2)) + (assoc data :id id))] + {:db updated-db + :fx (if (false? time) + [] + [[:timeout {:id id + :event [:notifications/remove id] + :time (or time 2000)}]])}))) + +(rf/reg-event-db + :notifications/remove + (fn [db [_ id]] + (update db :notifications #(remove (fn [notification] + (= (:id notification) id)) %)))) + +(rf/reg-event-db + :notifications/clear + (fn [db _] + (dissoc db :notifications))) diff --git a/src/frontend/tubo/notifications/subs.cljs b/src/frontend/tubo/notifications/subs.cljs new file mode 100644 index 0000000..f7c9460 --- /dev/null +++ b/src/frontend/tubo/notifications/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.notifications.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :notifications + (fn [db _] + (:notifications db))) diff --git a/src/frontend/tubo/notifications/views.cljs b/src/frontend/tubo/notifications/views.cljs new file mode 100644 index 0000000..b5acf01 --- /dev/null +++ b/src/frontend/tubo/notifications/views.cljs @@ -0,0 +1,27 @@ +(ns tubo.notifications.views + (:require + [re-frame.core :as rf])) + +(defn notification-content + [{:keys [failure parse-error status status-text] :as notification}] + (when notification + [:div.flex.flex-col.justify-center.pl-4.pr-8.py-4.rounded.backdrop-blur.shadow.shadow-neutral-700 + {:class (case failure + :success ["bg-green-600/90" "text-white"] + :error ["bg-red-600/90" "text-white"] + ["bg-neutral-300"])} + [:button.text-lg.absolute.top-1.right-2 + {:on-click + #(rf/dispatch [:notifications/remove (:id notification)])} + [:i.fa-solid.fa-close]] + [:span.font-bold (str (when status (str status ": ")) status-text)] + (when parse-error + [:span.line-clamp-1 (:status-text parse-error)])])) + +(defn notifications-panel + [] + (fn [] + (let [notifications @(rf/subscribe [:notifications])] + [:div.fixed.flex.flex-col.items-end.gap-2.top-16.z-20.w-full.py-1.px-2 + (for [[i notification] (map-indexed vector notifications)] + ^{:key i} [notification-content notification])]))) diff --git a/src/frontend/tubo/player/events.cljs b/src/frontend/tubo/player/events.cljs new file mode 100644 index 0000000..786cb3c --- /dev/null +++ b/src/frontend/tubo/player/events.cljs @@ -0,0 +1,248 @@ +(ns tubo.player.events + (:require + [tubo.utils :as utils] + [goog.object :as gobj] + [re-frame.core :as rf] + [vimsical.re-frame.cofx.inject :as inject])) + +(rf/reg-fx + :volume + (fn [{:keys [player volume]}] + (when (and @player (> (.-readyState @player) 0)) + (set! (.-volume @player) (/ volume 100))))) + +(rf/reg-fx + :mute + (fn [{:keys [player muted?]}] + (when (and @player (> (.-readyState @player) 0)) + (set! (.-muted @player) muted?)))) + +(rf/reg-fx + :src + (fn [{:keys [player src current-pos]}] + (set! (.-src @player) src) + (set! (.-onended @player) + #(rf/dispatch [:queue/change-pos (inc current-pos)])))) + +(rf/reg-fx + :current-time + (fn [{:keys [time player]}] + (set! (.-currentTime @player) time))) + +(rf/reg-event-fx + :player/seek + [(rf/inject-cofx ::inject/sub [:player])] + (fn [{:keys [db player]} [_ time]] + {:current-time {:time time :player player}})) + +(rf/reg-fx + :pause + (fn [{:keys [paused? player]}] + (when (and @player (> (.-readyState @player) 0)) + (if paused? + (.play @player) + (.pause @player))))) + +(rf/reg-event-db + :player/set-paused + (fn [db [_ val]] + (assoc db :paused val))) + +(rf/reg-event-fx + :player/pause + [(rf/inject-cofx ::inject/sub [:player])] + (fn [{:keys [db player]} [_ paused?]] + {:pause {:paused? (not paused?) + :player player}})) + +(rf/reg-event-fx + :player/stop + (fn [{:keys [db]}] + {:fx [[:dispatch [:player/pause true]] + [:dispatch [:player/seek 0]]]})) + +(rf/reg-event-fx + :player/start-in-background + [(rf/inject-cofx ::inject/sub [:player]) + (rf/inject-cofx ::inject/sub [:elapsed-time])] + (fn [{:keys [db player]} _] + {:fx [[:dispatch [:player/set-paused true]] + [:dispatch [:player/pause false]] + [:dispatch [:player/change-volume (:volume-level db) player]]] + :db (assoc db :player-ready (and @player (> (.-readyState @player) 0)))})) + +(rf/reg-fx + :audio-poster-mode + (fn [{:keys [player options]}] + (.audioPosterMode + @player + (-> (filter #(= (:src %) (.src @player)) (:sources options)) + first + :label + (clojure.string/includes? "audio-only"))))) + +(rf/reg-fx + :slider-color + (fn [{:keys [player color]}] + (doseq [class [".vjs-play-progress" ".vjs-volume-level" ".vjs-slider-bar"]] + (set! (.. (.$ (.getChild ^videojs/VideoJsPlayer @player "ControlBar") class) -style -background) color)))) + +(rf/reg-event-fx + :player/start-in-main + [(rf/inject-cofx ::inject/sub [:elapsed-time])] + (fn [{:keys [db]} [_ !player options service-id]] + {:fx [[:audio-poster-mode {:player !player :options options}] + [:slider-color {:player !player :color (utils/get-service-color service-id)}]]})) + +(rf/reg-fx + :media-session-metadata + (fn [metadata] + (when (gobj/containsKey js/navigator "mediaSession") + (set! (.-metadata js/navigator.mediaSession) + (js/MediaMetadata. (clj->js metadata)))))) + +(rf/reg-fx + :media-session-handlers + (fn [{:keys [current-pos player stream]}] + (when (gobj/containsKey js/navigator "mediaSession") + (let [current-time (.-currentTime @player) + update-position + #(.setPositionState js/navigator.mediaSession + {:duration (.-duration @player) + :playbackRate (.-playbackRate @player) + :position (.-currentTime @player)}) + seek #(do (rf/dispatch [:seek %]) (update-position)) + events + {"play" #(.play @player) + "pause" #(.pause @player) + "previoustrack" #(rf/dispatch [:change-queue-pos (dec current-pos)]) + "nexttrack" #(rf/dispatch [:change-queue-pos (inc current-pos)]) + "seekbackward" (fn [^js/navigator.MediaSessionActionDetails details] + (seek (- current-time (or (.-seekOffset details) 10)))) + "seekforward" (fn [^js/navigator.MediaSessionActionDetails details] + (seek (+ current-time (or (.-seekOffset details) 10)))) + "seekto" (fn [^js/navigator.MediaSessionActionDetails details] + (seek (.-seekTime details))) + "stop" #(seek 0)}] + (doseq [[action cb] events] + (.setActionHandler js/navigator.mediaSession action cb)))))) + +(rf/reg-event-fx + :player/change-volume + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ value player]] + {:db (assoc db :volume-level value) + :store (assoc store :volume-level value) + :volume {:player player :volume value}})) + +(rf/reg-event-fx + :player/mute + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ value player]] + {:db (assoc db :muted value) + :store (assoc store :muted value) + :mute {:player player :muted? value}})) + +(rf/reg-event-fx + :player/loop + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} _] + (let [loop-state (case (:loop-playback db) + :stream false + :playlist :stream + :playlist)] + {:db (assoc db :loop-playback loop-state) + :store (assoc store :loop-playback loop-state)}))) + +(rf/reg-event-fx + :player/dispose + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} _] + (let [remove-entries + (fn [elem] + (-> elem + (update :show-background-player #(not %)) + (assoc :player-ready false) + (assoc :queue []) + (assoc :queue-pos 0)))] + {:db (remove-entries db) + :store (remove-entries store) + :fx [[:dispatch [:player/pause true]] + [:dispatch [:player/seek 0]]]}))) + +(rf/reg-event-fx + :player/switch-to-background + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ stream]] + (let [updated-db (update db :queue conj stream) + idx (.indexOf (:queue updated-db) stream)] + {:db (-> updated-db + (assoc :show-background-player true)) + :store (-> store + (assoc :show-background-player true) + (assoc :queue (:queue updated-db))) + :fx [[:dispatch [:player/fetch-stream + (:url stream) idx (= (count (:queue db)) 0)]]]}))) + +(rf/reg-event-fx + :player/load-related-streams + (fn [{:keys [db]} [_ res]] + (let [{:keys [related-streams]} (js->clj res :keywordize-keys true)] + {:fx [[:dispatch [:queue/add-n related-streams]]]}))) + +(rf/reg-event-fx + :player/load-stream + [(rf/inject-cofx ::inject/sub [:player])] + (fn [{:keys [db player]} [_ idx play? res]] + (let [stream-res (js->clj res :keywordize-keys true)] + {:db (assoc db :show-background-player-loading false) + :fx (apply conj [[:dispatch [:queue/change-stream-source + (-> stream-res :audio-streams first :content) + idx]]] + (when play? + [[:src + {:player player + :src (-> stream-res :audio-streams first :content) + :current-pos (:queue-pos db)}] + [:media-session-metadata + {:title (:name stream-res) + :artist (:uploader-name stream-res) + :artwork [{:src (:thumbnail-url stream-res)}]}] + [:media-session-handlers + {:current-pos (:queue-pos db) + :player player}]]))}))) + +(rf/reg-event-fx + :player/bad-response + (fn [{:keys [db]} [_ play? res]] + {:db (assoc db + :show-background-player-loading false + :player-ready true) + :fx [[:dispatch [:bad-response res]] + (when play? + (if (> (-> db :queue count) 1) + [:dispatch [:queue/change-pos (-> db :queue-pos inc)]] + [:dispatch [:player/dispose]]))]})) + +(rf/reg-event-fx + :player/fetch-related-streams + (fn [{:keys [db]} [_ url]] + {:fx [[:dispatch [:stream/fetch url + [:player/load-related-streams]] [:bad-response]]] + :db (assoc db :show-background-player-loading true)})) + +(rf/reg-event-fx + :player/fetch-stream + (fn [{:keys [db]} [_ url idx play?]] + {:fx [[:dispatch [:stream/fetch url + [:player/load-stream idx play?] + [:player/bad-response play?]]]] + :db (assoc db :show-background-player-loading true)})) + +(rf/reg-event-fx + :player/start-radio + (fn [{:keys [db]} [_ stream]] + {:fx [[:dispatch [:player/switch-to-background stream]] + (when (not= (count (:queue db)) 0) + [:dispatch [:queue/change-pos (count (:queue db))]]) + [:dispatch [:player/fetch-related-streams (:url stream)]]]})) diff --git a/src/frontend/tubo/player/subs.cljs b/src/frontend/tubo/player/subs.cljs new file mode 100644 index 0000000..55da360 --- /dev/null +++ b/src/frontend/tubo/player/subs.cljs @@ -0,0 +1,52 @@ +(ns tubo.player.subs + (:require + [re-frame.core :as rf] + [reagent.core :as r])) + +(defonce !player (atom nil)) +(defonce !elapsed-time (r/atom 0)) + +(rf/reg-sub + :player + (fn [db _] + !player)) + +(rf/reg-sub + :player-ready + (fn [db _] + (:player-ready db))) + +(rf/reg-sub + :show-background-player + (fn [db _] + (:show-background-player db))) + +(rf/reg-sub + :show-background-player-loading + (fn [db _] + (:show-background-player-loading db))) + +(rf/reg-sub + :loop-playback + (fn [db _] + (:loop-playback db))) + +(rf/reg-sub + :paused + (fn [db _] + (:paused db))) + +(rf/reg-sub + :volume-level + (fn [db _] + (:volume-level db))) + +(rf/reg-sub + :muted + (fn [db _] + (:muted db))) + +(rf/reg-sub + :elapsed-time + (fn [db _] + !elapsed-time)) diff --git a/src/frontend/tubo/player/views.cljs b/src/frontend/tubo/player/views.cljs new file mode 100644 index 0000000..4fc7994 --- /dev/null +++ b/src/frontend/tubo/player/views.cljs @@ -0,0 +1,182 @@ +(ns tubo.player.views + (:require + [re-frame.core :as rf] + [reagent.core :as r] + [reagent.dom :as rdom] + [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as modals] + [tubo.components.layout :as layout] + [tubo.components.player :as player] + [tubo.utils :as utils] + ["video.js" :as videojs] + ["videojs-mobile-ui"] + ["@silvermine/videojs-quality-selector" :as VideojsQualitySelector])) + +(defn audio + [!player] + (let [{:keys [stream]} @(rf/subscribe [:queue-stream]) + queue-pos @(rf/subscribe [:queue-pos])] + (r/create-class + {:component-did-mount + (fn [this] + (set! (.-onended (rdom/dom-node this)) + #(rf/dispatch [:queue/change-pos (inc queue-pos)])) + (when stream + (set! (.-src (rdom/dom-node this)) stream))) + :reagent-render + (fn [!player] + (let [!elapsed-time @(rf/subscribe [:elapsed-time]) + muted? @(rf/subscribe [:muted]) + volume-level @(rf/subscribe [:volume-level]) + loop-playback @(rf/subscribe [:loop-playback])] + [:audio + {:ref #(reset! !player %) + :loop (= loop-playback :stream) + :muted muted? + :on-loaded-data #(rf/dispatch [:player/start-in-background]) + :on-time-update #(reset! !elapsed-time (.-currentTime @!player)) + :on-pause #(rf/dispatch [:player/set-paused true]) + :on-play #(rf/dispatch [:player/set-paused false])}]))}))) + +(defn stream-metadata + [{:keys [thumbnail-url url name uploader-url uploader-name]}] + [:div.flex.items-center.lg:flex-1 + [:div + [layout/thumbnail thumbnail-url (rfe/href :stream-page nil {:url url}) + name nil :classes [:h-14 :py-2 "w-[70px]"]]] + [:div.flex.flex-col.px-2 + [:a.text-xs.line-clamp-1 + {:href (rfe/href :stream-page nil {:url url}) + :title name} + name] + [:a.text-xs.pt-2.text-neutral-600.dark:text-neutral-300.line-clamp-1 + {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name} + uploader-name]]]) + +(defn main-controls + [!player color] + (let [queue @(rf/subscribe [:queue]) + queue-pos @(rf/subscribe [:queue-pos]) + loading? @(rf/subscribe [:show-background-player-loading]) + !elapsed-time @(rf/subscribe [:elapsed-time]) + loop-playback @(rf/subscribe [:loop-playback]) + paused? @(rf/subscribe [:paused]) + player-ready? @(rf/subscribe [:player-ready])] + [:div.flex.flex-col.items-center.ml-auto + [:div.flex.justify-end + [player/loop-button loop-playback color] + [player/button + :icon [:i.fa-solid.fa-backward-step] + :on-click #(rf/dispatch [:queue/change-pos (dec queue-pos)]) + :disabled? (not (and queue (not= queue-pos 0)))] + [player/button + :icon [:i.fa-solid.fa-backward] + :on-click #(rf/dispatch [:player/seek (- @!elapsed-time 5)])] + [player/button + :icon (if (or (not loading?) player-ready?) + (if paused? + [:i.fa-solid.fa-play] + [:i.fa-solid.fa-pause]) + [layout/loading-icon color "lg:text-2xl"]) + :on-click #(rf/dispatch [:player/pause (not paused?)]) + :show-on-mobile? true + :extra-classes ["lg:text-2xl"]] + [player/button + :icon [:i.fa-solid.fa-forward] + :on-click #(rf/dispatch [:player/seek (+ @!elapsed-time 5)])] + [player/button + :icon [:i.fa-solid.fa-forward-step] + :on-click #(rf/dispatch [:queue/change-pos (inc queue-pos)]) + :disabled? (not (and queue (< (inc queue-pos) (count queue))))]] + [:div.hidden.lg:flex.items-center.text-sm + [:span.mx-2 + (if @!elapsed-time (utils/format-duration @!elapsed-time) "--:--")] + [:div.w-20.lg:w-64.mx-2.flex.items-center + [player/time-slider !player !elapsed-time color]] + [:span.mx-2 + (if player-ready? (utils/format-duration (.-duration @!player)) "--:--")]]])) + +(defn extra-controls + [!player {:keys [url uploader-url] :as stream} color] + (let [!menu-active? (r/atom nil)] + (fn [] + (let [muted? @(rf/subscribe [:muted]) + volume @(rf/subscribe [:volume-level]) + queue @(rf/subscribe [:queue]) + queue-pos @(rf/subscribe [:queue-pos]) + bookmarks @(rf/subscribe [:bookmarks]) + liked? (some #(= (:url %) url) (-> bookmarks first :items)) + bookmark #(rf/dispatch [:modals/open [modals/add-to-bookmark %]])] + [:div.flex.lg:justify-end.lg:flex-1 + [player/volume-slider !player volume muted? color] + [player/button + :icon [:i.fa-solid.fa-list] + :on-click #(rf/dispatch [:queue/show true]) + :show-on-mobile? true + :extra-classes [:!pl-4 :!pr-3]] + [layout/popover-menu !menu-active? + [{:label (if liked? "Remove favorite" "Favorite") + :icon [:i.fa-solid.fa-heart (when liked? {:style {:color color}})] + :on-click #(rf/dispatch [(if liked? :likes/remove :likes/add) stream])} + {:label "Play radio" + :icon [:i.fa-solid.fa-tower-cell] + :on-click #(rf/dispatch [:player/start-radio stream])} + {:label "Add current to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(bookmark stream)} + {:label "Add queue to playlist" + :icon [:i.fa-solid.fa-list] + :on-click #(bookmark queue)} + {:label "Remove from queue" + :icon [:i.fa-solid.fa-trash] + :on-click #(rf/dispatch [:queue/remove queue-pos])} + {:label "Show channel details" + :icon [:i.fa-solid.fa-user] + :on-click #(rf/dispatch [:navigate + {:name :channel-page + :params {} + :query {:url uploader-url}}])} + {:label "Close player" + :icon [:i.fa-solid.fa-close] + :on-click #(rf/dispatch [:player/dispose])}] + :menu-styles {:bottom "30px" :top nil :right "10px"} + :extra-classes [:pt-1 :!pl-4 :px-3]]])))) + +(defn background-player + [] + (let [!player @(rf/subscribe [:player]) + stream @(rf/subscribe [:queue-stream]) + show-player? @(rf/subscribe [:show-background-player]) + show-queue? @(rf/subscribe [:show-queue]) + dark-theme? @(rf/subscribe [:dark-theme]) + color (-> stream :service-id utils/get-service-color) + bg-color (str "rgba(" (if dark-theme? "23,23,23" "255,255,255") ",0.95)") + bg-image (str "linear-gradient(" bg-color "," bg-color "),url(" (:thumbnail-url stream) ")")] + (when show-player? + [:div.sticky.absolute.left-0.bottom-0.z-10.p-3.transition-all.ease-in + {:style + {:visibility (when show-queue? "hidden") + :opacity (if show-queue? 0 1) + :background-image bg-image + :background-size "cover" + :background-position "center" + :background-repeat "no-repeat"}} + [:div.flex.items-center.justify-between + [audio !player] + [stream-metadata stream] + [main-controls !player color] + [extra-controls !player stream color]]]))) + +(defn main-player + [options service-id] + (let [!player (atom nil)] + (r/create-class + {:component-did-mount + (fn [^videojs/VideoJsPlayer this] + (VideojsQualitySelector videojs) + (reset! !player (videojs (rdom/dom-node this) (clj->js options))) + (.on @!player "ready" #(.mobileUi ^videojs/VideoJsPlayer @!player)) + (.on @!player "play" #(rf/dispatch [:player/start-in-main !player options service-id]))) + :component-will-unmount #(when @!player (.dispose @!player)) + :reagent-render (fn [options] [:video-js.vjs-tubo])}))) diff --git a/src/frontend/tubo/playlist/events.cljs b/src/frontend/tubo/playlist/events.cljs new file mode 100644 index 0000000..730950e --- /dev/null +++ b/src/frontend/tubo/playlist/events.cljs @@ -0,0 +1,46 @@ +(ns tubo.playlist.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :playlist/fetch + (fn [{:keys [db]} [_ url on-success on-error params]] + (api/get-request (str "/playlists/" (js/encodeURIComponent url)) + on-success on-error params))) + +(rf/reg-event-db + :playlist/load-paginated + (fn [db [_ res]] + (-> db + (update-in [:playlist :related-streams] #(apply conj %1 %2) + (:related-streams (js->clj res :keywordize-keys true))) + (assoc-in [:playlist :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + :playlist/fetch-paginated + (fn [{:keys [db]} [_ url next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + {:fx [[:dispatch [:playlist/fetch url + [:playlist/load-paginated] [:bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}]]] + :db (assoc db :show-pagination-loading true)}))) + +(rf/reg-event-fx + :playlist/load-page + (fn [{:keys [db]} [_ res]] + (let [playlist-res (js->clj res :keywordize-keys true)] + {:db (assoc db :playlist playlist-res + :show-page-loading false) + :fx [[:dispatch [:services/fetch playlist-res]] + [:document-title (:name playlist-res)]]}))) + +(rf/reg-event-fx + :playlist/fetch-page + (fn [{:keys [db]} [_ url]] + {:fx [[:dispatch [:playlist/fetch url + [:playlist/load-page] [:bad-response]]]] + :db (assoc db :show-page-loading true)})) diff --git a/src/frontend/tubo/playlist/subs.cljs b/src/frontend/tubo/playlist/subs.cljs new file mode 100644 index 0000000..495e86d --- /dev/null +++ b/src/frontend/tubo/playlist/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.playlist.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :playlist + (fn [db _] + (:playlist db))) diff --git a/src/frontend/tubo/playlist/views.cljs b/src/frontend/tubo/playlist/views.cljs new file mode 100644 index 0000000..737fab4 --- /dev/null +++ b/src/frontend/tubo/playlist/views.cljs @@ -0,0 +1,40 @@ +(ns tubo.playlist.views + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as modals] + [tubo.components.items :as items] + [tubo.components.layout :as layout])) + +(defn playlist + [{{:keys [url]} :query-params}] + (let [!menu-active? (r/atom nil)] + (fn [] + (let [{:keys [id name playlist-type thumbnail-url banner-url next-page + uploader-name uploader-url related-streams + stream-count]} @(rf/subscribe [:playlist]) + next-page-url (:url next-page) + scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] + (when scrolled-to-bottom? + (rf/dispatch [:playlist/fetch-paginated url next-page-url])) + [layout/content-container + [:div.flex.flex-col.justify-center + [layout/content-header name + (when related-streams + [layout/popover-menu !menu-active? + [{:label "Add to queue" + :icon [:i.fa-solid.fa-headphones] + :on-click #(rf/dispatch [:queue/add-n related-streams])} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark related-streams]])}]])] + [:div.flex.items-center.justify-between.my-4.gap-x-4 + [:div.flex.items-center + [layout/uploader-avatar playlist] + [:a.line-clamp-1.ml-2 + {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name} + uploader-name]] + [:span.text-sm.whitespace-nowrap (str stream-count " streams")]]] + [items/related-streams related-streams next-page-url]])))) diff --git a/src/frontend/tubo/queue/events.cljs b/src/frontend/tubo/queue/events.cljs new file mode 100644 index 0000000..296dc37 --- /dev/null +++ b/src/frontend/tubo/queue/events.cljs @@ -0,0 +1,75 @@ +(ns tubo.queue.events + (:require + [re-frame.core :as rf])) + +(rf/reg-event-fx + :queue/show + (fn [{:keys [db]} [_ show?]] + {:db (assoc db :show-queue show?) + :body-overflow show?})) + +(rf/reg-event-fx + :queue/add + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ stream]] + (let [updated-db (update db :queue conj stream)] + {:db updated-db + :store (assoc store :queue (:queue updated-db))}))) + +(rf/reg-event-fx + :queue/add-n + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ streams]] + {:db (assoc db :show-background-player true) + :store (assoc store :show-background-player true) + :fx (conj (map (fn [stream] [:dispatch [:queue/add stream]]) streams) + [:dispatch [:player/fetch-stream (-> streams first :url) + (count (:queue db)) (= (count (:queue db)) 0)]])})) + +(rf/reg-event-fx + :queue/remove + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ pos]] + (let [updated-db (update db :queue #(into (subvec % 0 pos) (subvec % (inc pos)))) + queue-pos (:queue-pos db) + queue-length (count (:queue updated-db))] + {:db updated-db + :store (assoc store :queue (:queue updated-db)) + :fx (cond + (and (not (= queue-length 0)) + (or (< pos queue-pos) + (= pos queue-pos) + (= queue-pos queue-length))) + [[:dispatch [:queue/change-pos + (cond + (= pos queue-length) 0 + (= pos queue-pos) pos + :else (dec queue-pos))]]] + (= (count (:queue updated-db)) 0) + [[:dispatch [:player/dispose]] + [:dispatch [:queue/show false]]] + :else [])}))) + +(rf/reg-event-fx + :queue/change-pos + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ i]] + (let [idx (if (< i (count (:queue db))) + i + (when (= (:loop-playback db) :playlist) 0)) + stream (get (:queue db) idx)] + (when stream + {:db (-> db + (assoc :player-ready false) + (assoc :queue-pos idx) + (assoc-in [:queue idx :stream] "")) + :store (assoc store :queue-pos idx) + :fx [[:dispatch [:player/fetch-stream (:url stream) idx true]]]})))) + +(rf/reg-event-fx + :queue/change-stream-source + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ src idx]] + (let [update-entry #(assoc-in % [:queue idx :stream] src)] + {:db (update-entry db) + :store (update-entry store)}))) diff --git a/src/frontend/tubo/queue/subs.cljs b/src/frontend/tubo/queue/subs.cljs new file mode 100644 index 0000000..f173ff7 --- /dev/null +++ b/src/frontend/tubo/queue/subs.cljs @@ -0,0 +1,25 @@ +(ns tubo.queue.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :queue + (fn [db _] + (:queue db))) + +(rf/reg-sub + :queue-pos + (fn [db _] + (:queue-pos db))) + +(rf/reg-sub + :show-queue + (fn [db _] + (:show-queue db))) + +(rf/reg-sub + :queue-stream + (fn [_] + [(rf/subscribe [:queue]) (rf/subscribe [:queue-pos])]) + (fn [[queue pos] _] + (and (not-empty queue) (< pos (count queue)) (nth queue pos)))) diff --git a/src/frontend/tubo/queue/views.cljs b/src/frontend/tubo/queue/views.cljs new file mode 100644 index 0000000..d519da7 --- /dev/null +++ b/src/frontend/tubo/queue/views.cljs @@ -0,0 +1,152 @@ +(ns tubo.queue.views + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as modals] + [tubo.components.items :as items] + [tubo.components.layout :as layout] + [tubo.components.player :as player] + [tubo.utils :as utils])) + +(defn item-metadata + [{:keys [uploader-name name service-id duration thumbnail-url]} queue-pos i] + [:div.flex.cursor-pointer.py-2 + {:class (when (= i queue-pos) ["bg-neutral-200" "dark:bg-stone-800"]) + :on-click #(rf/dispatch [:queue/change-pos i])} + [:div.flex.items-center.justify-center.min-w-20.w-20.xs:min-w-28.xs:w-28 + [:span.font-bold.text-neutral-400.text-sm + (if (= i queue-pos) [:i.fa-solid.fa-play] (inc i))]] + [:div.w-36 + [layout/thumbnail thumbnail-url nil name duration :classes "h-16"]] + [:div.flex.flex-col.pl-4.pr-12.w-full + [:h1.line-clamp-1.w-fit {:title name} name] + [:div.text-neutral-600.dark:text-neutral-300.text-sm.flex.flex-col.xs:flex-row + [:span.line-clamp-1 {:title uploader-name} uploader-name] + [:span.px-2.hidden.xs:inline-block + {:dangerouslySetInnerHTML {:__html "•"}}] + [:span (utils/get-service-name service-id)]]]]) + +(defn popover + [{:keys [url service-id uploader-url] :as item} i menu-active? bookmarks] + (let [liked? (some #(= (:url %) url) (-> bookmarks first :items))] + [:div.absolute.right-0.top-0.min-h-full.flex.items-center + [layout/popover-menu menu-active? + [{:label (if liked? "Remove favorite" "Favorite") + :icon [:i.fa-solid.fa-heart (when liked? {:style {:color (utils/get-service-color service-id)}})] + :on-click #(rf/dispatch [(if liked? :likes/remove :likes/add) item])} + {:label "Play radio" + :icon [:i.fa-solid.fa-tower-cell] + :on-click #(rf/dispatch [:player/start-radio item])} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark item]])} + {:label "Remove from queue" + :icon [:i.fa-solid.fa-trash] + :on-click #(rf/dispatch [:queue/remove i])} + {:label "Show channel details" + :icon [:i.fa-solid.fa-user] + :on-click #(rf/dispatch [:navigate + {:name :channel-page + :params {} + :query {:url uploader-url}}])}] + :menu-styles {:right "40px"} + :extra-classes [:px-7 :py-2]]])) + +(defn queue-item + [item queue-pos i bookmarks] + (let [!menu-active? (r/atom false)] + (fn [item queue-pos i bookmarks] + [:div.relative.w-full + {:ref #(when (= queue-pos i) (rf/dispatch [:scroll-into-view %]))} + [item-metadata item queue-pos i] + [popover item i !menu-active? bookmarks]]))) + +(defn queue-metadata + [{:keys [url name uploader-url uploader-name]}] + [:div.flex.flex-col.py-2 + [:a.text-md.line-clamp-1.w-fit + {:href (rfe/href :stream-page nil {:url url}) + :title name} + name] + [:a.text-sm.pt-2.text-neutral-600.dark:text-neutral-300.line-clamp-1.w-fit + {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name} + uploader-name]]) + +(defn main-controls + [{:keys [service-id]} queue queue-pos] + (let [loop-playback @(rf/subscribe [:loop-playback]) + service-color (and service-id (utils/get-service-color service-id)) + !player @(rf/subscribe [:player]) + loading? @(rf/subscribe [:show-background-player-loading]) + player-ready? @(rf/subscribe [:player-ready]) + paused? @(rf/subscribe [:paused]) + !elapsed-time @(rf/subscribe [:elapsed-time]) + queue @(rf/subscribe [:queue]) + queue-pos @(rf/subscribe [:queue-pos])] + [:<> + [:div.flex.flex-auto.py-2.w-full.items-center.text-sm + [:span.mr-4 (if @!elapsed-time (utils/format-duration @!elapsed-time) "--:--")] + [player/time-slider !player !elapsed-time service-color] + [:span.ml-4 (if player-ready? (utils/format-duration (.-duration @!player)) "--:--")]] + [:div.flex.justify-center.items-center + [player/loop-button loop-playback service-color true] + [player/button + :icon [:i.fa-solid.fa-backward-step] + :on-click #(rf/dispatch [:queue/change-pos (dec queue-pos)]) + :disabled? (not (and queue (not= queue-pos 0))) + :extra-classes [:text-xl] + :show-on-mobile? true] + [player/button + :icon [:i.fa-solid.fa-backward] + :on-click #(rf/dispatch [:player/seek (- @!elapsed-time 5)]) + :extra-classes [:text-xl] + :show-on-mobile? true] + [player/button + :icon (if (or (not loading?) player-ready?) + (if paused? + [:i.fa-solid.fa-play] + [:i.fa-solid.fa-pause]) + [layout/loading-icon service-color :text-3xl]) + :on-click #(rf/dispatch [:player/pause (not paused?)]) + :extra-classes [:text-3xl] + :show-on-mobile? true] + [player/button + :icon [:i.fa-solid.fa-forward] + :on-click #(rf/dispatch [:player/seek (+ @!elapsed-time 5)]) + :extra-classes [:text-xl] + :show-on-mobile? true] + [player/button + :icon [:i.fa-solid.fa-forward-step] + :on-click #(rf/dispatch [:queue/change-pos (inc queue-pos)]) + :disabled? (not (and queue (< (inc queue-pos) (count queue)))) + :extra-classes [:text-xl] + :show-on-mobile? true] + [player/button + :icon [:i.fa-solid.fa-list] + :on-click #(rf/dispatch [:queue/show false]) + :show-on-mobile? true + :extra-classes [:pl-4 :pr-3]]]])) + +(defn queue + [] + (let [show-queue @(rf/subscribe [:show-queue]) + stream @(rf/subscribe [:queue-stream]) + bookmarks @(rf/subscribe [:bookmarks]) + queue-pos @(rf/subscribe [:queue-pos]) + queue @(rf/subscribe [:queue])] + [:div.fixed.flex.flex-col.items-center.min-w-full.w-full.z-10.backdrop-blur + {:class ["dark:bg-neutral-900/90" "bg-neutral-100/90" + "min-h-[calc(100dvh-56px)]" "h-[calc(100dvh-56px)]" + (when-not show-queue "hidden") + (if show-queue "opacity-1" "opacity-0")]} + [layout/focus-overlay #(rf/dispatch [:queue/show false]) show-queue true] + [:div.z-20.w-full.flex.flex-col.flex-auto.h-full.lg:pt-5 + {:class ["lg:w-4/5" "xl:w-3/5"]} + [:div.flex.flex-col.overflow-y-auto.flex-auto.gap-y-1 + (for [[i item] (map-indexed vector queue)] + ^{:key i} [queue-item item queue-pos i bookmarks])] + [:div.flex.flex-col.py-4.shrink-0.px-5 + [queue-metadata stream] + [main-controls stream queue queue-pos]]]])) diff --git a/src/frontend/tubo/routes.cljs b/src/frontend/tubo/routes.cljs index 337a6d6..d721429 100644 --- a/src/frontend/tubo/routes.cljs +++ b/src/frontend/tubo/routes.cljs @@ -3,62 +3,61 @@ [reitit.frontend :as ref] [reitit.frontend.easy :as rfe] [re-frame.core :as rf] - [tubo.events :as events] - [tubo.views.channel :as channel] - [tubo.views.kiosk :as kiosk] - [tubo.views.playlist :as playlist] - [tubo.views.bookmarks :as bookmarks] - [tubo.views.search :as search] - [tubo.views.settings :as settings] - [tubo.views.stream :as stream])) + [tubo.channel.views :as channel] + [tubo.kiosks.views :as kiosk] + [tubo.playlist.views :as playlist] + [tubo.bookmarks.views :as bookmarks] + [tubo.search.views :as search] + [tubo.settings.views :as settings] + [tubo.stream.views :as stream])) (def router (ref/router - [["/" {:view kiosk/kiosk - :name ::home - :controllers [{:start #(rf/dispatch [::events/get-homepage])}]}] - ["/search" {:view search/search - :name ::search + [["/" {:view kiosk/kiosk + :name :homepage + :controllers [{:start #(rf/dispatch [:fetch-homepage])}]}] + ["/search" {:view search/search + :name :search-page :controllers [{:parameters {:query [:q :serviceId]} - :start (fn [{{:keys [serviceId q]} :query}] - (rf/dispatch [::events/get-search-page serviceId q])) - :stop #(rf/dispatch [::events/show-search-form false])}]}] - ["/stream" {:view stream/stream - :name ::stream + :start (fn [{{:keys [serviceId q]} :query}] + (rf/dispatch [:search/fetch-page serviceId q])) + :stop #(rf/dispatch [:search/show-form false])}]}] + ["/stream" {:view stream/stream + :name :stream-page :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-stream-page url]))}]}] - ["/channel" {:view channel/channel - :name ::channel + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [:stream/fetch-page url]))}]}] + ["/channel" {:view channel/channel + :name :channel-page :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-channel-page url]))}]}] - ["/playlist" {:view playlist/playlist - :name ::playlist + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [:channel/fetch-page url]))}]}] + ["/playlist" {:view playlist/playlist + :name :playlist-page :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-playlist-page url]))}]}] - ["/kiosk" {:view kiosk/kiosk - :name ::kiosk + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [:playlist/fetch-page url]))}]}] + ["/kiosk" {:view kiosk/kiosk + :name :kiosk-page :controllers [{:parameters {:query [:kioskId :serviceId]} - :start (fn [{{:keys [serviceId kioskId]} :query}] - (rf/dispatch [::events/get-kiosk-page serviceId kioskId]))}]}] - ["/settings" {:view settings/settings-page - :name ::settings - :controllers [{:start #(rf/dispatch [::events/get-settings-page])}]}] - ["/bookmark" {:view bookmarks/bookmark-page - :name ::bookmark + :start (fn [{{:keys [serviceId kioskId]} :query}] + (rf/dispatch [:kiosks/fetch-page serviceId kioskId]))}]}] + ["/settings" {:view settings/settings + :name :settings-page + :controllers [{:start #(rf/dispatch [:settings/fetch-page])}]}] + ["/bookmark" {:view bookmarks/bookmark + :name :bookmark-page :controllers [{:parameters {:query [:id]} - :start (fn [{{:keys [id]} :query}] - (rf/dispatch [::events/get-bookmark-page id]))}]}] - ["/bookmarks" {:view bookmarks/bookmarks-page - :name ::bookmarks - :controllers [{:start #(rf/dispatch [::events/get-bookmarks-page])}]}]])) + :start (fn [{{:keys [id]} :query}] + (rf/dispatch [:bookmark/fetch-page id]))}]}] + ["/bookmarks" {:view bookmarks/bookmarks + :name :bookmarks-page + :controllers [{:start #(rf/dispatch [:bookmarks/fetch-page])}]}]])) (defn on-navigate [new-match] (when new-match - (rf/dispatch [::events/navigated new-match]))) + (rf/dispatch [:navigated new-match]))) (defn start-routes! [] diff --git a/src/frontend/tubo/search/events.cljs b/src/frontend/tubo/search/events.cljs new file mode 100644 index 0000000..e09e5b3 --- /dev/null +++ b/src/frontend/tubo/search/events.cljs @@ -0,0 +1,64 @@ +(ns tubo.search.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :search/fetch + (fn [{:keys [db]} [_ service-id on-success on-error params]] + (api/get-request (str "/services/" service-id "/search") + on-success on-error params))) + +(rf/reg-event-fx + :search/load-page + (fn [{:keys [db]} [_ res]] + (let [search-res (js->clj res :keywordize-keys true)] + {:db (assoc db :search-results search-res + :show-page-loading false) + :fx [[:dispatch [:services/fetch search-res]]]}))) + +(rf/reg-event-fx + :search/fetch-page + (fn [{:keys [db]} [_ service-id query]] + {:db (assoc db + :show-page-loading true + :show-search-form true) + :fx [[:dispatch [:search/fetch service-id + [:search/load-page] [:bad-response] {:q query}]] + [:document-title (str "Search for \"" query "\"")]]})) + +(rf/reg-event-db + :search/load-paginated + (fn [db [_ res]] + (let [search-res (js->clj res :keywordize-keys true)] + (if (empty? (:items search-res)) + (-> db + (assoc-in [:search-results :next-page] nil) + (assoc :show-pagination-loading false)) + (-> db + (update-in [:search-results :items] #(apply conj %1 %2) + (:items search-res)) + (assoc-in [:search-results :next-page] (:next-page search-res)) + (assoc :show-pagination-loading false)))))) + +(rf/reg-event-fx + :search/fetch-paginated + (fn [{:keys [db]} [_ query id next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + {:fx [[:dispatch [:search/fetch id + [:search/load-paginated] [:bad-response] + {:q query + :nextPage (js/encodeURIComponent next-page-url)}]]] + :db (assoc db :show-pagination-loading true)}))) + +(rf/reg-event-db + :search/show-form + (fn [db [_ show?]] + (when-not (= (-> db :current-match :path) "search") + (assoc db :show-search-form show?)))) + +(rf/reg-event-db + :search/change-query + (fn [db [_ res]] + (assoc db :search-query res))) diff --git a/src/frontend/tubo/search/subs.cljs b/src/frontend/tubo/search/subs.cljs new file mode 100644 index 0000000..59c69f5 --- /dev/null +++ b/src/frontend/tubo/search/subs.cljs @@ -0,0 +1,18 @@ +(ns tubo.search.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :search-results + (fn [db _] + (:search-results db))) + +(rf/reg-sub + :search-query + (fn [db _] + (:search-query db))) + +(rf/reg-sub + :show-search-form + (fn [db _] + (:show-search-form db))) diff --git a/src/frontend/tubo/search/views.cljs b/src/frontend/tubo/search/views.cljs new file mode 100644 index 0000000..58764f9 --- /dev/null +++ b/src/frontend/tubo/search/views.cljs @@ -0,0 +1,61 @@ +(ns tubo.search.views + (:require + [re-frame.core :as rf] + [reagent.core :as r] + [reitit.frontend.easy :as rfe] + [tubo.components.items :as items] + [tubo.components.layout :as layout])) + +(defn search-form [] + (let [!query (r/atom "") + !input (r/atom nil)] + (fn [] + (let [search-query @(rf/subscribe [:search-query]) + show-search-form? @(rf/subscribe [:show-search-form]) + service-id @(rf/subscribe [:service-id])] + [:form.relative.flex.items-center.text-white.ml-4 + {:class (when-not show-search-form? "hidden") + :on-submit #(do (.preventDefault %) + (when-not (empty? @!query) + (rf/dispatch [:navigate + {:name :search-page + :params {} + :query {:q search-query + :serviceId service-id}}])))} + [:div.flex + [:button.mx-2 + {:on-click #(rf/dispatch [:search/show-form false]) :type "button"} + [:i.fa-solid.fa-arrow-left]] + [:input.w-full.sm:w-96.bg-transparent.py-2.pl-0.pr-6.mx-2.border-none.focus:ring-transparent.placeholder-white + {:type "text" + :ref #(do (reset! !input %) + (when % + (.focus %))) + :default-value @!query + :on-change #(let [input (.. % -target -value)] + (when-not (empty? input) + (rf/dispatch [:search/change-query input])) + (reset! !query input)) + :placeholder "Search"}] + [:button.mx-4 {:type "submit"} [:i.fa-solid.fa-search]] + [:button.mx-4.text-xs.absolute.right-8.top-3 + {:type "button" + :on-click #(when @!input + (set! (.-value @!input) "") + (reset! !query "") + (.focus @!input)) + :class (when (empty? @!query) :invisible)} + [:i.fa-solid.fa-circle-xmark]]]])))) + +(defn search + [{{:keys [q serviceId]} :query-params}] + (let [{:keys [items next-page] + :as search-results} @(rf/subscribe [:search-results]) + next-page-url (:url next-page) + services @(rf/subscribe [:services]) + service-id (or @(rf/subscribe [:service-id]) serviceId) + scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] + (when scrolled-to-bottom? + (rf/dispatch [:search/fetch-paginated q service-id next-page-url])) + [layout/content-container + [items/related-streams items next-page-url]])) diff --git a/src/frontend/tubo/services/events.cljs b/src/frontend/tubo/services/events.cljs new file mode 100644 index 0000000..7fb1789 --- /dev/null +++ b/src/frontend/tubo/services/events.cljs @@ -0,0 +1,29 @@ +(ns tubo.services.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :services/fetch + (fn [{:keys [db]} [_ {:keys [service-id]}]] + {:db db + :fx [[:dispatch [:services/change-id service-id]] + [:dispatch [:kiosks/fetch-all service-id + [:kiosks/load] [:bad-response]]]]})) + +(rf/reg-event-fx + :services/change-id + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ service-id]] + {:db (assoc db :service-id service-id) + :store (assoc store :service-id service-id)})) + +(rf/reg-event-fx + :services/fetch-all + (fn [{:keys [db]} [_ on-success on-error]] + (api/get-request "/services" on-success on-error))) + +(rf/reg-event-db + :services/load + (fn [db [_ res]] + (assoc db :services (js->clj res :keywordize-keys true)))) diff --git a/src/frontend/tubo/services/subs.cljs b/src/frontend/tubo/services/subs.cljs new file mode 100644 index 0000000..84363d0 --- /dev/null +++ b/src/frontend/tubo/services/subs.cljs @@ -0,0 +1,28 @@ +(ns tubo.services.subs + (:require + [re-frame.core :as rf] + [tubo.utils :as utils])) + +(rf/reg-sub + :service-id + (fn [db _] + (:service-id db))) + +(rf/reg-sub + :service-color + (fn [_] + (rf/subscribe [:service-id])) + (fn [id _] + (and id (utils/get-service-color id)))) + +(rf/reg-sub + :service-name + (fn [_] + (rf/subscribe [:service-id])) + (fn [id _] + (and id (utils/get-service-name id)))) + +(rf/reg-sub + :services + (fn [db _] + (:services db))) diff --git a/src/frontend/tubo/services/views.cljs b/src/frontend/tubo/services/views.cljs new file mode 100644 index 0000000..b27dca0 --- /dev/null +++ b/src/frontend/tubo/services/views.cljs @@ -0,0 +1,20 @@ +(ns tubo.services.views + (:require + [re-frame.core :as rf])) + +(defn services-dropdown + [services service-id service-color] + [:div.relative.flex.flex-col.items-center-justify-center.text-white.px-2 + {:style {:background service-color}} + [:div.w-full.box-border.z-10.lg:z-0 + [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.font-bold.w-full + {:on-change #(rf/dispatch [:kiosks/change-page (js/parseInt (.. % -target -value))]) + :value service-id + :style {:background :transparent}} + (when services + (for [[i service] (map-indexed vector services)] + ^{:key i} [:option.text-white.bg-neutral-900.border-none + {:value (:id service)} + (-> service :info :name)]))]] + [:div.flex.items-center.justify-end.absolute.min-h-full.top-0.right-4.lg:right-0.z-0 + [:i.fa-solid.fa-caret-down]]]) diff --git a/src/frontend/tubo/settings/events.cljs b/src/frontend/tubo/settings/events.cljs new file mode 100644 index 0000000..e2a5643 --- /dev/null +++ b/src/frontend/tubo/settings/events.cljs @@ -0,0 +1,60 @@ +(ns tubo.settings.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :settings/change + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ key val]] + {:db (assoc-in db [:settings key] val) + :store (assoc store key val)})) + +(rf/reg-event-fx + :settings/load-kiosks + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ service-name service-id res]] + (let [kiosks-res (js->clj res :keywordize-keys true) + default-service-kiosk (-> db :settings :default-service :default-kiosk) + default-kiosk (if (some #(= % default-service-kiosk) + (:available-kiosks kiosks-res)) + default-service-kiosk + (:default-kiosk kiosks-res))] + {:db (update-in db [:settings :default-service] assoc + :id service-name + :service-id service-id + :available-kiosks (:available-kiosks kiosks-res) + :default-kiosk default-kiosk) + :store (update-in store [:default-service] assoc + :id service-name + :service-id service-id + :available-kiosks (:available-kiosks kiosks-res) + :default-kiosk default-kiosk)}))) + +(rf/reg-event-fx + :settings/change-service + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ val]] + (let [service-id (-> (filter #(= val (-> % :info :name)) (:services db)) + first + :id)] + (api/get-request (str "/services/" service-id "/kiosks") + [:settings/load-kiosks val service-id] + [:bad-response])))) + +(rf/reg-event-fx + :settings/change-kiosk + [(rf/inject-cofx :store)] + (fn [{:keys [db store]} [_ val]] + {:db (assoc-in db [:settings :default-service :default-kiosk] val) + :store (assoc-in store [:default-service :default-kiosk] val)})) + +(rf/reg-event-fx + :settings/fetch-page + (fn [{:keys [db]} _] + (let [id (-> db :settings :default-service :id) + service-id (-> db :settings :default-service :service-id)] + (assoc + (api/get-request (str "/services/" service-id "/kiosks") + [:settings/load-kiosks id service-id] [:bad-response]) + :document-title "Settings")))) diff --git a/src/frontend/tubo/settings/subs.cljs b/src/frontend/tubo/settings/subs.cljs new file mode 100644 index 0000000..d6aaadf --- /dev/null +++ b/src/frontend/tubo/settings/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.settings.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :settings + (fn [db _] + (:settings db))) diff --git a/src/frontend/tubo/settings/views.cljs b/src/frontend/tubo/settings/views.cljs new file mode 100644 index 0000000..ef1a1c5 --- /dev/null +++ b/src/frontend/tubo/settings/views.cljs @@ -0,0 +1,35 @@ +(ns tubo.settings.views + (:require + [re-frame.core :as rf] + [tubo.components.layout :as layout])) + +(defn boolean-input + [label key value] + [layout/boolean-input label key value + #(rf/dispatch [:settings/change key (not value)])]) + +(defn select-input + [label key value options on-change] + [layout/select-input label key value options + (or on-change + #(rf/dispatch [:settings/change key (.. % -target -value)]))]) + +(defn settings + [] + (let [{:keys [theme themes show-comments show-related show-description + default-service]} @(rf/subscribe [:settings]) + service-color @(rf/subscribe [:service-color]) + services @(rf/subscribe [:services])] + [layout/content-container + [layout/content-header "Settings"] + [:form.flex.flex-wrap.py-4 + [select-input "Theme" :theme theme #{:auto :light :dark}] + [select-input "Default service" :default-service (:id default-service) + (map #(-> % :info :name) services) + #(rf/dispatch [:settings/change (.. % -target -value)])] + [select-input "Default kiosk" :default-service + (:default-kiosk default-service) (:available-kiosks default-service) + #(rf/dispatch [:settings/change (.. % -target -value)])] + [boolean-input "Show description" :show-description show-description] + [boolean-input "Show comments" :show-comments show-comments] + [boolean-input "Show related videos" :show-related show-related]]])) diff --git a/src/frontend/tubo/stream/events.cljs b/src/frontend/tubo/stream/events.cljs new file mode 100644 index 0000000..77737d2 --- /dev/null +++ b/src/frontend/tubo/stream/events.cljs @@ -0,0 +1,32 @@ +(ns tubo.stream.events + (:require + [re-frame.core :as rf] + [tubo.api :as api])) + +(rf/reg-event-fx + :stream/fetch + (fn [{:keys [db]} [_ url on-success on-error]] + (api/get-request (str "/streams/" (js/encodeURIComponent url)) + on-success on-error))) + +(rf/reg-event-fx + :stream/load-page + (fn [{:keys [db]} [_ res]] + (let [stream-res (js->clj res :keywordize-keys true)] + {:db (assoc db :stream stream-res + :show-page-loading false) + :fx [(when (and (-> db :settings :show-comments)) + [:dispatch [:comments/fetch-page (:url stream-res)]]) + [:dispatch [:services/fetch stream-res]] + [:document-title (:name stream-res)]]}))) + +(rf/reg-event-fx + :stream/fetch-page + (fn [{:keys [db]} [_ url]] + {:fx [[:dispatch [:stream/fetch url [:stream/load-page] [:bad-response]]]] + :db (assoc db :show-page-loading true)})) + +(rf/reg-event-db + :stream/toggle-layout + (fn [db [_ layout]] + (assoc-in db [:stream layout] (not (-> db :stream layout))))) diff --git a/src/frontend/tubo/stream/subs.cljs b/src/frontend/tubo/stream/subs.cljs new file mode 100644 index 0000000..d7d549b --- /dev/null +++ b/src/frontend/tubo/stream/subs.cljs @@ -0,0 +1,8 @@ +(ns tubo.stream.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :stream + (fn [db _] + (:stream db))) diff --git a/src/frontend/tubo/stream/views.cljs b/src/frontend/tubo/stream/views.cljs new file mode 100644 index 0000000..83b04e6 --- /dev/null +++ b/src/frontend/tubo/stream/views.cljs @@ -0,0 +1,174 @@ +(ns tubo.stream.views + (:require + [reagent.core :as r] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.bookmarks.modals :as modals] + [tubo.comments.views :as comments] + [tubo.components.items :as items] + [tubo.components.layout :as layout] + [tubo.player.views :as player] + [tubo.utils :as utils])) + +(def player-elements + ["PlayToggle" "ProgressControl" "VolumePanel" "CurrentTimeDisplay" + "TimeDivider" "DurationDisplay" "Spacer" "QualitySelector" + "PlaybackRateMenuButton" "FullscreenToggle"]) + +(defn get-player-sources + [available-streams] + (reverse (map (fn [{:keys [content format resolution averageBitrate]}] + {:src content + :type "video/mp4" + :label (str (or resolution "audio-only") " " + format + (when-not resolution + (str " " averageBitrate "kbit/s")))}) + available-streams))) + +(defn player + [{:keys [thumbnail-url audio-streams video-streams service-id]}] + (let [page-loading? @(rf/subscribe [:show-page-loading])] + (when-not page-loading? + [:div.flex.flex-col.flex-auto.items-center.xl:py-6.!pb-0 + [:div.flex.flex-col.flex-auto.w-full {:class ["xl:w-3/5"]} + [:div.flex.justify-center.relative + {:class "h-[300px] md:h-[450px] lg:h-[600px]"} + [player/main-player + {:sources (get-player-sources (into audio-streams video-streams)) + :poster thumbnail-url + :controls true + :controlBar {:children player-elements} + :preload "metadata" + :responsive true + :fill true + :playbackRates [0.5 1 1.5 2]} + service-id]]]]))) + +(defn metadata-popover + [_] + (let [!menu-active? (r/atom nil)] + (fn [{:keys [service-id url] :as stream}] + (let [bookmarks @(rf/subscribe [:bookmarks]) + liked? (some #(= (:url %) url) (-> bookmarks first :items))] + [layout/popover-menu !menu-active? + [{:label "Add to queue" + :icon [:i.fa-solid.fa-headphones] + :on-click #(rf/dispatch [:player/switch-to-background stream])} + {:label "Play radio" + :icon [:i.fa-solid.fa-tower-cell] + :on-click #(rf/dispatch [:player/start-radio stream])} + {:label (if liked? "Remove favorite" "Favorite") + :icon (if liked? + [:i.fa-solid.fa-heart + {:style {:color (utils/get-service-color service-id)}}] + [:i.fa-solid.fa-heart]) + :on-click #(rf/dispatch [(if liked? :likes/remove :likes/add) stream true])} + {:label "Original" + :link {:route url :external? true} + :icon [:i.fa-solid.fa-external-link-alt]} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark stream]])}]])))) + +(defn metadata-uploader + [{:keys [uploader-url uploader-name subscriber-count] :as stream}] + [:div.flex.items-center + [layout/uploader-avatar stream] + [:div.mx-3 + [:a.line-clamp-1.font-semibold + {:href (rfe/href :channel-page nil {:url uploader-url}) + :title uploader-name} + uploader-name] + (when subscriber-count + [:div.flex.my-2.items-center + [:i.fa-solid.fa-users.text-xs] + [:p.mx-2 (utils/format-quantity subscriber-count)]])]]) + +(defn metadata-stats + [{:keys [view-count like-count dislike-count upload-date]}] + [:div.flex.flex-col.items-end.flex-auto.justify-center + (when view-count + [:div.sm:text-base.text-sm.mb-1 + [:i.fa-solid.fa-eye] + [:span.ml-2 (.toLocaleString view-count)]]) + [:div.flex + (when like-count + [:div.items-center.sm:text-base.text-sm + [:i.fa-solid.fa-thumbs-up] + [:span.ml-2 (.toLocaleString like-count)]]) + (when dislike-count + [:div.ml-2.items-center.sm:text-base.text-sm + [:i.fa-solid.fa-thumbs-down] + [:span.ml-2 dislike-count]])] + (when upload-date + [:div.sm:text-base.text-sm.mt-1.whitespace-nowrap + [:i.fa-solid.fa-calendar] + [:span.ml-2 (utils/format-date-string upload-date)]])]) + +(defn metadata + [{:keys [name] :as stream}] + [:<> + [:div.flex.items-center.justify-between.pt-4.my-3 + [:h1.text-lg.sm:text-2xl.font-bold.line-clamp-1 {:title name} name] + [metadata-popover stream]] + [:div.flex.justify-between.py-2.flex-nowrap + [metadata-uploader stream] + [metadata-stats stream]]]) + +(defn description + [{:keys [description show-description]}] + (let [show? (:show-description @(rf/subscribe [:settings]))] + (when (and show? (not (empty? description))) + [layout/show-more-container show-description description + #(rf/dispatch [:stream/toggle-layout :show-description])]))) + +(defn comments + [{:keys [comments-page show-comments show-comments-loading url] :as stream}] + (let [show? (:show-comments @(rf/subscribe [:settings])) + service-color @(rf/subscribe [:service-color])] + (when (and comments-page (not (empty? (:comments comments-page))) show?) + [layout/accordeon + {:label "Comments" + :on-open #(if show-comments + (rf/dispatch [:stream/toggle-layout :show-comments]) + (if comments-page + (rf/dispatch [:stream/toggle-layout :show-comments]) + (rf/dispatch [:comments/fetch-page url]))) + :open? show-comments + :left-icon "fa-solid fa-comments"} + (if show-comments-loading + [layout/loading-icon service-color "text-2xl"] + (when (and show-comments comments-page) + [comments/comments comments-page stream]))]))) + +(defn suggested + [_] + (let [!menu-active? (r/atom nil)] + (fn [{:keys [related-streams show-related]}] + (let [show? (:show-related @(rf/subscribe [:settings]))] + (when (and show? (not (empty? related-streams))) + [layout/accordeon + {:label "Suggested" + :on-open #(rf/dispatch [:stream/toggle-layout :show-related]) + :open? (not show-related) + :left-icon "fa-solid fa-list" + :right-button [layout/popover-menu !menu-active? + [{:label "Add to queue" + :icon [:i.fa-solid.fa-headphones] + :on-click #(rf/dispatch [:queue/add-n related-streams])} + {:label "Add to playlist" + :icon [:i.fa-solid.fa-plus] + :on-click #(rf/dispatch [:modals/open [modals/add-to-bookmark related-streams]])}]]} + [items/related-streams related-streams nil]]))))) + +(defn stream + [] + (let [stream @(rf/subscribe [:stream])] + [:<> + [player stream] + [layout/content-container + [metadata stream] + [description stream] + [comments stream] + [suggested stream]]])) diff --git a/src/frontend/tubo/subs.cljs b/src/frontend/tubo/subs.cljs index dd9ad89..2dea707 100644 --- a/src/frontend/tubo/subs.cljs +++ b/src/frontend/tubo/subs.cljs @@ -2,7 +2,19 @@ (:require [reagent.core :as r] [re-frame.core :as rf] - [tubo.utils :as utils])) + [tubo.utils :as utils] + [tubo.bookmarks.subs] + [tubo.channel.subs] + [tubo.kiosks.subs] + [tubo.modals.subs] + [tubo.notifications.subs] + [tubo.player.subs] + [tubo.playlist.subs] + [tubo.queue.subs] + [tubo.search.subs] + [tubo.services.subs] + [tubo.settings.subs] + [tubo.stream.subs])) (defonce !is-window-visible (let [a (r/atom true)] @@ -12,22 +24,24 @@ (defonce !scroll-distance (let [a (r/atom 0) - compute-scroll-distance #(when (> (.-scrollY js/window) 0) - (reset! a (+ (.-scrollY js/window) (.-innerHeight js/window))))] + compute-scroll-distance + #(when (> (.-scrollY js/window) 0) + (reset! a (+ (.-scrollY js/window) (.-innerHeight js/window))))] (.addEventListener js/window "scroll" compute-scroll-distance) (.addEventListener js/window "touchmove" compute-scroll-distance) a)) (defonce !auto-theme (let [theme (r/atom (when (and (.-matchMedia js/window) - (.-matches (.matchMedia js/window "(prefers-color-scheme: dark)"))) - :dark))] - (.addEventListener (.matchMedia js/window "(prefers-color-scheme: dark)") "change" - #(reset! theme (if (.-matches %) :dark :light))) + (.-matches + (.matchMedia js/window + "(prefers-color-scheme: dark)"))) + "dark"))] + (.addEventListener (.matchMedia js/window "(prefers-color-scheme: dark)") + "change" + #(reset! theme (if (.-matches %) "dark" "light"))) theme)) -(defonce !elapsed-time (r/atom 0)) -(defonce !player (atom nil)) (rf/reg-sub :is-window-visible @@ -40,113 +54,11 @@ (> (+ @!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 - :auto-theme - (fn [db _] - @!auto-theme)) - -(rf/reg-sub - :player-ready - (fn [db _] - (:player-ready db))) - -(rf/reg-sub - :paused - (fn [db _] - (:paused db))) - -(rf/reg-sub - :volume-level - (fn [db _] - (:volume-level db))) - -(rf/reg-sub - :muted - (fn [db _] - (:muted db))) - -(rf/reg-sub - :http-response - (fn [db _] - (:http-response db))) - -(rf/reg-sub - :search-results - (fn [db _] - (:search-results db))) - -(rf/reg-sub - :notifications - (fn [db _] - (:notifications db))) - -(rf/reg-sub - :modal - (fn [db _] - (:modal db))) - -(rf/reg-sub - :stream - (fn [db _] - (:stream db))) - -(rf/reg-sub - :playlist - (fn [db _] - (:playlist db))) - -(rf/reg-sub - :channel - (fn [db _] - (:channel db))) - -(rf/reg-sub - :search-query - (fn [db _] - (:search-query db))) - -(rf/reg-sub - :service-id - (fn [db _] - (:service-id db))) - -(rf/reg-sub - :service-color - (fn [_] - (rf/subscribe [:service-id])) - (fn [id _] - (utils/get-service-color id))) - -(rf/reg-sub - :service-name + :dark-theme (fn [_] - (rf/subscribe [:service-id])) - (fn [id _] - (utils/get-service-name id))) - -(rf/reg-sub - :services - (fn [db _] - (:services db))) - -(rf/reg-sub - :kiosks - (fn [db _] - (:kiosks db))) - -(rf/reg-sub - :kiosk - (fn [db _] - (:kiosk db))) + (rf/subscribe [:settings])) + (fn [{:keys [theme]} _] + (or (and (= theme "auto") (= @!auto-theme "dark")) (= theme "dark")))) (rf/reg-sub :current-match @@ -158,38 +70,6 @@ (fn [db _] (:page-scroll db))) -(rf/reg-sub - :media-queue - (fn [db _] - (:media-queue db))) - -(rf/reg-sub - :media-queue-pos - (fn [db _] - (:media-queue-pos db))) - -(rf/reg-sub - :media-queue-stream - (fn [_] - [(rf/subscribe [:media-queue]) (rf/subscribe [:media-queue-pos])]) - (fn [[queue pos] _] - (and (not-empty queue) (< pos (count queue)) (nth queue pos)))) - -(rf/reg-sub - :bookmarks - (fn [db _] - (:bookmarks db))) - -(rf/reg-sub - :show-audio-player - (fn [db _] - (:show-audio-player db))) - -(rf/reg-sub - :show-audio-player-loading - (fn [db _] - (:show-audio-player-loading db))) - (rf/reg-sub :show-page-loading (fn [db _] @@ -204,23 +84,3 @@ :show-mobile-nav (fn [db _] (:show-mobile-nav db))) - -(rf/reg-sub - :show-search-form - (fn [db _] - (:show-search-form db))) - -(rf/reg-sub - :show-media-queue - (fn [db _] - (:show-media-queue db))) - -(rf/reg-sub - :settings - (fn [db _] - (:settings db))) - -(rf/reg-sub - :loop-playback - (fn [db _] - (:loop-playback db))) diff --git a/src/frontend/tubo/utils.cljs b/src/frontend/tubo/utils.cljs index 643031c..58bb3e5 100644 --- a/src/frontend/tubo/utils.cljs +++ b/src/frontend/tubo/utils.cljs @@ -4,21 +4,23 @@ (defn get-service-color [id] - (case id - 0 "#cc0000" - 1 "#ff7700" - 2 "#333333" - 3 "#F2690D" - 4 "#629aa9")) + (when id + (case id + 0 "#cc0000" + 1 "#ff7700" + 2 "#333333" + 3 "#F2690D" + 4 "#629aa9"))) (defn get-service-name [id] - (case id - 0 "YouTube" - 1 "SoundCloud" - 2 "media.ccc.de" - 3 "PeerTube" - 4 "Bandcamp")) + (when id + (case id + 0 "YouTube" + 1 "SoundCloud" + 2 "media.ccc.de" + 3 "PeerTube" + 4 "Bandcamp"))) (defn format-date-string [date] diff --git a/src/frontend/tubo/views.cljs b/src/frontend/tubo/views.cljs index aabd7b6..12fbe54 100644 --- a/src/frontend/tubo/views.cljs +++ b/src/frontend/tubo/views.cljs @@ -1,23 +1,21 @@ (ns tubo.views (:require [re-frame.core :as rf] - [tubo.components.audio-player :as player] [tubo.components.navigation :as navigation] - [tubo.components.notification :as notification] - [tubo.components.play-queue :as queue] - [tubo.events :as events])) + [tubo.notifications.views :as notifications] + [tubo.player.views :as player] + [tubo.queue.views :as queue])) (defn app [] - (let [current-match @(rf/subscribe [:current-match]) - auto-theme @(rf/subscribe [:auto-theme]) - {:keys [theme]} @(rf/subscribe [:settings])] - [:div {:class (when (or (and (= theme "auto") (= auto-theme :dark)) (= theme "dark")) "dark")} - [:div.min-h-screen.flex.flex-col.h-full.dark:text-white.bg-neutral-100.dark:bg-neutral-900.relative.font-nunito-sans + (let [current-match @(rf/subscribe [:current-match]) + dark-theme? @(rf/subscribe [:dark-theme])] + [:div {:class (when dark-theme? :dark)} + [:div.font-nunito-sans.min-h-screen.h-full.relative.flex.flex-col.dark:text-white.bg-neutral-100.dark:bg-neutral-900 [navigation/navbar current-match] - [notification/notifications-panel] + [notifications/notifications-panel] [:div.flex.flex-col.flex-auto.justify-between.relative (when-let [view (-> current-match :data :view)] [view current-match]) [queue/queue] - [player/player]]]])) + [player/background-player]]]])) diff --git a/src/frontend/tubo/views/bookmarks.cljs b/src/frontend/tubo/views/bookmarks.cljs deleted file mode 100644 index cf827bb..0000000 --- a/src/frontend/tubo/views/bookmarks.cljs +++ /dev/null @@ -1,79 +0,0 @@ -(ns tubo.views.bookmarks - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.components.modal :as modal] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.events :as events])) - -(defn add-bookmark-modal - [] - (let [!bookmark-name (r/atom "")] - (fn [] - [modal/modal-content "Create New Playlist?" - [layout/text-input "Title" :text-input @!bookmark-name - #(reset! !bookmark-name (.. % -target -value)) "Playlist name"] - [layout/secondary-button "Cancel" - #(rf/dispatch [::events/close-modal])] - [layout/primary-button "Create Playlist" - #(rf/dispatch [::events/add-bookmark-list {:name @!bookmark-name} true])]]))) - -(defn bookmarks-page - [] - (let [!menu-active? (r/atom nil)] - (fn [] - (let [service-color @(rf/subscribe [:service-color]) - bookmarks @(rf/subscribe [:bookmarks]) - items (map #(assoc % - :url (rfe/href :tubo.routes/bookmark nil {:id (:id %)}) - :thumbnail-url (-> % :items first :thumbnail-url) - :stream-count (count (:items %)) - :bookmark-id (:id %)) bookmarks)] - [layout/content-container - [layout/content-header "Bookmarks" - [layout/popover-menu !menu-active? - [{:label "Add New" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/open-modal [add-bookmark-modal]])} - [:<> - [:input.hidden - {:id "file-selector" - :type "file" - :multiple "multiple" - :on-click #(reset! !menu-active? true) - :on-change #(rf/dispatch [::events/import-bookmark-lists (.. % -target -files)])}] - [:label.whitespace-nowrap.cursor-pointer.w-full.h-full.absolute.right-0.top-0 - {:for "file-selector"}] - [:span.text-xs.w-10.min-w-4.w-4.flex.items-center [:i.fa-solid.fa-file-import]] - [:span "Import"]] - {:label "Export" - :icon [:i.fa-solid.fa-file-export] - :on-click #(rf/dispatch [::events/export-bookmark-lists])} - {:label "Clear All" - :icon [:i.fa-solid.fa-trash] - :on-click #(rf/dispatch [::events/clear-bookmark-lists])}]]] - [items/related-streams items]])))) - -(defn bookmark-page - [] - (let [!menu-active? (r/atom nil)] - (fn [] - (let [bookmarks @(rf/subscribe [:bookmarks]) - service-color @(rf/subscribe [:service-color]) - {{:keys [id]} :query-params} @(rf/subscribe [:current-match]) - {:keys [items name]} (first (filter #(= (:id %) id) bookmarks))] - [layout/content-container - [layout/content-header name - (when-not (empty? items) - [layout/popover-menu !menu-active? - [{:label "Add to queue" - :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/enqueue-related-streams items])} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal items]])}]])] - [items/related-streams (map #(assoc % :type "stream" :bookmark-id id) items)]])))) diff --git a/src/frontend/tubo/views/channel.cljs b/src/frontend/tubo/views/channel.cljs deleted file mode 100644 index 3b99b52..0000000 --- a/src/frontend/tubo/views/channel.cljs +++ /dev/null @@ -1,47 +0,0 @@ -(ns tubo.views.channel - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.events :as events])) - -(defn channel - [query-params] - (let [!menu-active? (r/atom nil)] - (fn [query-params] - (let [!show-description? (r/atom false)] - (fn [{{:keys [url]} :query-params}] - (let [{:keys [banner avatar name description subscriber-count related-streams next-page] - :as channel} @(rf/subscribe [:channel]) - next-page-url (:url next-page) - service-color @(rf/subscribe [:service-color]) - scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] - (when scrolled-to-bottom? - (rf/dispatch [::events/channel-pagination url next-page-url])) - [layout/content-container - (when banner - [:div.flex.justify-center.h-24 - [:img.min-w-full.min-h-full.object-cover.rounded {:src banner}]]) - [:div.flex.items-center.justify-between - [:div.flex.items-center.my-4.mx-2 - [layout/uploader-avatar avatar name] - [:div.m-4 - [:h1.text-2xl.line-clamp-1.font-semibold {:title name} name] - (when subscriber-count - [:div.flex.my-2.items-center - [:i.fa-solid.fa-users.text-xs] - [:span.mx-2 (.toLocaleString subscriber-count)]])]] - (when related-streams - [layout/popover-menu !menu-active? - [{:label "Add to queue" - :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/enqueue-related-streams related-streams])} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal related-streams]])}]])] - [layout/show-more-container @!show-description? description - #(reset! !show-description? (not @!show-description?))] - [items/related-streams related-streams next-page-url]])))))) diff --git a/src/frontend/tubo/views/kiosk.cljs b/src/frontend/tubo/views/kiosk.cljs deleted file mode 100644 index 7e534bf..0000000 --- a/src/frontend/tubo/views/kiosk.cljs +++ /dev/null @@ -1,20 +0,0 @@ -(ns tubo.views.kiosk - (:require - [re-frame.core :as rf] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.events :as events])) - -(defn kiosk - [{{:keys [serviceId kioskId]} :query-params}] - (let [{:keys [id url related-streams - next-page]} @(rf/subscribe [:kiosk]) - next-page-url (:url next-page) - service-color @(rf/subscribe [:service-color]) - service-id (or @(rf/subscribe [:service-id]) serviceId) - scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] - (when scrolled-to-bottom? - (rf/dispatch [::events/kiosk-pagination service-id id next-page-url])) - [layout/content-container - [layout/content-header id] - [items/related-streams related-streams next-page-url]])) diff --git a/src/frontend/tubo/views/playlist.cljs b/src/frontend/tubo/views/playlist.cljs deleted file mode 100644 index b11e1e6..0000000 --- a/src/frontend/tubo/views/playlist.cljs +++ /dev/null @@ -1,43 +0,0 @@ -(ns tubo.views.playlist - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.events :as events])) - -(defn playlist - [{{:keys [url]} :query-params}] - (let [!menu-active? (r/atom nil)] - (fn [] - (let [{:keys [id name playlist-type thumbnail-url banner-url - uploader-name uploader-url uploader-avatar - stream-count next-page - related-streams]} @(rf/subscribe [:playlist]) - next-page-url (:url next-page) - scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] - (when scrolled-to-bottom? - (rf/dispatch [::events/playlist-pagination url next-page-url])) - [layout/content-container - [:div.flex.flex-col.justify-center - [layout/content-header name - (when related-streams - [layout/popover-menu !menu-active? - [{:label "Add to queue" - :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/enqueue-related-streams related-streams])} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal related-streams]])}]])] - [:div.flex.items-center.justify-between.my-4.gap-x-4 - [:div.flex.items-center - [layout/uploader-avatar uploader-avatar uploader-name uploader-url] - [:a.line-clamp-1.ml-2 - {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) - :title uploader-name} - uploader-name]] - [:span.text-sm.whitespace-nowrap (str stream-count " streams")]]] - [items/related-streams related-streams next-page-url]])))) diff --git a/src/frontend/tubo/views/search.cljs b/src/frontend/tubo/views/search.cljs deleted file mode 100644 index 8133e03..0000000 --- a/src/frontend/tubo/views/search.cljs +++ /dev/null @@ -1,20 +0,0 @@ -(ns tubo.views.search - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.events :as events])) - -(defn search - [{{:keys [q serviceId]} :query-params}] - (let [{:keys [items next-page] - :as search-results} @(rf/subscribe [:search-results]) - next-page-url (:url next-page) - services @(rf/subscribe [:services]) - service-id (or @(rf/subscribe [:service-id]) serviceId) - scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])] - (when scrolled-to-bottom? - (rf/dispatch [::events/search-pagination q service-id next-page-url])) - [layout/content-container - [items/related-streams items next-page-url]])) diff --git a/src/frontend/tubo/views/settings.cljs b/src/frontend/tubo/views/settings.cljs deleted file mode 100644 index a5352e5..0000000 --- a/src/frontend/tubo/views/settings.cljs +++ /dev/null @@ -1,33 +0,0 @@ -(ns tubo.views.settings - (:require - [re-frame.core :as rf] - [tubo.components.layout :as layout] - [tubo.events :as events])) - -(defn boolean-input - [label key value] - [layout/boolean-input label key value #(rf/dispatch [::events/change-setting key (not value)])]) - -(defn select-input - [label key value options on-change] - [layout/select-input label key value options - (or on-change #(rf/dispatch [::events/change-setting key (.. % -target -value)]))]) - -(defn settings-page [] - (let [{:keys [theme themes show-comments show-related - show-description default-service]} @(rf/subscribe [:settings]) - service-color @(rf/subscribe [:service-color]) - services @(rf/subscribe [:services])] - [layout/content-container - [layout/content-header "Settings"] - [:form.flex.flex-wrap.py-4 - [select-input "Theme" :theme theme #{:auto :light :dark}] - [select-input "Default service" :default-service (:id default-service) - (map #(-> % :info :name) services) - #(rf/dispatch [::events/change-service-setting (.. % -target -value)])] - [select-input "Default kiosk" :default-service - (:default-kiosk default-service) (:available-kiosks default-service) - #(rf/dispatch [::events/change-kiosk-setting (.. % -target -value)])] - [boolean-input "Show description?" :show-description show-description] - [boolean-input "Show comments?" :show-comments show-comments] - [boolean-input "Show related videos?" :show-related show-related]]])) diff --git a/src/frontend/tubo/views/stream.cljs b/src/frontend/tubo/views/stream.cljs deleted file mode 100644 index f2bb780..0000000 --- a/src/frontend/tubo/views/stream.cljs +++ /dev/null @@ -1,145 +0,0 @@ -(ns tubo.views.stream - (:require - [reagent.core :as r] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tubo.events :as events] - [tubo.components.items :as items] - [tubo.components.layout :as layout] - [tubo.components.comments :as comments] - [tubo.components.modals.bookmarks :as bookmarks] - [tubo.components.video-player :as player] - [tubo.utils :as utils])) - -(defn stream - [match] - (let [!stream-menu-active? (r/atom nil) - !suggested-menu-active? (r/atom nil)] - (fn [] - (let [{:keys [name url video-streams audio-streams view-count - subscriber-count like-count dislike-count - description uploader-avatar uploader-name - uploader-url upload-date related-streams - thumbnail-url show-comments-loading comments-page - show-comments show-related show-description service-id] - :as stream} @(rf/subscribe [:stream]) - {show-comments? :show-comments - show-related? :show-related - show-description? :show-description} - @(rf/subscribe [:settings]) - available-streams (apply conj audio-streams video-streams) - page-loading? @(rf/subscribe [:show-page-loading]) - service-color @(rf/subscribe [:service-color]) - bookmarks @(rf/subscribe [:bookmarks]) - liked? (some #(= (:url %) url) (-> bookmarks first :items)) - sources (reverse (map (fn [{:keys [content format resolution averageBitrate]}] - {:src content - :type "video/mp4" - :label (str (or resolution "audio-only") " " - format - (when-not resolution - (str " " averageBitrate "kbit/s")))}) - available-streams)) - player-elements ["PlayToggle" "ProgressControl" "VolumePanel" "CurrentTimeDisplay" - "TimeDivider" "DurationDisplay" "Spacer" "PlaybackRateMenuButton" - "QualitySelector" "FullscreenToggle"]] - [layout/content-container - [:div.flex.justify-center.relative - {:class "h-[300px] md:h-[450px] lg:h-[600px]"} - [player/player - {:sources sources - :poster thumbnail-url - :controls true - :controlBar {:children player-elements} - :preload "metadata" - :responsive true - :fill true - :playbackRates [0.5 1 1.5 2]}]] - [:div - [:div.flex.flex-col - [:div.flex.items-center.justify-between.pt-4.my-3 - [:div.flex-auto - [:h1.text-lg.sm:text-2xl.font-bold.line-clamp-1 {:title name} name]] - [:div.flex.flex-auto.justify-end.items-center - [layout/popover-menu !stream-menu-active? - [{:label "Add to queue" - :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/switch-to-audio-player stream])} - {:label "Play radio" - :icon [:i.fa-solid.fa-tower-cell] - :on-click #(rf/dispatch [::events/start-stream-radio stream])} - {:label (if liked? "Remove favorite" "Favorite") - :icon (if liked? - [:i.fa-solid.fa-heart {:style {:color (utils/get-service-color service-id)}}] - [:i.fa-solid.fa-heart]) - :on-click #(rf/dispatch [(if liked? ::events/remove-from-likes ::events/add-to-likes) stream])} - {:label "Original" - :link {:route url :external? true} - :icon [:i.fa-solid.fa-external-link-alt]} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal stream]])}]]]] - [:div.flex.justify-between.py-2.flex-nowrap - [:div.flex.items-center - [layout/uploader-avatar uploader-avatar uploader-name - (rfe/href :tubo.routes/channel nil {:url uploader-url})] - [:div.mx-3 - [:a.line-clamp-1.font-semibold - {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) - :title uploader-name} - uploader-name] - (when subscriber-count - [:div.flex.my-2.items-center - [:i.fa-solid.fa-users.text-xs] - [:p.mx-2 (utils/format-quantity subscriber-count)]])]] - [:div.flex.flex-col.items-end.flex-auto.justify-center - (when view-count - [:div.sm:text-base.text-sm.mb-1 - [:i.fa-solid.fa-eye] - [:span.ml-2 (.toLocaleString view-count)]]) - [:div.flex - (when like-count - [:div.items-center.sm:text-base.text-sm - [:i.fa-solid.fa-thumbs-up] - [:span.ml-2 (.toLocaleString like-count)]]) - (when dislike-count - [:div.ml-2.items-center.sm:text-base.text-sm - [:i.fa-solid.fa-thumbs-down] - [:span.ml-2 dislike-count]])] - (when upload-date - [:div.sm:text-base.text-sm.mt-1.whitespace-nowrap - [:i.fa-solid.fa-calendar] - [:span.ml-2 (utils/format-date-string upload-date)]])]] - (when (and show-description? (not (empty? description))) - [layout/show-more-container show-description description - #(rf/dispatch [::events/toggle-stream-layout :show-description])]) - (when (and comments-page (not (empty? (:comments comments-page))) show-comments?) - [layout/accordeon - {:label "Comments" - :on-open #(if show-comments - (rf/dispatch [::events/toggle-stream-layout :show-comments]) - (if comments-page - (rf/dispatch [::events/toggle-stream-layout :show-comments]) - (rf/dispatch [::events/get-comments url]))) - :open? show-comments - :left-icon "fa-solid fa-comments"} - (if show-comments-loading - [layout/loading-icon service-color "text-2xl"] - (when (and show-comments comments-page) - [comments/comments comments-page uploader-name uploader-avatar url]))]) - (when (and show-related? (not (empty? related-streams))) - [layout/accordeon - {:label "Suggested" - :on-open #(rf/dispatch [::events/toggle-stream-layout :show-related]) - :open? (not show-related) - :left-icon "fa-solid fa-list" - :right-button [layout/popover-menu !suggested-menu-active? - [{:label "Add to queue" - :icon [:i.fa-solid.fa-headphones] - :on-click #(rf/dispatch [::events/enqueue-related-streams related-streams])} - {:label "Add to playlist" - :icon [:i.fa-solid.fa-plus] - :on-click #(rf/dispatch [::events/add-bookmark-list-modal - [bookmarks/add-to-bookmark-list-modal related-streams]])}]]} - [items/related-streams related-streams nil]])]]])))) -- cgit v1.2.3