Browse Source

feat(frontend): add common layout components for views

Miguel Ángel Moreno 1 year ago
parent
commit
ec7d69bb72

+ 6 - 4
src/frontend/tubo/components/comments.cljs

@@ -2,6 +2,7 @@
   (:require
    [re-frame.core :as rf]
    [reitit.frontend.easy :as rfe]
+   [tubo.components.layout :as layout]
    [tubo.components.loading :as loading]
    [tubo.events :as events]
    [tubo.util :as util]))
@@ -71,7 +72,8 @@
      (when (:url next-page)
        (if pagination-loading?
          (loading/loading-icon service-color)
-         [:button.flex.items-center.justify-center
-          {:on-click #(rf/dispatch [::events/comments-pagination url (:url next-page)])}
-          [:i.fa-solid.fa-plus]
-          [:p.px-2 "Show more comments"]]))]))
+         [:div.flex.justify-center
+          [layout/secondary-button
+           "Show more comments"
+           #(rf/dispatch [::events/comments-pagination url (:url next-page)])
+           "fa-solid fa-plus"]]))]))

+ 60 - 0
src/frontend/tubo/components/layout.cljs

@@ -0,0 +1,60 @@
+(ns tubo.components.layout
+  (:require
+   [re-frame.core :as rf]
+   [tubo.components.loading :as loading]))
+
+(defn logo []
+  [:img.mb-1
+   {:src   "/images/tubo.png"
+    :style {:maxHeight "25px" :maxWidth "40px"}
+    :title "Tubo"}])
+
+(defn focus-overlay [on-click-cb active?]
+  [:div.w-full.fixed.min-h-screen.right-0.top-0.transition-all.delay-75.ease-in-out
+   {:class    "bg-black/50"
+    :style    {:visibility (when-not active? "hidden")
+               :opacity    (if active? "1" "0")}
+    :on-click on-click-cb}])
+
+(defn content-container
+  [& children]
+  (let [page-loading? @(rf/subscribe [:show-page-loading])
+        service-color @(rf/subscribe [:service-color])]
+    [:div.flex.flex-col.flex-auto.items-center.px-5.py-4
+     (if page-loading?
+       [loading/loading-icon service-color "text-5xl"]
+       [: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 uploader-avatar
+  [source name & url]
+  (let [image [:img.rounded-full.object-cover.max-w-full.min-h-full {:src source :alt name}]]
+    (when source
+      [:div.relative.w-16.h-16.flex-auto.flex.items-center
+       (if url
+         [:a.flex-auto.flex {:href url :title name} image]
+         image)])))
+
+(defn primary-button
+  [label on-click-cb 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-cb}
+   (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}])])
+
+(defn secondary-button
+  [label on-click-cb left-icon right-icon]
+  [:button.dark:bg-transparent.bg-neutral-100.px-4.rounded-3xl.py-1.border.border-neutral-700.dark:border-stone-700.outline-none.focus:ring-transparent.whitespace-nowrap
+   {:on-click on-click-cb}
+   (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}])])

+ 5 - 11
src/frontend/tubo/views.cljs

@@ -3,8 +3,9 @@
    [reitit.frontend.easy :as rfe]
    [re-frame.core :as rf]
    [reagent.core :as r]
-   [tubo.components.navigation :as navigation]
    [tubo.components.audio-player :as player]
+   [tubo.components.layout :as layout]
+   [tubo.components.navigation :as navigation]
    [tubo.components.play-queue :as queue]
    [tubo.events :as events]
    [tubo.routes :as routes]))
