aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiguel Ángel Moreno <mail@migalmoreno.com>2024-05-29 10:41:21 +0200
committerMiguel Ángel Moreno <mail@migalmoreno.com>2024-05-29 11:16:15 +0200
commitaf5351fdb5325db4aff75faa85a5bb97fe0d1364 (patch)
treea63ec85c4b94959462f0d6a575809a8bd268f8f9
parentce1fb72f0dcbf56c77e33bf59337fa662c8b5001 (diff)
refactor(frontend): modularize the app into standalone panels
-rw-r--r--src/frontend/tubo/bookmarks/events.cljs235
-rw-r--r--src/frontend/tubo/bookmarks/modals.cljs44
-rw-r--r--src/frontend/tubo/bookmarks/subs.cljs8
-rw-r--r--src/frontend/tubo/bookmarks/views.cljs66
-rw-r--r--src/frontend/tubo/channel/events.cljs52
-rw-r--r--src/frontend/tubo/channel/subs.cljs8
-rw-r--r--src/frontend/tubo/channel/views.cljs47
-rw-r--r--src/frontend/tubo/comments/events.cljs55
-rw-r--r--src/frontend/tubo/comments/views.cljs81
-rw-r--r--src/frontend/tubo/components/audio_player.cljs157
-rw-r--r--src/frontend/tubo/components/comments.cljs78
-rw-r--r--src/frontend/tubo/components/items.cljs136
-rw-r--r--src/frontend/tubo/components/layout.cljs93
-rw-r--r--src/frontend/tubo/components/modal.cljs33
-rw-r--r--src/frontend/tubo/components/modals/bookmarks.cljs46
-rw-r--r--src/frontend/tubo/components/navigation.cljs172
-rw-r--r--src/frontend/tubo/components/play_queue.cljs137
-rw-r--r--src/frontend/tubo/components/player.cljs74
-rw-r--r--src/frontend/tubo/components/video_player.cljs39
-rw-r--r--src/frontend/tubo/core.cljs4
-rw-r--r--src/frontend/tubo/events.cljs1057
-rw-r--r--src/frontend/tubo/kiosks/events.cljs87
-rw-r--r--src/frontend/tubo/kiosks/subs.cljs13
-rw-r--r--src/frontend/tubo/kiosks/views.cljs43
-rw-r--r--src/frontend/tubo/modals/events.cljs36
-rw-r--r--src/frontend/tubo/modals/subs.cljs8
-rw-r--r--src/frontend/tubo/modals/views.cljs34
-rw-r--r--src/frontend/tubo/notifications/events.cljs28
-rw-r--r--src/frontend/tubo/notifications/subs.cljs8
-rw-r--r--src/frontend/tubo/notifications/views.cljs (renamed from src/frontend/tubo/components/notification.cljs)19
-rw-r--r--src/frontend/tubo/player/events.cljs248
-rw-r--r--src/frontend/tubo/player/subs.cljs52
-rw-r--r--src/frontend/tubo/player/views.cljs182
-rw-r--r--src/frontend/tubo/playlist/events.cljs46
-rw-r--r--src/frontend/tubo/playlist/subs.cljs8
-rw-r--r--src/frontend/tubo/playlist/views.cljs (renamed from src/frontend/tubo/views/playlist.cljs)83
-rw-r--r--src/frontend/tubo/queue/events.cljs75
-rw-r--r--src/frontend/tubo/queue/subs.cljs25
-rw-r--r--src/frontend/tubo/queue/views.cljs152
-rw-r--r--src/frontend/tubo/routes.cljs85
-rw-r--r--src/frontend/tubo/search/events.cljs64
-rw-r--r--src/frontend/tubo/search/subs.cljs18
-rw-r--r--src/frontend/tubo/search/views.cljs61
-rw-r--r--src/frontend/tubo/services/events.cljs29
-rw-r--r--src/frontend/tubo/services/subs.cljs28
-rw-r--r--src/frontend/tubo/services/views.cljs20
-rw-r--r--src/frontend/tubo/settings/events.cljs60
-rw-r--r--src/frontend/tubo/settings/subs.cljs8
-rw-r--r--src/frontend/tubo/settings/views.cljs35
-rw-r--r--src/frontend/tubo/stream/events.cljs32
-rw-r--r--src/frontend/tubo/stream/subs.cljs8
-rw-r--r--src/frontend/tubo/stream/views.cljs174
-rw-r--r--src/frontend/tubo/subs.cljs194
-rw-r--r--src/frontend/tubo/utils.cljs26
-rw-r--r--src/frontend/tubo/views.cljs20
-rw-r--r--src/frontend/tubo/views/bookmarks.cljs79
-rw-r--r--src/frontend/tubo/views/channel.cljs47
-rw-r--r--src/frontend/tubo/views/kiosk.cljs20
-rw-r--r--src/frontend/tubo/views/search.cljs20
-rw-r--r--src/frontend/tubo/views/settings.cljs33
-rw-r--r--src/frontend/tubo/views/stream.cljs145
61 files changed, 2597 insertions, 2378 deletions
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/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 "&bull;"}}]
- [: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/components/notification.cljs b/src/frontend/tubo/notifications/views.cljs
index 2f03cd4..b5acf01 100644
--- a/src/frontend/tubo/components/notification.cljs
+++ b/src/frontend/tubo/notifications/views.cljs
@@ -1,19 +1,18 @@
-(ns tubo.components.notification
+(ns tubo.notifications.views
(:require
- [re-frame.core :as rf]
- [tubo.events :as events]))
+ [re-frame.core :as rf]))
(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"]))}
+ [: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 [::events/remove-notification (:id notification)])}
+ {: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
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/views/playlist.cljs b/src/frontend/tubo/playlist/views.cljs
index b11e1e6..737fab4 100644
--- a/src/frontend/tubo/views/playlist.cljs
+++ b/src/frontend/tubo/playlist/views.cljs
@@ -1,43 +1,40 @@
-(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]]))))
+(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 "&bull;"}}]
+ [: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
@@ -159,38 +71,6 @@
(: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 _]
(:show-page-loading 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/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]])]]]))))