aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/tubo
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/tubo')
-rw-r--r--src/frontend/tubo/api.cljs15
-rw-r--r--src/frontend/tubo/components/comments.cljs77
-rw-r--r--src/frontend/tubo/components/items.cljs93
-rw-r--r--src/frontend/tubo/components/loading.cljs8
-rw-r--r--src/frontend/tubo/components/navigation.cljs12
-rw-r--r--src/frontend/tubo/components/player.cljs70
-rw-r--r--src/frontend/tubo/core.cljs22
-rw-r--r--src/frontend/tubo/events.cljs382
-rw-r--r--src/frontend/tubo/routes.cljs58
-rw-r--r--src/frontend/tubo/subs.cljs98
-rw-r--r--src/frontend/tubo/util.cljs24
-rw-r--r--src/frontend/tubo/views.cljs99
-rw-r--r--src/frontend/tubo/views/channel.cljs41
-rw-r--r--src/frontend/tubo/views/kiosk.cljs26
-rw-r--r--src/frontend/tubo/views/playlist.cljs40
-rw-r--r--src/frontend/tubo/views/search.cljs28
-rw-r--r--src/frontend/tubo/views/stream.cljs122
17 files changed, 1215 insertions, 0 deletions
diff --git a/src/frontend/tubo/api.cljs b/src/frontend/tubo/api.cljs
new file mode 100644
index 0000000..2ca442f
--- /dev/null
+++ b/src/frontend/tubo/api.cljs
@@ -0,0 +1,15 @@
+(ns tubo.api
+ (:require
+ [ajax.core :as ajax]))
+
+(defn get-request
+ ([uri on-success on-failure]
+ (get-request uri on-success on-failure {}))
+ ([uri on-success on-failure params]
+ {:http-xhrio {:method :get
+ :uri uri
+ :params params
+ :format (ajax/json-request-format)
+ :response-format (ajax/json-response-format {:keywords? true})
+ :on-success on-success
+ :on-failure on-failure}}))
diff --git a/src/frontend/tubo/components/comments.cljs b/src/frontend/tubo/components/comments.cljs
new file mode 100644
index 0000000..a65b6d8
--- /dev/null
+++ b/src/frontend/tubo/components/comments.cljs
@@ -0,0 +1,77 @@
+(ns tubo.components.comments
+ (:require
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.components.loading :as loading]
+ [tubo.events :as events]
+ [tubo.util :as util]))
+
+(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-center
+ [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name}
+ [:h1.text-gray-300.font-bold uploader-name]]
+ (when stream-position
+ [:p.mx-1.text-xs (str "at " (util/format-duration stream-position))])])
+ (when uploader-verified?
+ [:i.fa-solid.fa-circle-check.ml-2])]
+ [:div.my-2
+ [:p text]]
+ [:div..flex.items-center.my-2
+ [:div.mr-4
+ [:p (util/format-date 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?
+ (loading/loading-icon service-color)
+ [:div.flex.items-center.justify-center
+ {:style {:cursor "pointer"}
+ :on-click #(rf/dispatch [::events/comments-pagination url (:url next-page)])}
+ [:i.fa-solid.fa-plus]
+ [:p.px-2 "Show more comments"]]))]))
diff --git a/src/frontend/tubo/components/items.cljs b/src/frontend/tubo/components/items.cljs
new file mode 100644
index 0000000..95e2251
--- /dev/null
+++ b/src/frontend/tubo/components/items.cljs
@@ -0,0 +1,93 @@
+(ns tubo.components.items
+ (:require
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.components.loading :as loading]
+ [tubo.util :as util]))
+
+(defn thumbnail
+ [thumbnail-url route url name duration]
+ [:div.flex.py-2.box-border.h-44.xs:h-28
+ [:div.relative.min-w-full
+ [:a.absolute.min-w-full.min-h-full.z-10 {:href route :title name}]
+ [:img.rounded.object-cover.min-h-full.max-h-full.min-w-full {:src thumbnail-url}]
+ (when duration
+ [:div.rounded.p-2.absolute {:style {:bottom 5 :right 5 :background "rgba(0,0,0,.7)" :zIndex "0"}}
+ [:p {:style {:fontSize "14px"}}
+ (if (= duration 0)
+ "LIVE"
+ (util/format-duration duration))]])]])
+
+(defn item-content
+ [{:keys [url name thumbnail-url description subscriber-count
+ stream-count verified? key uploader-name uploader-url
+ uploader-avatar upload-date short-description view-count]} item-route]
+ [:<>
+ (when name
+ [:div.my-2
+ [:a {:href item-route :title name}
+ [:h1.line-clamp-2.my-1 name]]])
+ (when-not (empty? uploader-name)
+ [: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-gray-300.font-bold.pr-2 uploader-name]]
+ [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 uploader-name])
+ (when verified?
+ [:i.fa-solid.fa-circle-check])])
+ (when subscriber-count
+ [:div.flex.items-center
+ [:i.fa-solid.fa-users.text-xs]
+ [:p.mx-2 subscriber-count]])
+ (when stream-count
+ [:div.flex.items-center
+ [:i.fa-solid.fa-video.text-xs]
+ [:p.mx-2 stream-count]])
+ [:div.flex.my-1.justify-between
+ [:p (util/format-date upload-date)]
+ (when view-count
+ [:div.flex.items-center.h-full.pl-2
+ [:i.fa-solid.fa-eye.text-xs]
+ [:p.pl-1.5 (util/format-quantity view-count)]])]])
+
+(defn stream-item
+ [{:keys [url name thumbnail-url duration] :as item}]
+ [:<>
+ [thumbnail thumbnail-url (rfe/href :tubo.routes/stream nil {:url url}) url name duration]
+ [item-content item (rfe/href :tubo.routes/stream nil {:url url})]])
+
+(defn channel-item
+ [{:keys [url name thumbnail-url] :as item}]
+ [:<>
+ [thumbnail thumbnail-url (rfe/href :tubo.routes/channel nil {:url url}) url name nil]
+ [item-content item (rfe/href :tubo.routes/channel nil {:url url})]])
+
+(defn playlist-item
+ [{:keys [url name thumbnail-url] :as item}]
+ [:<>
+ [thumbnail thumbnail-url (rfe/href :tubo.routes/playlist nil {:url url}) url name nil]
+ [item-content item (rfe/href :tubo.routes/playlist nil {:url url})]])
+
+(defn generic-item
+ [item]
+ [:div.w-full.xs:w-56.h-80.xs:h-72.my-2 {:key key}
+ [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full
+ (case (:type item)
+ "stream" [stream-item item]
+ "channel" [channel-item item]
+ "playlist" [playlist-item item])]])
+
+(defn related-streams
+ [related-streams next-page-url]
+ (let [service-color @(rf/subscribe [:service-color])
+ pagination-loading? @(rf/subscribe [:show-pagination-loading])]
+ [:div.flex.flex-col.justify-center.items-center.flex-auto.my-2.md:my-8
+ (if (empty? related-streams)
+ [:div.flex.items-center
+ [:p "No available streams"]]
+ [:div.flex.justify-center.flex-wrap
+ (for [[i item] (map-indexed vector related-streams)
+ :let [keyed-item (assoc item :key i)]]
+ [generic-item keyed-item])])
+ (when-not (empty? next-page-url)
+ [loading/loading-icon service-color "text-2xl" (when-not pagination-loading? "invisible")])]))
diff --git a/src/frontend/tubo/components/loading.cljs b/src/frontend/tubo/components/loading.cljs
new file mode 100644
index 0000000..08c37de
--- /dev/null
+++ b/src/frontend/tubo/components/loading.cljs
@@ -0,0 +1,8 @@
+(ns tubo.components.loading)
+
+(defn loading-icon
+ [service-color & styles]
+ [: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))
+ :style {:color service-color}}]])
diff --git a/src/frontend/tubo/components/navigation.cljs b/src/frontend/tubo/components/navigation.cljs
new file mode 100644
index 0000000..a48854c
--- /dev/null
+++ b/src/frontend/tubo/components/navigation.cljs
@@ -0,0 +1,12 @@
+(ns tubo.components.navigation
+ (:require
+ [re-frame.core :as rf]
+ [tubo.events :as events]))
+
+(defn back-button [service-color]
+ [:div.flex.items-center
+ [:button.py-4.px-2
+ {:on-click #(rf/dispatch [::events/history-back])}
+ [:i.fa-solid.fa-chevron-left
+ {:style {:color service-color}}]
+ [:span " Back"]]])
diff --git a/src/frontend/tubo/components/player.cljs b/src/frontend/tubo/components/player.cljs
new file mode 100644
index 0000000..c4b5df7
--- /dev/null
+++ b/src/frontend/tubo/components/player.cljs
@@ -0,0 +1,70 @@
+(ns tubo.components.player
+ (:require
+ [reagent.core :as r]
+ [reagent.dom :as rdom]
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.events :as events]
+ ["video.js" :as videojs]))
+
+(defn global-player
+ []
+ (let [!player (r/atom nil)
+ !loop? (r/atom nil)]
+ (fn []
+ (let [{:keys [uploader-name uploader-url name stream url service-color]} @(rf/subscribe [:global-stream])
+ show-global-player? @(rf/subscribe [:show-global-player])]
+ (when show-global-player?
+ [:div.sticky.bottom-0.z-50.bg-neutral-900.p-5.absolute.box-border.m-0
+ {:style {:borderColor service-color :borderTopWidth "2px" :borderStyle "solid"}}
+ [:div.flex.items-center.justify-between
+ [:div.flex.flex-wrap.items-center
+ [:div.flex.flex-col
+ [:a.text-xs
+ {:href (rfe/href :tubo.router/stream nil {:url url})} name]
+ [:a.text-xs.text-gray-300
+ {:href (rfe/href :tubo.router/channel nil {:url uploader-url})} uploader-name]]
+ [:div.px-2.py-0.md:pt-4
+ [:audio {:src stream :ref #(reset! !player %) :loop @!loop?}]]
+ [:div.mx-2
+ [:button.focus:ring-transparent.mx-2
+ {:on-click (fn [] (swap! !loop? #(not %)))}
+ [:i.fa-solid.fa-repeat
+ {:style {:color (when @!loop? service-color)}}]]
+ [:button.focus:ring-transparent.mx-2
+ {:on-click #(when-let [player @!player]
+ (if (.-paused player)
+ (.play player)
+ (.pause player)))}
+ (if @!player
+ (if (.-paused @!player)
+ [:i.fa-solid.fa-play]
+ [:i.fa-solid.fa-pause])
+ [:i.fa-solid.fa-play])]]]
+ [:div.px-2
+ [:i.fa-solid.fa-close.cursor-pointer
+ {:on-click (fn []
+ (rf/dispatch [::events/toggle-global-player])
+ (.pause @!player))}]]]])))))
+
+(defn stream-player
+ [options url]
+ (let [!player (atom nil)]
+ (r/create-class
+ {:display-name "StreamPlayer"
+ :component-did-mount
+ (fn [this]
+ (reset! !player (videojs (rdom/dom-node this) (clj->js options))))
+ :component-did-update
+ (fn [this [_ prev-argv prev-more]]
+ (when (and @!player (not= prev-more (first (r/children this))))
+ (.src @!player (apply array (map #(js-obj "type" % "src" (first (r/children this)))
+ ["video/mp4" "video/webm"])))
+ (.ready @!player #(.play @!player))))
+ :component-will-unmount
+ (fn [_]
+ (when @!player
+ (.dispose @!player)))
+ :reagent-render
+ (fn [options url]
+ [:video-js.vjs-default-skin.vjs-big-play-centered.bottom-0.object-cover.min-h-full.max-h-full.min-w-full])})))
diff --git a/src/frontend/tubo/core.cljs b/src/frontend/tubo/core.cljs
new file mode 100644
index 0000000..4834dc2
--- /dev/null
+++ b/src/frontend/tubo/core.cljs
@@ -0,0 +1,22 @@
+(ns tubo.core
+ (:require
+ ["react-dom/client" :as rdom]
+ [reagent.core :as r]
+ [re-frame.core :as rf]
+ [tubo.events :as events]
+ [tubo.routes :as routes]
+ [tubo.subs]
+ [tubo.views :as views]))
+
+(defonce root (rdom/createRoot (.querySelector js/document "#app")))
+
+(defn ^:dev/after-load mount-root
+ []
+ (rf/clear-subscription-cache!)
+ (routes/start-routes!)
+ (.render root (r/as-element [views/app])))
+
+(defn ^:export init
+ []
+ (rf/dispatch-sync [::events/initialize-db])
+ (mount-root))
diff --git a/src/frontend/tubo/events.cljs b/src/frontend/tubo/events.cljs
new file mode 100644
index 0000000..d919e31
--- /dev/null
+++ b/src/frontend/tubo/events.cljs
@@ -0,0 +1,382 @@
+(ns tubo.events
+ (:require
+ [day8.re-frame.http-fx]
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [reitit.frontend.controllers :as rfc]
+ [tubo.api :as api]))
+
+(rf/reg-event-db
+ ::initialize-db
+ (fn [_ _]
+ {:global-search ""
+ :service-id 0
+ :service-color "#cc0000"
+ :stream {}
+ :search-results []
+ :services []
+ :current-match nil
+ :page-scroll 0}))
+
+(rf/reg-fx
+ ::scroll-to-top
+ (fn [_]
+ (.scrollTo js/window #js {"top" 0 "behavior" "smooth"})))
+
+(rf/reg-fx
+ ::history-back!
+ (fn [_]
+ (.back js/window.history)))
+
+(rf/reg-event-fx
+ ::history-back
+ (fn [_ _]
+ {::history-back! nil}))
+
+(rf/reg-event-db
+ ::page-scroll
+ (fn [db _]
+ (when (> (.-scrollY js/window) 0)
+ (assoc db :page-scroll (+ (.-scrollY js/window) (.-innerHeight js/window))))))
+
+(rf/reg-event-db
+ ::reset-page-scroll
+ (fn [db _]
+ (assoc db :page-scroll 0)))
+
+(rf/reg-event-db
+ ::toggle-mobile-nav
+ (fn [db _]
+ (assoc db :show-mobile-nav (not (:show-mobile-nav db)))))
+
+(rf/reg-event-fx
+ ::navigated
+ (fn [{:keys [db]} [_ new-match]]
+ (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-pagination-loading false))
+ ::scroll-to-top nil})))
+
+(rf/reg-event-fx
+ ::navigate
+ (fn [_ [_ route]]
+ {::navigate! route}))
+
+(rf/reg-fx
+ ::navigate!
+ (fn [{:keys [name params query]}]
+ (rfe/push-state name params query)))
+
+(rf/reg-event-db
+ ::bad-response
+ (fn [db [_ res]]
+ (js/console.log res)
+ (assoc db :http-response (get-in res [:response :error]))))
+
+(rf/reg-event-db
+ ::change-global-search
+ (fn [db [_ res]]
+ (assoc db :global-search res)))
+
+(rf/reg-event-db
+ ::change-service-color
+ (fn [db [_ service-id]]
+ (assoc db :service-color
+ (case service-id
+ 0 "#cc0000"
+ 1 "#ff7700"
+ 2 "#333333"
+ 3 "#F2690D"
+ 4 "#629aa9"))))
+
+(rf/reg-event-fx
+ ::change-service-id
+ (fn [{:keys [db]} [_ service-id]]
+ {:db (assoc db :service-id service-id)
+ :fx [[:dispatch [::change-service-color 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 "/api/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 "/api/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]]
+ (-> db
+ (update-in [:search-results :items] #(apply conj %1 %2)
+ (:items (js->clj res :keywordize-keys true)))
+ (assoc-in [:search-results :next-page]
+ (:next-page (js->clj res :keywordize-keys true)))
+ (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 "/api/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-db
+ ::change-global-stream
+ (fn [db [_ global-stream]]
+ (assoc db :global-stream global-stream)))
+
+(rf/reg-event-db
+ ::toggle-global-player
+ (fn [db _]
+ (assoc db :show-global-player (not (:show-global-player db)))))
+
+(rf/reg-event-fx
+ ::switch-to-global-player
+ (fn [{:keys [db]} [_ global-stream]]
+ {:db (assoc db :show-global-player true)
+ :fx [[:dispatch [::change-global-stream global-stream]]]}))
+
+(rf/reg-event-db
+ ::load-services
+ (fn [db [_ res]]
+ (assoc db :services (js->clj res :keywordize-keys true))))
+
+(rf/reg-event-fx
+ ::get-services
+ (fn [{:keys [db]} _]
+ (api/get-request "/api/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 "/api/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-comments
+ (fn [db _]
+ (assoc-in db [:stream :show-comments] (not (-> db :stream :show-comments)))))
+
+(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 "/api/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 "/api/services/" id "/kiosks") [::load-kiosks] [::bad-response])))
+
+(rf/reg-event-db
+ ::load-kiosk
+ (fn [db [_ res]]
+ (assoc db :kiosk (js->clj res :keywordize-keys true)
+ :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-default-kiosk
+ (fn [{:keys [db]} [_ service-id]]
+ (assoc
+ (api/get-request (str "/api/services/" service-id "/default-kiosk")
+ [::load-kiosk] [::bad-response])
+ :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-fx
+ ::get-kiosk
+ (fn [{:keys [db]} [_ service-id kiosk-id]]
+ (if kiosk-id
+ (assoc
+ (api/get-request (str "/api/services/" service-id "/kiosks/"
+ (js/encodeURIComponent kiosk-id))
+ [::load-kiosk] [::bad-response])
+ :db (assoc db :show-page-loading true))
+ {:fx [[:dispatch [::get-default-kiosk service-id]]]})))
+
+(rf/reg-event-fx
+ ::change-service
+ (fn [{:keys [db]} [_ service-id]]
+ {:fx [[: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 "/api/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-stream
+ (fn [{:keys [db]} [_ res]]
+ (let [stream-res (js->clj res :keywordize-keys true)]
+ {:db (assoc db :stream stream-res
+ :show-page-loading false)
+ :fx [[:dispatch [::change-stream-format nil]]
+ [:dispatch [::get-comments (:url stream-res)]]]})))
+
+(rf/reg-event-fx
+ ::get-stream
+ (fn [{:keys [db]} [_ uri]]
+ (assoc
+ (api/get-request (str "/api/streams/" (js/encodeURIComponent uri))
+ [::load-stream] [::bad-response])
+ :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-db
+ ::change-stream-format
+ (fn [{:keys [stream] :as db} [_ format-id]]
+ (let [{:keys [audio-streams video-streams]} stream]
+ (if format-id
+ (assoc db :stream-format
+ (first (filter #(= format-id (:id %)) (apply conj audio-streams video-streams))))
+ (assoc db :stream-format (-> (if (empty? video-streams) audio-streams video-streams)
+ last))))))
+
+(rf/reg-event-db
+ ::load-channel
+ (fn [db [_ res]]
+ (assoc db :channel (js->clj res :keywordize-keys true)
+ :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-channel
+ (fn [{:keys [db]} [_ uri]]
+ (assoc
+ (api/get-request
+ (str "/api/channels/" (js/encodeURIComponent uri))
+ [::load-channel] [::bad-response])
+ :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-db
+ ::load-playlist
+ (fn [db [_ res]]
+ (assoc db :playlist (js->clj res :keywordize-keys true)
+ :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-playlist
+ (fn [{:keys [db]} [_ uri]]
+ (assoc
+ (api/get-request (str "/api/playlists/" (js/encodeURIComponent uri))
+ [::load-playlist] [::bad-response])
+ :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-db
+ ::load-search-results
+ (fn [db [_ res]]
+ (assoc db :search-results (js->clj res :keywordize-keys true)
+ :show-page-loading false
+ :global-search "")))
+
+(rf/reg-event-fx
+ ::get-search-results
+ (fn [{:keys [db]} [_ service-id query]]
+ (assoc
+ (api/get-request (str "/api/services/" service-id "/search")
+ [::load-search-results] [::bad-response]
+ {:q query})
+ :db (assoc db :show-page-loading true))))
diff --git a/src/frontend/tubo/routes.cljs b/src/frontend/tubo/routes.cljs
new file mode 100644
index 0000000..eb40ac7
--- /dev/null
+++ b/src/frontend/tubo/routes.cljs
@@ -0,0 +1,58 @@
+(ns tubo.routes
+ (:require
+ [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.search :as search]
+ [tubo.views.stream :as stream]))
+
+(def routes
+ (ref/router
+ [["/" {:view kiosk/kiosk
+ :name ::home
+ :controllers [{:start (fn [_]
+ (rf/dispatch [::events/change-service-id 0])
+ (rf/dispatch [::events/get-default-kiosk 0])
+ (rf/dispatch [::events/get-kiosks 0]))}]}]
+ ["/search" {:view search/search
+ :name ::search
+ :controllers [{:parameters {:query [:q :serviceId]}
+ :start (fn [{{:keys [serviceId q]} :query}]
+ (rf/dispatch [::events/change-service-id (js/parseInt serviceId)])
+ (rf/dispatch [::events/get-search-results serviceId q]))}]}]
+ ["/stream" {:view stream/stream
+ :name ::stream
+ :controllers [{:parameters {:query [:url]}
+ :start (fn [{{:keys [url]} :query}]
+ (rf/dispatch [::events/get-stream url]))}]}]
+ ["/channel" {:view channel/channel
+ :name ::channel
+ :controllers [{:parameters {:query [:url]}
+ :start (fn [{{:keys [url]} :query}]
+ (rf/dispatch [::events/get-channel url]))}]}]
+ ["/playlist" {:view playlist/playlist
+ :name ::playlist
+ :controllers [{:parameters {:query [:url]}
+ :start (fn [{{:keys [url]} :query}]
+ (rf/dispatch [::events/get-playlist url]))}]}]
+ ["/kiosk" {:view kiosk/kiosk
+ :name ::kiosk
+ :controllers [{:parameters {:query [:kioskId :serviceId]}
+ :start (fn [{{:keys [serviceId kioskId]} :query}]
+ (rf/dispatch [::events/change-service-id (js/parseInt serviceId)])
+ (rf/dispatch [::events/get-kiosk serviceId kioskId])
+ (rf/dispatch [::events/get-kiosks serviceId]))}]}]]))
+
+(defn on-navigate
+ [new-match]
+ (rf/dispatch [::events/reset-page-scroll])
+ (when new-match
+ (rf/dispatch [::events/navigated new-match])))
+
+(defn start-routes!
+ []
+ (rfe/start! routes on-navigate {:use-fragment false}))
diff --git a/src/frontend/tubo/subs.cljs b/src/frontend/tubo/subs.cljs
new file mode 100644
index 0000000..88291ca
--- /dev/null
+++ b/src/frontend/tubo/subs.cljs
@@ -0,0 +1,98 @@
+(ns tubo.subs
+ (:require
+ [re-frame.core :as rf]))
+
+(rf/reg-sub
+ :http-response
+ (fn [db _]
+ (:http-response db)))
+
+(rf/reg-sub
+ :search-results
+ (fn [db _]
+ (:search-results db)))
+
+(rf/reg-sub
+ :stream
+ (fn [db _]
+ (:stream db)))
+
+(rf/reg-sub
+ :stream-format
+ (fn [db _]
+ (:stream-format db)))
+
+(rf/reg-sub
+ :playlist
+ (fn [db _]
+ (:playlist db)))
+
+(rf/reg-sub
+ :channel
+ (fn [db _]
+ (:channel db)))
+
+(rf/reg-sub
+ :global-search
+ (fn [db _]
+ (:global-search db)))
+
+(rf/reg-sub
+ :service-id
+ (fn [db _]
+ (:service-id db)))
+
+(rf/reg-sub
+ :service-color
+ (fn [db _]
+ (:service-color db)))
+
+(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/reg-sub
+ :current-match
+ (fn [db _]
+ (:current-match db)))
+
+(rf/reg-sub
+ :page-scroll
+ (fn [db _]
+ (:page-scroll db)))
+
+(rf/reg-sub
+ :global-stream
+ (fn [db _]
+ (:global-stream db)))
+
+(rf/reg-sub
+ :show-global-player
+ (fn [db _]
+ (:show-global-player db)))
+
+(rf/reg-sub
+ :show-page-loading
+ (fn [db _]
+ (:show-page-loading db)))
+
+(rf/reg-sub
+ :show-pagination-loading
+ (fn [db _]
+ (:show-pagination-loading db)))
+
+(rf/reg-sub
+ :show-mobile-nav
+ (fn [db _]
+ (:show-mobile-nav db)))
diff --git a/src/frontend/tubo/util.cljs b/src/frontend/tubo/util.cljs
new file mode 100644
index 0000000..8e03f85
--- /dev/null
+++ b/src/frontend/tubo/util.cljs
@@ -0,0 +1,24 @@
+(ns tubo.util
+ (:require
+ ["timeago.js" :as timeago]))
+
+(defn format-date
+ [date]
+ (if (-> date js/Date.parse js/isNaN)
+ date
+ (timeago/format date)))
+
+(defn format-quantity
+ [num]
+ (.format
+ (js/Intl.NumberFormat
+ "en-US" #js {"notation" "compact" "maximumFractionDigits" 1})
+ num))
+
+(defn format-duration
+ [num]
+ (let [duration (js/Date. (* num 1000))
+ slice (if (> (.getHours duration) 1)
+ #(.slice % 11 19)
+ #(.slice % 14 19))]
+ (-> duration (.toISOString) slice)))
diff --git a/src/frontend/tubo/views.cljs b/src/frontend/tubo/views.cljs
new file mode 100644
index 0000000..4fe2202
--- /dev/null
+++ b/src/frontend/tubo/views.cljs
@@ -0,0 +1,99 @@
+(ns tubo.views
+ (:require
+ [reitit.frontend.easy :as rfe]
+ [re-frame.core :as rf]
+ [reagent.ratom :as ratom]
+ [tubo.components.navigation :as navigation]
+ [tubo.components.player :as player]
+ [tubo.events :as events]
+ [tubo.routes :as routes]))
+
+(defonce scroll-hook (.addEventListener js/window "scroll" #(rf/dispatch [::events/page-scroll])))
+(defonce mobile-touch-hook (.addEventListener js/document.body "touchmove" #(rf/dispatch [::events/page-scroll])))
+(defonce services (rf/dispatch [::events/get-services]))
+(defonce kiosks (rf/dispatch [::events/get-kiosks 0]))
+
+(defn navbar
+ [{{:keys [serviceId]} :query-params}]
+ (let [service-id @(rf/subscribe [:service-id])
+ service-color @(rf/subscribe [:service-color])
+ global-search @(rf/subscribe [:global-search])
+ services @(rf/subscribe [:services])
+ id (js/parseInt (or serviceId service-id))
+ mobile-nav? @(rf/subscribe [:show-mobile-nav])
+ {:keys [available-kiosks default-kiosk]} @(rf/subscribe [:kiosks])]
+ [:nav.flex.px-2.py-2.5.items-center.sticky.top-0.z-50.font-nunito
+ {:style {:background service-color}}
+ [:div.flex.items-center.justify-between.flex-auto
+ [:div.py-2
+ [:a.px-5.text-white.font-bold
+ {:href (rfe/href ::routes/home)}
+ "tubo"]]
+ [:form.flex.items-center.relative
+ {:on-submit (fn [e]
+ (.preventDefault e)
+ (rf/dispatch [::events/navigate
+ {:name ::routes/search
+ :params {}
+ :query {:q global-search :serviceId service-id}}]))}
+ [:div
+ [:input.bg-transparent.border-none.rounded.py-2.px-1.focus:ring-transparent.placeholder-white.box-border.w-40.xs:w-auto
+ {:type "text"
+ :value global-search
+ :on-change #(rf/dispatch [::events/change-global-search (.. % -target -value)])
+ :placeholder "Search for something"}]]
+ [:div.flex.items-center.px-2
+ [:button.text-white
+ {:type "submit"}
+ [:i.fas.fa-search]]]]
+ [:div.cursor-pointer.px-2.ml:hidden
+ {:on-click #(rf/dispatch [::events/toggle-mobile-nav])}
+ [:i.fa-solid.fa-bars]]
+ [:div.bg-neutral-900.items-center.fixed.overflow-x-hidden.min-h-screen.w-60.top-0.shadow-xl.shadow-black.pt-8
+ {:class (str "ease-in-out delay-75 transition-[right] "
+ "ml:w-full ml:flex ml:min-h-0 ml:relative ml:text-white ml:bg-transparent ml:shadow-none ml:p-0 ml:right-0 "
+ (if mobile-nav? "right-0" "right-[-245px]"))}
+ [:div.cursor-pointer.px-2.ml:hidden.absolute.top-1.right-2
+ {:on-click #(rf/dispatch [::events/toggle-mobile-nav])}
+ [:i.fa-solid.fa-close.text-xl]]
+ [:div.relative.flex.flex-col.items-center.justify-center.ml:flex-row
+ [:div.w-full.box-border.z-10
+ [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.font-bold.font-nunito.px-5.w-full
+ {:on-change #(rf/dispatch [::events/change-service (js/parseInt (.. % -target -value))])
+ :value service-id
+ :style {:background "transparent"}}
+ (when services
+ (for [service services]
+ [:option.bg-neutral-900.border-none {:value (:id service) :key (:id service)}
+ (-> service :info :name)]))]]
+ [:div.flex.absolute.min-h-full.top-0.right-4.ml:right-0.items-center.justify-end.z-0
+ [:i.fa-solid.fa-caret-down]]]
+ [:div.relative.flex-auto.ml:pl-4
+ [:ul.flex.font-roboto.flex-col.ml:flex-row
+ (for [kiosk available-kiosks]
+ [:li.px-5.py-2 {:key kiosk}
+ [:a {:href (rfe/href ::routes/kiosk nil {:serviceId service-id
+ :kioskId kiosk})}
+ kiosk]])]]]]]))
+
+(defn footer
+ []
+ [:footer
+ [:div.bg-black.text-gray-300.p-5.text-center.w-full
+ [:div.flex.flex-col.justify-center.items-center
+ [:div.flex.items-center.justify-center
+ [:div.items-center
+ [:a.font-bold {:href "https://sr.ht/~conses/tubo"} "tubo"]]
+ [:div
+ [:p.px-2 (str "2022-" (.getFullYear (js/Date.)))]]]]]])
+
+(defn app
+ []
+ (let [current-match @(rf/subscribe [:current-match])]
+ [:div.min-h-screen.flex.flex-col.h-full.text-white.bg-neutral-900.relative
+ [navbar current-match]
+ [:div.flex.flex-col.justify-between.relative.font-nunito {:class "min-h-[calc(100vh-58px)]"}
+ (when-let [view (-> current-match :data :view)]
+ [view current-match])
+ [footer]
+ [player/global-player]]]))
diff --git a/src/frontend/tubo/views/channel.cljs b/src/frontend/tubo/views/channel.cljs
new file mode 100644
index 0000000..ee5459f
--- /dev/null
+++ b/src/frontend/tubo/views/channel.cljs
@@ -0,0 +1,41 @@
+(ns tubo.views.channel
+ (:require
+ [re-frame.core :as rf]
+ [tubo.components.items :as items]
+ [tubo.components.loading :as loading]
+ [tubo.components.navigation :as navigation]
+ [tubo.events :as events]))
+
+(defn channel
+ [{{:keys [url]} :query-params}]
+ (let [{:keys [banner avatar name description subscriber-count
+ related-streams next-page]} @(rf/subscribe [:channel])
+ next-page-url (:url next-page)
+ service-color @(rf/subscribe [:service-color])
+ page-loading? @(rf/subscribe [:show-page-loading])
+ pagination-loading? @(rf/subscribe [:show-pagination-loading])
+ page-scroll @(rf/subscribe [:page-scroll])
+ scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+ (when scrolled-to-bottom?
+ (rf/dispatch [::events/channel-pagination url next-page-url]))
+ [:div.flex.flex-col.items-center.px-5.py-2.flex-auto
+ (if page-loading?
+ [loading/loading-icon service-color "text-5xl"]
+ [:div.flex.flex-col.flex-auto {:class "ml:w-4/5 xl:w-3/5"}
+ [navigation/back-button service-color]
+ (when banner
+ [:div.flex.justify-center
+ [:img {:src banner}]])
+ [:div.flex.items-center.my-4.mx-2
+ (when avatar
+ [:div.relative.w-16.h-16
+ [:img.rounded-full.object-cover.max-w-full.min-h-full {:src avatar :alt name}]])
+ [:div.m-4
+ [:h1.text-xl name]
+ (when subscriber-count
+ [:div.flex.my-2.items-center
+ [:i.fa-solid.fa-users.text-xs]
+ [:span.mx-2 (.toLocaleString subscriber-count)]])]]
+ [:div.my-2
+ [:p 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
new file mode 100644
index 0000000..1c072af
--- /dev/null
+++ b/src/frontend/tubo/views/kiosk.cljs
@@ -0,0 +1,26 @@
+(ns tubo.views.kiosk
+ (:require
+ [re-frame.core :as rf]
+ [tubo.components.items :as items]
+ [tubo.components.loading :as loading]
+ [tubo.components.navigation :as navigation]
+ [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])
+ page-loading? @(rf/subscribe [:show-page-loading])
+ page-scroll @(rf/subscribe [:page-scroll])
+ scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+ (when scrolled-to-bottom?
+ (rf/dispatch [::events/kiosk-pagination serviceId id next-page-url]))
+ [:div.flex.flex-col.items-center.px-5.py-2.flex-auto
+ (if page-loading?
+ [loading/loading-icon service-color "text-5xl"]
+ [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"}
+ [:div.flex.justify-center.items-center.my-4.mx-2
+ [:div.m-4
+ [:h1.text-2xl id]]]
+ [items/related-streams related-streams next-page-url]])]))
diff --git a/src/frontend/tubo/views/playlist.cljs b/src/frontend/tubo/views/playlist.cljs
new file mode 100644
index 0000000..ed4f90f
--- /dev/null
+++ b/src/frontend/tubo/views/playlist.cljs
@@ -0,0 +1,40 @@
+(ns tubo.views.playlist
+ (:require
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.components.items :as items]
+ [tubo.components.loading :as loading]
+ [tubo.components.navigation :as navigation]
+ [tubo.events :as events]))
+
+(defn playlist
+ [{{:keys [url]} :query-params}]
+ (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)
+ service-color @(rf/subscribe [:service-color])
+ page-loading? @(rf/subscribe [:show-page-loading])
+ page-scroll @(rf/subscribe [:page-scroll])
+ scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+ (when scrolled-to-bottom?
+ (rf/dispatch [::events/playlist-pagination url next-page-url]))
+ [:div.flex.flex-col.items-center.px-5.pt-4.flex-auto
+ (if page-loading?
+ [loading/loading-icon service-color "text-5xl"]
+ [:div.flex.flex-col.flex-auto.w-full {:class "ml:w-4/5"}
+ [navigation/back-button service-color]
+ (when banner-url
+ [:div
+ [:img {:src banner-url}]])
+ [:div.flex.items-center.justify-center.my-4.mx-2
+ [:div.flex.flex-col.justify-center.items-center
+ [:h1.text-2xl.font-bold name]
+ [:div.flex.items-center.pt-4
+ [:span.mr-2 "By"]
+ [:div.flex.items-center.py-3.box-border.h-12
+ [:div.w-12
+ [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name}
+ [:img.rounded-full.object-cover.min-h-full.min-w-full {:src uploader-avatar :alt uploader-name}]]]]]
+ [:p.pt-4 (str stream-count " streams")]]]
+ [items/related-streams related-streams next-page-url]])]))
diff --git a/src/frontend/tubo/views/search.cljs b/src/frontend/tubo/views/search.cljs
new file mode 100644
index 0000000..9938f53
--- /dev/null
+++ b/src/frontend/tubo/views/search.cljs
@@ -0,0 +1,28 @@
+(ns tubo.views.search
+ (:require
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.components.items :as items]
+ [tubo.components.loading :as loading]
+ [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 @(rf/subscribe [:service-id])
+ service-color @(rf/subscribe [:service-color])
+ page-scroll @(rf/subscribe [:page-scroll])
+ page-loading? @(rf/subscribe [:show-page-loading])
+ scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+ (when scrolled-to-bottom?
+ (rf/dispatch [::events/search-pagination q serviceId next-page-url]))
+ [:div.flex.flex-col.items-center.flex-auto
+ [:div.flex.flex-col.items-center.w-full.pt-4.flex-initial
+ [:h2 (str "Showing search results for: \"" q "\"")]
+ [:h1 (str "Number of search results: " (count items))]]
+ (if page-loading?
+ [loading/loading-icon service-color "text-5xl"]
+ [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"}
+ [items/related-streams items next-page-url]])]))
diff --git a/src/frontend/tubo/views/stream.cljs b/src/frontend/tubo/views/stream.cljs
new file mode 100644
index 0000000..a380b5e
--- /dev/null
+++ b/src/frontend/tubo/views/stream.cljs
@@ -0,0 +1,122 @@
+(ns tubo.views.stream
+ (:require
+ [re-frame.core :as rf]
+ [reitit.frontend.easy :as rfe]
+ [tubo.events :as events]
+ [tubo.components.items :as items]
+ [tubo.components.loading :as loading]
+ [tubo.components.navigation :as navigation]
+ [tubo.components.comments :as comments]
+ [tubo.components.player :as player]
+ [tubo.util :as util]))
+
+(defn stream
+ [match]
+ (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 service-id] :as stream} @(rf/subscribe [:stream])
+ available-streams (apply conj audio-streams video-streams)
+ {:keys [content id] :as stream-format} @(rf/subscribe [:stream-format])
+ page-loading? @(rf/subscribe [:show-page-loading])
+ service-color @(rf/subscribe [:service-color])]
+ [:div.flex.flex-col.items-center.justify-center.text-white.flex-auto
+ (if page-loading?
+ [loading/loading-icon service-color "text-5xl"]
+ [:div.w-full.pb-4.relative {:class "ml:w-4/5 xl:w-3/5"}
+ [navigation/back-button service-color]
+ [:div.flex.justify-center.relative
+ {:class "ml:h-[450px] lg:h-[600px]"}
+ (when stream-format
+ [player/stream-player {"sources" [{"src" content "type" "video/mp4"}
+ {"src" content "type" "video/webm"}]
+ "poster" thumbnail-url
+ "controls" true}
+ content])]
+ [:div.px-4.ml:p-0
+ [:div.flex.flex.w-full.mt-3
+ (when stream-format
+ [:div.relative.flex.bg-stone-800.flex-col.items-center.justify-center.z-10.mr-2.border.rounded.border-black
+ [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.pl-4.pr-8.w-full
+ {:on-change #(rf/dispatch [::events/change-stream-format (.. % -target -value)])
+ :value id
+ :style {:background "transparent"}}
+ (when available-streams
+ (for [[i {:keys [id format resolution averageBitrate]}] (map-indexed vector available-streams)]
+ [:option.bg-neutral-900.border-none {:value id :key i}
+ (str (or resolution "audio-only") " " format (when-not resolution (str " " averageBitrate "kbit/s")))]))]
+ [:div.flex.absolute.min-h-full.top-0.right-4.items-center.justify-end
+ {:style {:zIndex "-1"}}
+ [:i.fa-solid.fa-caret-down]]])
+ [:button.border.rounded.border-black.px-3.py-1.bg-stone-800
+ {:on-click #(rf/dispatch [::events/switch-to-global-player
+ {:uploader-name uploader-name :uploader-url uploader-url
+ :name name :url url :stream content :service-color service-color}])}
+ [:i.fa-solid.fa-headphones]]
+ [:button.border.rounded.border-black.px-3.py-1.bg-stone-800.mx-2
+ [:a {:href url}
+ [:i.fa-solid.fa-external-link-alt]]]]
+ [:div.flex.flex-col.py-1.comments
+ [:div.min-w-full.py-3
+ [:h1.text-2xl.font-extrabold.line-clamp-1 name]]
+ [:div.flex.justify-between.py-2
+ [:div.flex.items-center.flex-auto
+ (when uploader-avatar
+ [:div.relative.w-16.h-16
+ [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name}
+ [:img.rounded-full.object-cover.max-w-full.min-h-full {:src uploader-avatar :alt uploader-name}]]])
+ [:div.mx-2
+ [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url})} uploader-name]
+ (when subscriber-count
+ [:div.flex.my-2.items-center
+ [:i.fa-solid.fa-users.text-xs]
+ [:p.mx-2 (util/format-quantity subscriber-count)]])]]
+ [:div.flex.flex-col.items-end
+ (when view-count
+ [:div
+ [:i.fa-solid.fa-eye.text-xs]
+ [:span.ml-2 (.toLocaleString view-count)]])
+ [:div.flex
+ (when like-count
+ [:div.items-center
+ [:i.fa-solid.fa-thumbs-up.text-xs]
+ [:span.ml-2 (.toLocaleString like-count)]])
+ (when dislike-count
+ [:div.ml-2.items-center
+ [:i.fa-solid.fa-thumbs-down.text-xs]
+ [:span.ml-2 dislike-count]])]
+ (when upload-date
+ [:div
+ [:i.fa-solid.fa-calendar.mx-2.text-xs]
+ [:span
+ (-> upload-date
+ js/Date.parse
+ js/Date.
+ .toDateString)]])]]
+ [:div.min-w-full.py-3
+ [:h1 name]
+ [:div {:dangerouslySetInnerHTML {:__html description}}]]
+ [:div.py-6
+ [:div.flex.items-center
+ [:i.fa-solid.fa-comments]
+ [:p.px-2.py-4 "Comments"]
+ (if show-comments
+ [:i.fa-solid.fa-chevron-up {:on-click #(rf/dispatch [::events/toggle-comments])
+ :style {:cursor "pointer"}}]
+ [:i.fa-solid.fa-chevron-down {:on-click #(if (or show-comments comments-page)
+ (rf/dispatch [::events/toggle-comments])
+ (rf/dispatch [::events/get-comments url]))
+ :style {:cursor "pointer"}}])]
+ [:div
+ (if show-comments-loading
+ [loading/loading-icon service-color "text-2xl"]
+ (when (and show-comments comments-page)
+ [comments/comments comments-page uploader-name uploader-avatar url]))]]
+ (when-not (empty? related-streams)
+ [:div.py-3
+ [:div.flex.items-center
+ [:i.fa-solid.fa-list]
+ [:h1.px-2.text-lg.bold "Related Results"]]
+ [items/related-streams related-streams nil]])]]])]))