@@ -15,18 +16,11 @@
 (defn mobile-nav
   [show-mobile-nav? service-id service-color services available-kiosks]
   [:<>
-   [:div.w-full.fixed.min-h-screen.right-0.top-0.transition-all.delay-75.ease-in-out
-    {:class    "bg-black/50"
-     :style    {:visibility (when-not show-mobile-nav? "hidden")
-                :opacity (if show-mobile-nav? "1" "0")}
-     :on-click #(rf/dispatch [::events/toggle-mobile-nav])}]
-   [:div.items-center.fixed.overflow-x-hidden.min-h-screen.w-60.top-0.ease-in-out.delay-75.bg-white.dark:bg-neutral-900
+   [layout/focus-overlay #(rf/dispatch [::events/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
     {:class (str "transition-[right] " (if show-mobile-nav? "right-0" "right-[-245px]"))}
     [:div.flex.justify-center.py-8.items-center.text-white {:style {:background service-color}}
-     [:img.mb-1
-      {:src   "/images/tubo.png"
-       :style {:maxHeight "25px" :maxWidth "40px"}
-       :title "Tubo"}]
+     [layout/logo]
      [:h3.text-3xl.font-bold.px-4.font-roboto "Tubo"]]
     [:div.relative.flex.flex-col.items-center-justify-center.text-white {:style {:background service-color}}
      [:div.w-full.box-border.z-10

+ 7 - 11
src/frontend/tubo/views/bookmarks.cljs

@@ -2,20 +2,16 @@
   (:require
    [re-frame.core :as rf]
    [tubo.components.items :as items]
-   [tubo.components.navigation :as navigation]
+   [tubo.components.layout :as layout]
    [tubo.events :as events]))
 
 (defn bookmarks-page
   []
   (let [service-color @(rf/subscribe [:service-color])
         bookmarks @(rf/subscribe [:bookmarks])]
-    [:div.flex.flex-col.items-center.px-5.py-2.flex-auto
-     [:div.flex.flex-col.flex-auto.w-full {:class "ml:w-4/5 xl:w-3/5"}
-      [navigation/back-button service-color]
-      [:div.flex.justify-between
-       [:h1.text-2xl.font-bold.py-6 "Bookmarks"]
-       [:button
-        {:on-click #(rf/dispatch [::events/enqueue-related-streams bookmarks service-color])}
-        [:i.fa-solid.fa-headphones]
-        [:span.ml-2.text-neutral-600.dark:text-neutral-300 "Background"]]]
-      [items/related-streams bookmarks]]]))
+    [layout/content-container
+     [:div.flex.justify-between.mt-6
+      [:h1.text-3xl.font-nunito-semibold "Bookmarks"]
+      [layout/primary-button "Enqueue"
+       #(rf/dispatch [::events/enqueue-related-streams bookmarks service-color]) "fa-solid fa-headphones"]]
+     [items/related-streams bookmarks]]))

+ 20 - 28
src/frontend/tubo/views/channel.cljs

@@ -2,8 +2,8 @@
   (:require
    [re-frame.core :as rf]
    [tubo.components.items :as items]
+   [tubo.components.layout :as layout]
    [tubo.components.loading :as loading]
-   [tubo.components.navigation :as navigation]
    [tubo.events :as events]))
 
 (defn channel
@@ -17,30 +17,22 @@
         scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])]
     (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.justify-between
-         [: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.whitespace-nowrap.ml-4
-          [:button
-           {:on-click #(rf/dispatch [::events/enqueue-related-streams related-streams service-color])}
-           [:i.fa-solid.fa-headphones.mx-3]
-           [:span.text-neutral-600.dark:text-neutral-300 "Background"]]]]
-        [:div.my-2
-         [:p description]]
-        [items/related-streams related-streams next-page-url]])]))
+    [layout/content-container
+     (when banner
+       [:div.flex.justify-center
+        [:img.min-w-full {: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-xl name]
+        (when subscriber-count
+          [:div.flex.my-2.items-center
+           [:i.fa-solid.fa-users.text-xs]
+           [:span.mx-2 (.toLocaleString subscriber-count)]])]]
+      [layout/primary-button "Enqueue"
+       #(rf/dispatch [::events/enqueue-related-streams related-streams service-color])
+       "fa-solid fa-headphones"]]
+     [:div.my-2
+      [:p description]]
+     [items/related-streams related-streams next-page-url]]))

+ 5 - 10
src/frontend/tubo/views/kiosk.cljs

@@ -2,8 +2,8 @@
   (:require
    [re-frame.core :as rf]
    [tubo.components.items :as items]
+   [tubo.components.layout :as layout]
    [tubo.components.loading :as loading]
-   [tubo.components.navigation :as navigation]
    [tubo.events :as events]))
 
 (defn kiosk
@@ -11,15 +11,10 @@
   (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])
         scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])]
     (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 xl:w-3/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]])]))
+    [layout/content-container
+     [:div.flex.items-center.mt-6.mx-2
+      [:h1.text-3xl.font-nunito-semibold id]]
+     [items/related-streams related-streams next-page-url]]))

+ 15 - 29
src/frontend/tubo/views/playlist.cljs

@@ -3,8 +3,8 @@
    [re-frame.core :as rf]
    [reitit.frontend.easy :as rfe]
    [tubo.components.items :as items]
+   [tubo.components.layout :as layout]
    [tubo.components.loading :as loading]
-   [tubo.components.navigation :as navigation]
    [tubo.events :as events]))
 
 (defn playlist
@@ -14,34 +14,20 @@
                 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])
         scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])]
     (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 xl:w-3/5"}
-        [navigation/back-button service-color]
-        (when banner-url
-          [:div
-           [:img {:src banner-url}]])
-        [:div.flex.flex-col.justify-center.my-4.mx-2
-         [:div.flex.justify-between.items-center.mb-4
-          [:div
-           [:h1.text-2xl.font-bold.line-clamp-1 {:title name} name]]
-          [:div.whitespace-nowrap.ml-4
-           [:button
-            {:on-click #(rf/dispatch [::events/enqueue-related-streams related-streams service-color])}
-            [:i.mx-3.fa-solid.fa-headphones]
-            [:span.text-neutral-600.dark:text-neutral-300 "Background"]]]]
-         [:div.flex.items-center.justify-between
-          [:div.flex.items-center.my-4.mr-2
-           [:div.flex.items-center.py-3.pr-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}]]]]
-           [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name}
-            uploader-name]]
-          [:span.ml-2.whitespace-nowrap (str stream-count " streams")]]]
-        [items/related-streams related-streams next-page-url]])]))
+    [layout/content-container
+     [:div.flex.flex-col.justify-center.my-4.mx-2
+      [:div.flex.justify-between.items-center.mb-4
+       [:h1.text-2xl.font-bold.line-clamp-1.pr-2 {:title name} name]
+       [layout/primary-button "Enqueue"
+        #(rf/dispatch [::events/enqueue-related-streams related-streams service-color]) "fa-solid fa-headphones"]]
+      [:div.flex.items-center.justify-between
+       [:div.flex.items-center.my-4.mr-2
+        [layout/uploader-avatar uploader-avatar uploader-name uploader-url]
+        [:div
+         [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name}
+          uploader-name]]]
+       [:span.ml-2.whitespace-nowrap (str stream-count " streams")]]]
+     [items/related-streams related-streams next-page-url]]))

+ 3 - 7
src/frontend/tubo/views/search.cljs

@@ -4,6 +4,7 @@
    [reitit.frontend.easy :as rfe]
    [tubo.components.items :as items]
    [tubo.components.loading :as loading]
+   [tubo.components.layout :as layout]
    [tubo.events :as events]))
 
 (defn search
@@ -12,13 +13,8 @@
         next-page-url (:url next-page)
         services @(rf/subscribe [:services])
         service-id @(rf/subscribe [:service-id])
-        service-color @(rf/subscribe [:service-color])
-        page-loading? @(rf/subscribe [:show-page-loading])
         scrolled-to-bottom? @(rf/subscribe [:scrolled-to-bottom])]
     (when scrolled-to-bottom?
       (rf/dispatch [::events/search-pagination q serviceId next-page-url]))
-    [:div.flex.flex-col.items-center.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 xl:w-3/5"}
-        [items/related-streams items next-page-url]])]))
+    [layout/content-container
+     [items/related-streams items next-page-url]]))

+ 22 - 20
src/frontend/tubo/views/settings.cljs

@@ -1,8 +1,8 @@
 (ns tubo.views.settings
   (:require
    [re-frame.core :as rf]
-   [tubo.events :as events]
-   [tubo.components.navigation :as navigation]))
+   [tubo.components.layout :as layout]
+   [tubo.events :as events]))
 
 (defn boolean-input
   [label key value]
@@ -10,26 +10,28 @@
    [:label label]
    [:input
     {:type      "checkbox"
-     :checked value
-     :value value
+     :checked   value
+     :value     value
      :on-change #(rf/dispatch [::events/change-setting key (not value)])}]])
 
+(defn select-input
+  [label key value options]
+  [:div.w-full.flex.justify-between.items-center.py-2
+   [:label label]
+   [:select.focus:ring-transparent.bg-transparent.font-bold.font-nunito
+    {:value     value
+     :on-change #(rf/dispatch [::events/change-setting key (.. % -target -value)])}
+    (for [[i option] (map-indexed vector options)]
+      [:option.dark:bg-neutral-900.border-none {:value option :key i} option])]])
+
 (defn settings-page []
   (let [{:keys [current-theme themes show-comments show-related
                 show-description]} @(rf/subscribe [:settings])
-        service-color @(rf/subscribe [:service-color])]
-    [:div.flex.flex-col.items-center.px-5.py-2.flex-auto
-     [:div.flex.flex-col.flex-auto.w-full {:class "ml:w-4/5 xl:w-3/5"}
-      [navigation/back-button service-color]
-      [:h1.text-2xl.font-bold.py-6 "Settings"]
-      [:form.flex.flex-wrap
-       [:div.w-full.flex.justify-between.items-center.py-2
-        [:label "Theme"]
-        [:select.focus:ring-transparent.bg-transparent.font-bold.font-nunito
-         {:value     current-theme
-          :on-change #(rf/dispatch [::events/change-setting :current-theme (.. % -target -value)])}
-         (for [[i theme] (map-indexed vector themes)]
-           [:option.dark:bg-neutral-900.border-none {:value theme :key i} theme])]]
-       [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]]]]))
+        service-color              @(rf/subscribe [:service-color])]
+    [layout/content-container
+     [:h1.text-2xl.font-bold.py-6 "Settings"]
+     [:form.flex.flex-wrap
+      [select-input "Theme" :current-theme current-theme themes]
+      [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]]]))

+ 116 - 124
src/frontend/tubo/views/stream.cljs

@@ -4,8 +4,8 @@
    [reitit.frontend.easy :as rfe]
    [tubo.events :as events]
    [tubo.components.items :as items]
+   [tubo.components.layout :as layout]
    [tubo.components.loading :as loading]
-   [tubo.components.navigation :as navigation]
    [tubo.components.comments :as comments]
    [tubo.components.video-player :as player]
    [tubo.util :as util]))
@@ -26,126 +26,118 @@
         page-loading? @(rf/subscribe [:show-page-loading])
         service-color @(rf/subscribe [:service-color])
         bookmarks @(rf/subscribe [:bookmarks])]
-    [:div.flex.flex-col.items-center.justify-center.dark:text-white.flex-auto
-     (if page-loading?
-       [loading/loading-icon service-color "text-5xl"]
-       [:div.w-full.pb-4.relative.w-full {:class "ml:w-4/5 xl:w-3/5"}
-        [navigation/back-button service-color]
-        [:div.flex.justify-center.relative
-         {:class "h-[300px] ml:h-[450px] lg:h-[600px]"}
-         (when stream-format
-           [player/player {"sources"    [{"src" content "type" "video/mp4"}
-                                         {"src" content "type" "video/webm"}]
-                           "poster"     thumbnail-url
-                           "controls"   true
-                           "responsive" true
-                           "fill"       true}
-            content])]
-        [:div.px-4.ml:p-0.overflow-x-hidden
-         [:div.flex.flex.w-full.my-4.justify-center
-          [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
-           {:on-click #(rf/dispatch [::events/switch-to-audio-player stream service-color])}
-           [:i.fa-solid.fa-headphones]
-           [:span.mx-3 "Background"]]
-          (if (some #(= (:url %) url) bookmarks)
-            [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
-             {:on-click #(rf/dispatch [::events/remove-from-bookmarks stream])}
-             [:i.fa-solid.fa-bookmark]
-             [:span.mx-3 "Bookmarked"]]
-            [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
-             {:on-click #(rf/dispatch [::events/add-to-bookmarks stream])}
-             [:i.fa-regular.fa-bookmark]
-             [:span.mx-3 "Bookmark"]])
-          [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
-           [:a.block.sm:inline-block {:href url}
-            [:i.fa-solid.fa-external-link-alt]]
-           [:span.mx-3 "Original"]]
-          (when stream-format
-            [:div.relative.flex.flex-col.items-center.justify-center.text-neutral-600.dark:text-neutral-300
-             [:select.border-none.focus:ring-transparent.dark:bg-blend-color-dodge.pr-8.w-full.text-ellipsis.text-sm.sm:text-base
-              {: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.dark: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
-              [:i.fa-solid.fa-caret-down]]])]
-         [:div.flex.flex-col
-          [:div.min-w-full.pb-3
-           [:h1.text-2xl.font-extrabold.line-clamp-1 name]]
-          [:div.flex.justify-between.py-2
-           [:div.flex.items-center
-            (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-3
-             [: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.flex-auto
-            (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.mx-2]
-               [:span
-                (-> upload-date
-                    js/Date.parse
-                    js/Date.
-                    .toDateString)]])]]
-          (when (and show-description? description)
-            [:div.py-3.flex.flex-wrap.min-w-full
-             [:div {:dangerouslySetInnerHTML {:__html description}
-                    :class (when (not show-description) "line-clamp-2")}]
-             [:div.flex.justify-center.font-bold.min-w-full.pt-4.cursor-pointer
-              [:button
-               {:on-click #(rf/dispatch [::events/toggle-stream-layout :show-description])}
-               (if (not show-description) "Show More" "Show Less")]]])
-          (when (and comments-page (not (empty? (:comments comments-page))) show-comments?)
-            [: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.cursor-pointer
-                 {:on-click #(rf/dispatch [::events/toggle-stream-layout :show-comments])}]
-                [:i.fa-solid.fa-chevron-down.cursor-pointer
-                 {:on-click #(if (or show-comments comments-page)
-                               (rf/dispatch [::events/toggle-stream-layout :show-comments])
-                               (rf/dispatch [::events/get-comments url]))}])]
-             [: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 (and show-related? (not (empty? related-streams)))
-            [:div.py-6
-             [:div.flex.justify-between
-              [:div.flex.items-center.text-sm.sm:text-base
-               [:i.fa-solid.fa-list]
-               [:h1.px-2.text-lg.bold "Suggested"]
-               [:i.fa-solid.fa-chevron-up.cursor-pointer
-                {:class    (if (not show-related) "fa-chevron-up" "fa-chevron-down")
-                 :on-click #(rf/dispatch [::events/toggle-stream-layout :show-related])}]]
-              [:button
-               {:on-click #(rf/dispatch [::events/enqueue-related-streams related-streams service-color])}
-               [:i.mx-2.fa-solid.fa-headphones]
-               [:span.text-neutral-600.dark:text-neutral-300 "Background"]]]
-             (when (not show-related)
-               [items/related-streams related-streams nil])])]]])]))
+    [layout/content-container
+     [:div.flex.justify-center.relative
+      {:class "h-[300px] lg:h-[450px] lg:h-[600px]"}
+      (when stream-format
+        [player/player {"sources"    [{"src" content "type" "video/mp4"}
+                                      {"src" content "type" "video/webm"}]
+                        "poster"     thumbnail-url
+                        "controls"   true
+                        "responsive" true
+                        "fill"       true}
+         content])]
+     [:div.px-4.lg:p-0.overflow-x-hidden
+      [:div.flex.flex.w-full.my-4.justify-center
+       [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
+        {:on-click #(rf/dispatch [::events/switch-to-audio-player stream service-color])}
+        [:i.fa-solid.fa-headphones]
+        [:span.mx-3 "Background"]]
+       (if (some #(= (:url %) url) bookmarks)
+         [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
+          {:on-click #(rf/dispatch [::events/remove-from-bookmarks stream])}
+          [:i.fa-solid.fa-bookmark]
+          [:span.mx-3 "Bookmarked"]]
+         [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
+          {:on-click #(rf/dispatch [::events/add-to-bookmarks stream])}
+          [:i.fa-regular.fa-bookmark]
+          [:span.mx-3 "Bookmark"]])
+       [:button.sm:px-2.py-1.text-sm.sm:text-base.text-neutral-600.dark:text-neutral-300
+        [:a.block.sm:inline-block {:href url}
+         [:i.fa-solid.fa-external-link-alt]
+         [:span.mx-3 "Original"]]]
+       (when stream-format
+         [:div.relative.flex.flex-col.items-center.justify-center.text-neutral-600.dark:text-neutral-300
+          [:select.border-none.focus:ring-transparent.dark:bg-blend-color-dodge.pr-8.w-full.text-ellipsis.text-sm.sm:text-base
+           {: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.dark: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
+           [:i.fa-solid.fa-caret-down]]])]
+      [:div.flex.flex-col
+       [:div.min-w-full.pb-3
+        [:h1.text-2xl.font-extrabold.line-clamp-1 name]]
+       [:div.flex.justify-between.py-2
+        [: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 {: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.flex-auto
+         (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.mx-2]
+            [:span
+             (-> upload-date
+                 js/Date.parse
+                 js/Date.
+                 .toDateString)]])]]
+       (when (and show-description? description)
+         [:div.py-3.flex.flex-wrap.min-w-full
+          [:div {:dangerouslySetInnerHTML {:__html description}
+                 :class                   (when (not show-description) "line-clamp-2")}]
+          [:div.flex.justify-center.font-bold.min-w-full.pt-4.cursor-pointer
+           [layout/secondary-button
+            (if (not show-description) "Show More" "Show Less")
+            #(rf/dispatch [::events/toggle-stream-layout :show-description])]]])
+       (when (and comments-page (not (empty? (:comments comments-page))) show-comments?)
+         [:div
+          [:div.flex.items-center
+           [:i.fa-solid.fa-comments.w-6]
+           [:h2.mx-4.text-lg "Comments"]
+           (if show-comments
+             [:i.fa-solid.fa-chevron-up.cursor-pointer.text-sm
+              {:on-click #(rf/dispatch [::events/toggle-stream-layout :show-comments])}]
+             [:i.fa-solid.fa-chevron-down.cursor-pointer.text-sm.ml-2
+              {:on-click #(if (or show-comments comments-page)
+                            (rf/dispatch [::events/toggle-stream-layout :show-comments])
+                            (rf/dispatch [::events/get-comments url]))}])]
+          (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 (and show-related? (not (empty? related-streams)))
+         [:div.pt-2
+          [:div.flex.justify-between
+           [:div.flex.items-center.text-sm.sm:text-base
+            [:i.fa-solid.fa-list.w-6]
+            [:h2.mx-4.text-lg "Suggested"]
+            [:i.fa-solid.fa-chevron-up.cursor-pointer.text-sm
+             {:class    (if (not show-related) "fa-chevron-up" "fa-chevron-down")
+              :on-click #(rf/dispatch [::events/toggle-stream-layout :show-related])}]]
+           [layout/primary-button "Enqueue"
+            #(rf/dispatch [::events/enqueue-related-streams related-streams service-color])
+            "fa-solid fa-headphones"]]
+          (when (not show-related)
+            [items/related-streams related-streams nil])])]]]))