Browse Source

feat: add playback settings and improve players

Miguel Ángel Moreno 3 months ago
parent
commit
4c226f9f05

+ 9 - 0
resources/src/styles/index.scss

@@ -20,3 +20,12 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
 @import "tailwindcss/base";
 @import "tailwindcss/components";
 @import "tailwindcss/utilities";
+
+media-time-range {
+  --media-range-thumb-transform: scale(0) translate(0%, 0%);
+}
+
+media-time-range:hover {
+  --media-range-track-height: 5px;
+  --media-range-thumb-transform: scale(1) translate(0%, 0%);
+}

+ 1 - 0
src/backend/tubo/api.clj

@@ -169,6 +169,7 @@
      :subscriber-count   (non-negative (.getUploaderSubscriberCount info))
      :audio-streams      (j/from-java (.getAudioStreams info))
      :video-streams      (j/from-java (.getVideoStreams info))
+     :video-only-streams (j/from-java (.getVideoOnlyStreams info))
      :hls-url            (.getHlsUrl info)
      :dash-mpd-url       (.getDashMpdUrl info)
      :preview-frames     (j/from-java (.getPreviewFrames info))

+ 14 - 0
src/frontend/tubo/bg_player/events.cljs

@@ -1,6 +1,7 @@
 (ns tubo.bg-player.events
   (:require
    [re-frame.core :as rf]
+   [tubo.player.utils :as utils]
    [vimsical.re-frame.cofx.inject :as inject]))
 
 (rf/reg-event-fx
@@ -26,6 +27,19 @@
  (fn [db [_ val]]
    (assoc db :bg-player/loading val)))
 
+(rf/reg-event-fx
+ :bg-player/set-src
+ [(rf/inject-cofx ::inject/sub [:bg-player])]
+ (fn [{:keys [bg-player]} [_ src pos]]
+   {:fx [[:dispatch
+          [:player/set-src {:src src :player bg-player :current-pos pos}]]]}))
+
+(rf/reg-event-fx
+ :bg-player/set-stream
+ (fn [{:keys [db]} [_ stream pos]]
+   (let [audio-stream (utils/get-audio-stream stream (:settings db))]
+     {:fx [[:dispatch [:bg-player/set-src audio-stream pos]]]})))
+
 (rf/reg-event-fx
  :bg-player/start
  [(rf/inject-cofx ::inject/sub [:bg-player])

+ 47 - 88
src/frontend/tubo/bg_player/views.cljs

@@ -1,47 +1,13 @@
 (ns tubo.bg-player.views
   (:require
    [re-frame.core :as rf]
-   [reagent.dom :as rdom]
    [reagent.core :as r]
    [reitit.frontend.easy :as rfe]
    [tubo.bookmarks.modals :as modals]
    [tubo.layout.views :as layout]
+   [tubo.player.views :as player]
    [tubo.utils :as utils]))
 
-(defn button
-  [& {:keys [icon on-click disabled? show-on-mobile? extra-classes]}]
-  [:button.outline-none.focus:ring-transparent
-   {: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 color show-on-mobile?]
-  [button
-   :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 shuffle-button
-  [shuffle? color show-on-mobile?]
-  [button
-   :icon
-   [:i.fa-solid.fa-shuffle {:style {:color (when shuffle? color)}}]
-   :on-click #(rf/dispatch [:queue/shuffle (not shuffle?)])
-   :extra-classes [:text-sm]
-   :show-on-mobile? show-on-mobile?])
-
 (defonce slider-classes
   ["h-2" "cursor-pointer" "appearance-none" "rounded-full"
    "overflow-hidden"
@@ -95,7 +61,7 @@
       [:div.relative.flex.flex-col.justify-center.items-center
        {:on-mouse-over #(reset! show-slider? true)
         :on-mouse-out  #(reset! show-slider? false)}
-       [button
+       [player/button
         :icon
         (if muted? [:i.fa-solid.fa-volume-xmark] [:i.fa-solid.fa-volume-low])
         :on-click #(rf/dispatch [:bg-player/mute (not muted?) player])]
@@ -140,15 +106,15 @@
         !elapsed-time    @(rf/subscribe [:elapsed-time])]
     [:div.flex.flex-col.items-center.ml-auto
      [:div.flex.justify-end.gap-x-4
-      [loop-button loop-playback color]
-      [button
+      [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)))]
-      [button
+      [player/button
        :icon [:i.fa-solid.fa-backward]
        :on-click #(rf/dispatch [:bg-player/seek (- @!elapsed-time 5)])]
-      [button
+      [player/button
        :icon
        (if (and (not loading?) (or (nil? bg-player-ready?) @!player))
          (if paused?
@@ -158,14 +124,14 @@
        :on-click #(rf/dispatch [:bg-player/pause (not (.-paused @!player))])
        :show-on-mobile? true
        :extra-classes ["lg:text-2xl"]]
-      [button
+      [player/button
        :icon [:i.fa-solid.fa-forward]
        :on-click #(rf/dispatch [:bg-player/seek (+ @!elapsed-time 5)])]
-      [button
+      [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))))]
-      [shuffle-button shuffle? color]]
+      [player/shuffle-button shuffle? color]]
      [:div.hidden.lg:flex.items-center.text-sm
       [:span.mx-2
        (if (and bg-player-ready? @!player @!elapsed-time)
@@ -232,63 +198,56 @@
         volume @(rf/subscribe [:player/volume])]
     [:div.flex.lg:justify-end.lg:flex-1.gap-x-2
      [volume-slider !player volume muted? color]
-     [button
+     [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]]
      [popover stream]]))
 
+(defn on-progress
+  [!player !buffered]
+  (let [len (.. @!player -buffered -length)]
+    (when (and (.-duration @!player) (> len 0))
+      (if (= (.end (.-buffered @!player) (- len 1)) (.-duration @!player))
+        (reset! !buffered 100)
+        (when (< (.start (.-buffered @!player) (- len 1))
+                 (.-currentTime @!player))
+          (reset! !buffered (* (/ (.end (.-buffered @!player) (- len 1))
+                                  (.-duration @!player))
+                               100)))))))
+
+(defn on-update
+  [!player !buffered !elapsed]
+  (when @(rf/subscribe [:bg-player/loading])
+    (rf/dispatch [:bg-player/set-loading false]))
+  (on-progress !player !buffered)
+  (reset! !elapsed (.-currentTime @!player)))
+
 (defn audio-player
   []
-  (let [!elapsed-time @(rf/subscribe [:elapsed-time])
-        queue-pos     @(rf/subscribe [:queue/position])
-        stream        @(rf/subscribe [:queue/current])
-        !buffered     @(rf/subscribe [:bg-player/buffered])]
+  (let [!elapsed  @(rf/subscribe [:elapsed-time])
+        pos       @(rf/subscribe [:queue/position])
+        stream    @(rf/subscribe [:queue/current])
+        !buffered @(rf/subscribe [:bg-player/buffered])]
     (r/create-class
      {:component-will-unmount #(rf/dispatch [:bg-player/ready false])
-      :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
-                    :audio-streams
-                    first
-                    :content))))
+      :component-did-mount #(rf/dispatch [:bg-player/set-stream stream pos])
       :reagent-render
       (fn [!player]
-        (let [on-progress
-              #(let [len (.. @!player -buffered -length)]
-                 (when (and (.-duration @!player) (> len 0))
-                   (if (= (.end (.-buffered @!player) (- len 1))
-                          (.-duration @!player))
-                     (reset! !buffered 100)
-                     (when (< (.start (.-buffered @!player) (- len 1))
-                              (.-currentTime @!player))
-                       (reset! !buffered (* (/ (.end (.-buffered @!player)
-                                                     (- len 1))
-                                               (.-duration @!player))
-                                            100))))))
-              on-update (fn []
-                          (when @(rf/subscribe [:bg-player/loading])
-                            (rf/dispatch [:bg-player/set-loading false]))
-                          (on-progress)
-                          (reset! !elapsed-time (.-currentTime @!player)))]
-          [:audio
-           {:ref            #(reset! !player %)
-            :preload        "metadata"
-            :on-waiting     #(rf/dispatch [:bg-player/set-loading true])
-            :loop           (= @(rf/subscribe [:player/loop]) :stream)
-            :muted          @(rf/subscribe [:player/muted])
-            :on-can-play    #(rf/dispatch [:bg-player/ready true])
-            :on-seeked      #(reset! !elapsed-time (.-currentTime @!player))
-            :on-progress    on-progress
-            :on-time-update on-update
-            :on-loaded-data #(rf/dispatch [:bg-player/start])
-            :on-play        #(rf/dispatch [:bg-player/set-paused false])
-            :on-pause       #(rf/dispatch [:bg-player/set-paused true])}]))})))
+        [:audio
+         {:ref            #(reset! !player %)
+          :preload        "metadata"
+          :on-waiting     #(rf/dispatch [:bg-player/set-loading true])
+          :loop           (= @(rf/subscribe [:player/loop]) :stream)
+          :muted          @(rf/subscribe [:player/muted])
+          :on-can-play    #(rf/dispatch [:bg-player/ready true])
+          :on-seeked      #(reset! !elapsed (.-currentTime @!player))
+          :on-progress    #(on-progress !player !buffered)
+          :on-time-update #(on-update !player !buffered !elapsed)
+          :on-loaded-data #(rf/dispatch [:bg-player/start])
+          :on-play        #(rf/dispatch [:bg-player/set-paused false])
+          :on-pause       #(rf/dispatch [:bg-player/set-paused true])}])})))
 
 (defn player
   []

+ 50 - 30
src/frontend/tubo/events.cljs

@@ -44,36 +44,56 @@
        :service-id       (if-nil (:service-id store) 0)
        :bookmarks        (if-nil (:bookmarks store)
                                  [{:id (nano-id) :name "Liked Streams"}])
-       :settings         {:theme            (if-nil (-> store
-                                                        :settings
-                                                        :theme)
-                                                    "auto")
-                          :show-comments    (if-nil (-> store
-                                                        :settings
-                                                        :show-comments)
-                                                    true)
-                          :show-related     (if-nil (-> store
-                                                        :settings
-                                                        :show-related)
-                                                    true)
-                          :show-description (if-nil (-> store
-                                                        :settings
-                                                        :show-description)
-                                                    true)
-                          :items-layout     "list"
-                          :default-country  (if-nil (-> store
-                                                        :settings
-                                                        :default-country)
-                                                    {0 {:name "United States"
-                                                        :code "US"}})
-                          :default-kiosk    (if-nil (-> store
-                                                        :settings
-                                                        :default-kiosk)
-                                                    {0 "Trending"})
-                          :default-service  (if-nil (-> store
-                                                        :settings
-                                                        :default-service)
-                                                    0)}}})))
+       :settings         {:theme                (if-nil (-> store
+                                                            :settings
+                                                            :theme)
+                                                        "auto")
+                          :show-comments        (if-nil (-> store
+                                                            :settings
+                                                            :show-comments)
+                                                        true)
+                          :show-related         (if-nil (-> store
+                                                            :settings
+                                                            :show-related)
+                                                        true)
+                          :show-description     (if-nil (-> store
+                                                            :settings
+                                                            :show-description)
+                                                        true)
+                          :items-layout         "list"
+                          :default-resolution   (if-nil
+                                                 (-> store
+                                                     :settings
+                                                     :default-resolution)
+                                                 "720p")
+                          :default-video-format (if-nil
+                                                 (-> store
+                                                     :settings
+                                                     :default-video-format)
+                                                 "MPEG-4")
+                          :default-audio-format (if-nil
+                                                 (-> store
+                                                     :settings
+                                                     :default-audio-format)
+                                                 "m4a")
+                          :default-country      (if-nil (-> store
+                                                            :settings
+                                                            :default-country)
+                                                        {0 {:name
+                                                            "United States"
+                                                            :code "US"}})
+                          :default-kiosk        (if-nil (-> store
+                                                            :settings
+                                                            :default-kiosk)
+                                                        {0 "Trending"})
+                          :default-filter       (if-nil (-> store
+                                                            :settings
+                                                            :default-filter)
+                                                        {0 "all"})
+                          :default-service      (if-nil (-> store
+                                                            :settings
+                                                            :default-service)
+                                                        0)}}})))
 
 (rf/reg-fx
  :scroll-to-top

+ 25 - 9
src/frontend/tubo/main_player/events.cljs

@@ -1,6 +1,7 @@
 (ns tubo.main-player.events
   (:require
    [re-frame.core :as rf]
+   [tubo.player.utils :as utils]
    [vimsical.re-frame.cofx.inject :as inject]))
 
 (rf/reg-event-fx
@@ -12,10 +13,9 @@
 (rf/reg-event-fx
  :main-player/pause
  [(rf/inject-cofx ::inject/sub [:main-player])]
- (fn [{:keys [db main-player]} [_ paused?]]
-   (when (:main-player/ready db)
-     {:player/pause {:paused? (not paused?)
-                     :player  main-player}})))
+ (fn [{:keys [main-player]} [_ paused?]]
+   {:player/pause {:paused? (not paused?)
+                   :player  main-player}}))
 
 (rf/reg-event-fx
  :main-player/play
@@ -24,13 +24,29 @@
    {:fx [(when (and (:bg-player/ready db) main-player @main-player)
            [:dispatch [:bg-player/pause true]])]}))
 
+(rf/reg-event-fx
+ :main-player/set-src
+ [(rf/inject-cofx ::inject/sub [:main-player])]
+ (fn [{:keys [main-player]} [_ src pos]]
+   {:fx [[:dispatch
+          [:player/set-src {:src src :player main-player :current-pos pos}]]]}))
+
+(rf/reg-event-fx
+ :main-player/set-stream
+ (fn [{:keys [db]} [_ stream pos]]
+   (let [video-stream (utils/get-video-stream stream (:settings db))]
+     {:fx [[:dispatch [:main-player/set-src video-stream pos]]]})))
+
 (rf/reg-event-fx
  :main-player/start
- [(rf/inject-cofx ::inject/sub [:elapsed-time])]
- (fn [{:keys [db elapsed-time]}]
-   {:fx [[:dispatch [:main-player/pause false]]
-         (when (and (:main-player/show db) (not (:bg-player/ready db)))
-           [:dispatch [:main-player/seek @elapsed-time]])]}))
+ [(rf/inject-cofx ::inject/sub [:main-player])
+  (rf/inject-cofx ::inject/sub [:elapsed-time])]
+ (fn [{:keys [db main-player elapsed-time]}]
+   {:fx (into [[:dispatch [:main-player/pause false]]]
+              (when (and (:main-player/show db) (not (:bg-player/ready db)))
+                [[:dispatch [:main-player/seek @elapsed-time]]
+                 [:dispatch
+                  [:player/change-volume (:player/volume db) main-player]]]))}))
 
 (rf/reg-event-db
  :main-player/ready

+ 47 - 18
src/frontend/tubo/main_player/views.cljs

@@ -1,33 +1,62 @@
 (ns tubo.main-player.views
   (:require
    [re-frame.core :as rf]
+   [tubo.bg-player.views :as bg-player]
    [tubo.layout.views :as layout]
    [tubo.player.views :as player]
    [tubo.queue.views :as queue]
    [tubo.stream.views :as stream]))
 
+(defn player-args
+  [!player]
+  (let [!elapsed @(rf/subscribe [:elapsed-time])]
+    {:muted          @(rf/subscribe [:player/muted])
+     :on-can-play    #(rf/dispatch [:main-player/ready true])
+     :on-play        #(rf/dispatch [:main-player/play])
+     :on-loaded-data #(rf/dispatch [:main-player/start])
+     :on-time-update #(reset! !elapsed (.-currentTime @!player))
+     :on-seeked      #(reset! !elapsed (.-currentTime @!player))
+     :loop           (= @(rf/subscribe [:player/loop]) :stream)}))
+
 (defn player
   []
-  (let [queue        @(rf/subscribe [:queue])
-        queue-pos    @(rf/subscribe [:queue/position])
-        bookmarks    @(rf/subscribe [:bookmarks])
-        !player      @(rf/subscribe [:main-player])
-        stream       @(rf/subscribe [:queue/current])
-        show-player? @(rf/subscribe [:main-player/show])]
+  (let [queue         @(rf/subscribe [:queue])
+        pos           @(rf/subscribe [:queue/position])
+        bookmarks     @(rf/subscribe [:bookmarks])
+        !player       @(rf/subscribe [:main-player])
+        stream        @(rf/subscribe [:queue/current])
+        show-player?  @(rf/subscribe [:main-player/show])
+        loop-playback @(rf/subscribe [:player/loop])
+        service-color @(rf/subscribe [:service-color])
+        shuffled      @(rf/subscribe [:player/shuffled])]
     [:div.fixed.w-full.bg-neutral-100.dark:bg-neutral-900.overflow-auto.z-10.transition-all.ease-in-out
      {:class ["h-[calc(100%-56px)]"
               (if show-player? "translate-y-0" "translate-y-full")]}
      (when (and show-player? stream)
-       [:div
+       [:div.flex.flex-col.items-center.justify-center
         [:div.flex.flex-col.items-center.w-full.xl:py-6
-         [player/video-player stream !player]]
-        [:div.flex.items-center.justify-center
-         [:div.flex.flex-col.gap-y-1.w-full.h-fit.max-h-64.overflow-y-auto
-          {:class ["lg:w-4/5" "xl:w-3/5"]}
-          (for [[i item] (map-indexed vector queue)]
-            ^{:key i} [queue/queue-item item queue queue-pos i bookmarks])]]
-        [layout/content-container
-         [stream/metadata stream]
-         [stream/description stream]
-         [stream/comments stream]
-         [stream/related-items stream]]])]))
+         [player/video-player stream !player (player-args !player)
+          #(rf/dispatch [:main-player/set-stream stream pos])]]
+        [:div.flex.flex-col.w-full.p-4
+         {:class ["lg:w-4/5" "xl:w-3/5"]}
+         [:div.border.border-neutral-700.rounded-md
+          [:div.p-5.flex.items-center.justify-between.bg-neutral-800.rounded-t-md.border-b.border-neutral-700
+           [:div.flex.flex-col
+            [:h4.font-bold.text-lg "Queue"]
+            [:span.text-xs.text-neutral-400.dark:text-neutral-500
+             (str pos "/" (count queue))]]
+           [:div.flex.items-center
+            [:div.px-4
+             [player/loop-button loop-playback service-color true]]
+            [:div.pl-4.pr-5
+             [player/shuffle-button shuffled service-color true]]
+            [bg-player/popover stream]]]
+          [:div.flex.flex-col.gap-y-1.w-full.h-fit.max-h-64.overflow-y-auto.relative.scroll-smooth
+           (for [[i item] (map-indexed vector queue)]
+             ^{:key i} [queue/queue-item item queue pos i bookmarks])]]]
+        [:div.w-full
+         [layout/content-container
+          [stream/metadata stream]
+          [stream/description stream]
+          [stream/comments stream]
+          [stream/related-items stream]]]])]))

+ 18 - 3
src/frontend/tubo/player/events.cljs

@@ -1,7 +1,8 @@
 (ns tubo.player.events
   (:require
    [goog.object :as gobj]
-   [re-frame.core :as rf]))
+   [re-frame.core :as rf]
+   [tubo.player.utils :as utils]))
 
 (rf/reg-fx
  :player/volume
@@ -20,8 +21,22 @@
  (fn [{:keys [player src current-pos]}]
    (when (and player @player)
      (set! (.-src @player) (clj->js src))
-     (set! (.-onended @player)
-           #(rf/dispatch [:queue/change-pos (inc current-pos)])))))
+     (when current-pos
+       (set! (.-onended @player)
+             #(rf/dispatch [:queue/change-pos (inc current-pos)]))))))
+
+(rf/reg-event-fx
+ :player/set-src
+ (fn [_ [_ opts]]
+   {:player/src opts}))
+
+(rf/reg-event-fx
+ :player/set-stream
+ (fn [{:keys [db]} [_ stream player pos]]
+   (let [video-stream (utils/get-video-stream stream (:settings db))]
+     {:fx [[:dispatch
+            [:player/set-src
+             {:player player :src video-stream :current-pos pos}]]]})))
 
 (rf/reg-fx
  :player/loop

+ 61 - 0
src/frontend/tubo/player/utils.cljs

@@ -0,0 +1,61 @@
+(ns tubo.player.utils
+  (:require
+   [clojure.string :as s]))
+
+(defn fmt=
+  [format str]
+  (s/includes? (s/replace (:format format) #"[-_]" "")
+               (s/replace (s/upper-case str) #"[-_]" "")))
+
+(defn get-video-stream
+  [{:keys [audio-streams video-streams video-only-streams]} settings]
+  (let [video-streams (into video-streams video-only-streams)]
+    (-> (cond (seq video-streams)
+              (as-> video-streams streams
+                (if (some #(fmt= % (:default-video-format settings)) streams)
+                  (filter #(fmt= % (:default-video-format settings))
+                          streams)
+                  streams)
+                (if (some #(= (:resolution %)
+                              (:default-resolution settings))
+                          video-streams)
+                  (filter #(= (:resolution %)
+                              (:default-resolution settings))
+                          streams)
+                  streams)
+                (first streams))
+              (seq audio-streams)
+              (if (some #(fmt= % (:default-audio-format settings))
+                        audio-streams)
+                (->> audio-streams
+                     (filter #(fmt= % (:default-audio-format settings)))
+                     first)
+                (first audio-streams))
+              :else (first video-streams))
+        :content)))
+
+(defn get-audio-stream
+  [{:keys [audio-streams video-streams]} settings]
+  (-> (cond (seq audio-streams)
+            (if (some #(fmt= % (:default-audio-format settings))
+                      audio-streams)
+              (->> audio-streams
+                   (filter #(fmt= % (:default-audio-format settings)))
+                   first)
+              (first audio-streams))
+            (seq video-streams)
+            (as-> video-streams streams
+              (if (some #(fmt= % (:default-video-format settings)) streams)
+                (filter #(fmt= % (:default-video-format settings))
+                        streams)
+                streams)
+              (if (some #(= (:resolution %)
+                            (:default-resolution settings))
+                        video-streams)
+                (filter #(= (:resolution %)
+                            (:default-resolution settings))
+                        streams)
+                streams)
+              (first streams))
+            :else (first audio-streams))
+      :content))

+ 148 - 102
src/frontend/tubo/player/views.cljs

@@ -11,109 +11,155 @@
      MediaFullscreenButton
      MediaPipButton
      MediaPlayButton
-     MediaPlaybackRateButton
-     MediaMuteButton)]))
+     MediaMuteButton
+     MediaLoadingIndicator)]
+   ["media-chrome/dist/react/menu/index.js"
+    :refer
+    (MediaCaptionsMenu
+     MediaPlaybackRateMenu
+     MediaSettingsMenu
+     MediaSettingsMenuButton
+     MediaSettingsMenuItem
+     MediaRenditionMenu)]))
+
+(defn button
+  [& {:keys [icon on-click disabled? show-on-mobile? extra-classes]}]
+  [:button.outline-none.focus:ring-transparent
+   {: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 color show-on-mobile?]
+  [button
+   :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 shuffle-button
+  [shuffle? color show-on-mobile?]
+  [button
+   :icon
+   [:i.fa-solid.fa-shuffle {:style {:color (when shuffle? color)}}]
+   :on-click #(rf/dispatch [:queue/shuffle (not shuffle?)])
+   :extra-classes [:text-sm]
+   :show-on-mobile? show-on-mobile?])
 
 (defn video-player
-  []
-  (let [!elapsed-time       @(rf/subscribe [:elapsed-time])
-        !main-player-first? (r/atom true)]
+  [_ _ _ on-mount]
+  (let [service-color @(rf/subscribe [:service-color])]
     (r/create-class
-     {:component-will-unmount #(rf/dispatch [:main-player/ready false])
+     {:component-did-mount on-mount
       :reagent-render
-      (fn [{:keys [video-streams audio-streams thumbnails]}
-           !player]
-        (let [show-main-player? @(rf/subscribe [:main-player/show])
-              service-color     @(rf/subscribe [:service-color])]
-          [:div
-           {:class "w-full h-80 md:h-[450px] lg:h-[600px]"}
-           [:> MediaController
-            {:style {"--media-secondary-color" "transparent"
-                     "--media-primary-color"   "white"
-                     "aspectRatio"             "16/9"
-                     "height"                  "100%"
-                     "width"                   "100%"}}
-            [:video
-             {:style          {"maxHeight" "100%"
-                               "minHeight" "100%"
-                               "minWidth"  "100%"
-                               "maxWidth"  "100%"}
-              :ref            #(reset! !player %)
-              :poster         (-> thumbnails
-                                  last
-                                  :url)
-              :loop           (when show-main-player?
-                                (= @(rf/subscribe [:player/loop]) :stream))
-              :on-can-play    #(rf/dispatch [:main-player/ready true])
-              :on-ended       #(when show-main-player?
-                                 (rf/dispatch [:queue/change-pos
-                                               (inc @(rf/subscribe
-                                                      [:queue/position]))])
-                                 (reset! !elapsed-time 0))
-              :on-play        #(rf/dispatch [:main-player/play])
-              :on-loaded-data (fn []
-                                (when show-main-player?
-                                  (rf/dispatch [:main-player/start]))
-                                (when (and @!main-player-first?
-                                           show-main-player?)
-                                  (reset! !main-player-first? false)))
-              :on-time-update (when show-main-player?
-                                #(reset! !elapsed-time (.-currentTime
-                                                        @!player)))
-              :on-seeked      (when show-main-player?
-                                #(reset! !elapsed-time (.-currentTime
-                                                        @!player)))
-              :slot           "media"
-
-              :src            (-> (into video-streams audio-streams)
-                                  first
-                                  :content)
-              :preload        "auto"
-              :muted          @(rf/subscribe [:player/muted])}]
-            [:div.ytp-gradient-bottom.absolute.w-full.bottom-0.pointer-events-none.bg-bottom.bg-repeat-x
-             {:style
-              {"paddingTop" "37px"
-               "height" "170px"
-               "backgroundImage"
-               "url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAACqCAYAAABsziWkAAAAAXNSR0IArs4c6QAAAQVJREFUOE9lyNdHBQAAhfHb3nvvuu2997jNe29TJJEkkkgSSSSJJJJEEkkiifRH5jsP56Xz8PM5gcC/xfDEmjhKxEOCSaREEiSbFEqkQppJpzJMJiWyINvkUCIX8kw+JQqg0BRRxaaEEqVQZsopUQGVpooS1VBjglStqaNEPTSYRko0QbNpoUQrtJl2qsN0UqILuk0PJXqhz/RTYgAGzRA1bEYoMQpjZpwSExAyk5SYgmkzQ82aOUqEIWKilJiHBbNIiSVYhhVYhTVYhw3YhC3Yhh3YhT3YhwM4hCM4hhM4hTM4hwu4hCu4hhu4hTu4hwd4hCd4hhd4hTd4hw/4hC/4hh/4/QM2/id28uIEJAAAAABJRU5ErkJggg==')"}}]
-            [:> MediaTimeRange
-             {:class "w-full h-[5px]"
-              :style
-              {"--media-control-hover-background" "transparent"
-               "--media-range-track-transition" "height 0.1s linear"
-               "--media-range-track-background" "rgba(255,255,255,.2)"
-               "--media-range-track-pointer-background" "rgba(255,255,255,.5)"
-               "--media-time-range-buffered-color" "rgba(255,255,255,.4)"
-               "--media-range-bar-color" service-color
-               "--media-range-thumb-border-radius" "13px"
-               "--media-range-thumb-background" service-color
-               "--media-range-thumb-transition" "transform 0.1s linear"
-               "--media-range-thumb-transform" "scale(0) translate(0%, 0%)"}}]
-            [:> MediaControlBar
-             {:class "relative pl-[10px] pr-[5px]"
-              :style
-              {"--media-control-hover-background"  "transparent"
-               "--media-range-track-height"        "3px"
-               "--media-range-thumb-height"        "13px"
-               "--media-range-thumb-width"         "13px"
-               "--media-range-thumb-border-radius" "13px"}}
-             [:> MediaPlayButton
-              {:class "py-[6px] px-[10px]"
-               :style
-               {"--media-button-icon-width" "30px"}}]
-             [:> MediaMuteButton
-              {:class "peer/mute"}]
-             [:> MediaVolumeRange
-              {:class
-               ["w-0" "overflow-hidden" "transition-[width]"
-                "transition-200" "ease-in" "peer-hover/mute:w-[70px]"
-                "peer-focus/mute:w-[70px]" "hover:w-[70px]" "focus:w-[70px]"]
-               :style
-               {"--media-range-track-background" "rgba(255,255,255,.2)"
-                "--media-range-bar-color"        service-color
-                "--media-range-thumb-background" service-color}}]
-             [:> MediaTimeDisplay {:showDuration true}]
-             [:span.control-spacer.grow]
-             [:> MediaPlaybackRateButton]
-             [:> MediaPipButton]
-             [:> MediaFullscreenButton]]]]))})))
+      (fn [{:keys [thumbnails subtitles]} !player video-args]
+        [:div
+         {:class "w-full h-80 md:h-[450px] lg:h-[600px]"}
+         [:> MediaController
+          {:style {"--media-secondary-color" "transparent"
+                   "--media-primary-color"   "white"
+                   "aspectRatio"             "16/9"
+                   "height"                  "100%"
+                   "width"                   "100%"}}
+          [:video
+           (into
+            {:style   {"maxHeight" "100%"
+                       "minHeight" "100%"
+                       "minWidth"  "100%"
+                       "maxWidth"  "100%"}
+             :poster  (-> thumbnails
+                          last
+                          :url)
+             :ref     #(reset! !player %)
+             :slot    "media"
+             :preload "metadata"}
+            video-args)
+           [:track
+            {:label   (:displayLanguageName (first subtitles))
+             :kind    "captions"
+             :srcLang (:languageTag (first subtitles))
+             :src     (:content (first subtitles))}]]
+          [:div.ytp-gradient-bottom.absolute.w-full.bottom-0.pointer-events-none.bg-bottom.bg-repeat-x
+           {:style
+            {"paddingTop" "37px"
+             "height" "170px"
+             "backgroundImage"
+             "url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAACqCAYAAABsziWkAAAAAXNSR0IArs4c6QAAAQVJREFUOE9lyNdHBQAAhfHb3nvvuu2997jNe29TJJEkkkgSSSSJJJJEEkkiifRH5jsP56Xz8PM5gcC/xfDEmjhKxEOCSaREEiSbFEqkQppJpzJMJiWyINvkUCIX8kw+JQqg0BRRxaaEEqVQZsopUQGVpooS1VBjglStqaNEPTSYRko0QbNpoUQrtJl2qsN0UqILuk0PJXqhz/RTYgAGzRA1bEYoMQpjZpwSExAyk5SYgmkzQ82aOUqEIWKilJiHBbNIiSVYhhVYhTVYhw3YhC3Yhh3YhT3YhwM4hCM4hhM4hTM4hwu4hCu4hhu4hTu4hwd4hCd4hhd4hTd4hw/4hC/4hh/4/QM2/id28uIEJAAAAABJRU5ErkJggg==')"}}]
+          [:> MediaTimeRange
+           {:class ["w-full" "h-[5px]"]
+            :style
+            {"--media-control-hover-background"       "transparent"
+             "--media-range-track-transition"         "height 0.1s linear"
+             "--media-range-track-background"         "rgba(255,255,255,.2)"
+             "--media-range-track-pointer-background" "rgba(255,255,255,.5)"
+             "--media-time-range-buffered-color"      "rgba(255,255,255,.4)"
+             "--media-range-bar-color"                service-color
+             "--media-range-thumb-border-radius"      "13px"
+             "--media-range-thumb-background"         service-color
+             "--media-range-thumb-transition"         "transform 0.1s linear"}}]
+          [:> MediaLoadingIndicator
+           {:slot       "centered-chrome"
+            :noautohide true
+            :style      {"--media-loading-indicator-icon-height" "200px"}}]
+          [:> MediaSettingsMenu
+           {:anchor "auto"
+            :hidden true
+            :style  {"--media-secondary-color"          "rgba(23, 23, 23, .9)"
+                     "--media-menu-border"              "1px solid white"
+                     "--media-menu-border-radius"       "35px"
+                     "--media-font-family"              "Nunito Sans"
+                     "--media-control-hover-background" "red"}}
+           [:> MediaSettingsMenuItem
+            "Speed"
+            [:> MediaPlaybackRateMenu
+             {:slot "submenu" :hidden true}
+             [:div {:slot "title"} "Speed"]]]
+           [:> MediaSettingsMenuItem
+            "Quality"
+            [:> MediaRenditionMenu
+             {:slot "submenu" :hidden true}
+             [:div {:slot "title"} "Quality"]]]
+           [:> MediaSettingsMenuItem
+            "Captions"
+            [:> MediaCaptionsMenu
+             {:slot "submenu" :hidden true}
+             [:div {:slot "title"} "Captions"]]]]
+          [:> MediaControlBar
+           {:class "relative pl-[10px] pr-[5px]"
+            :style
+            {"--media-control-hover-background"  "transparent"
+             "--media-range-track-height"        "3px"
+             "--media-range-thumb-height"        "13px"
+             "--media-range-thumb-width"         "13px"
+             "--media-range-thumb-border-radius" "13px"
+             "--media-tooltip-display"           "none"}}
+           [:> MediaPlayButton
+            {:class "py-[6px] px-[10px]"
+             :style
+             {"--media-button-icon-width" "30px"}}]
+           [:> MediaMuteButton
+            {:class "peer/mute"}]
+           [:> MediaVolumeRange
+            {:class
+             ["w-0" "overflow-hidden" "transition-[width]"
+              "transition-200" "ease-in" "peer-hover/mute:w-[70px]"
+              "peer-focus/mute:w-[70px]" "hover:w-[70px]" "focus:w-[70px]"]
+             :style
+             {"--media-range-track-background" "rgba(255,255,255,.2)"
+              "--media-range-bar-color"        service-color
+              "--media-range-thumb-background" service-color}}]
+           [:> MediaTimeDisplay {:showDuration true}]
+           [:span.control-spacer.grow]
+           [:> MediaSettingsMenuButton]
+           [:> MediaPipButton]
+           [:> MediaFullscreenButton]]]])})))

+ 12 - 16
src/frontend/tubo/queue/events.cljs

@@ -1,15 +1,14 @@
 (ns tubo.queue.events
   (:require
-   [re-frame.core :as rf]
-   [vimsical.re-frame.cofx.inject :as inject]))
+   [re-frame.core :as rf]))
 
 (defn get-stream-metadata
   [stream]
   (select-keys stream
                [:type :service-id :url :name :thumbnails :audio-streams
-                :video-streams :verified? :uploader-name :uploader-url
-                :uploader-avatars :upload-date :short-description :duration
-                :view-count :bookmark-id]))
+                :video-streams :video-only-streams :verified? :uploader-name
+                :uploader-url :uploader-avatars :upload-date :short-description
+                :duration :view-count :bookmark-id]))
 
 (rf/reg-event-fx
  :queue/show
@@ -116,19 +115,16 @@
 
 (rf/reg-event-fx
  :queue/change-stream
- [(rf/inject-cofx :store)
-  (rf/inject-cofx ::inject/sub [:bg-player])]
- (fn [{:keys [db store bg-player]} [_ stream idx]]
+ [(rf/inject-cofx :store)]
+ (fn [{:keys [db store]} [_ stream idx]]
    (let [update-entry (fn [x]
                         (update-in
                          x
                          [:queue idx]
                          #(merge % (get-stream-metadata stream))))]
-     {:db         (assoc (update-entry db) :queue/position idx)
-      :store      (assoc (update-entry store) :queue/position idx)
-      :player/src {:player      bg-player
-                   :src         (-> stream
-                                    :audio-streams
-                                    first
-                                    :content)
-                   :current-pos idx}})))
+     {:db    (assoc (update-entry db) :queue/position idx)
+      :store (assoc (update-entry store) :queue/position idx)
+      :fx    [[:dispatch
+               [(if (:main-player/show db)
+                  :main-player/set-stream
+                  :bg-player/set-stream) stream idx]]]})))

+ 19 - 5
src/frontend/tubo/settings/views.cljs

@@ -65,16 +65,29 @@
      [boolean-input "Show 'Next' and 'Similar' videos" [:show-related]
       show-related]]))
 
+(defn video-audio-settings
+  [{:keys [default-audio-format default-video-format default-resolution]}]
+  [:<>
+   [select-input "Default resolution" [:default-resolution]
+    default-resolution ["Best" "1080p" "720p" "480p" "360p" "240p" "144p"]]
+   [select-input "Default video format" [:default-video-format]
+    default-video-format #{"MPEG-4" "WebM" "3GP"}]
+   [select-input "Default audio format" [:default-audio-format]
+    default-audio-format #{"m4a" "WebM"}]])
+
 (defn settings
   []
-  (let [!active-tab (r/atom :appearance)]
+  (let [!active-tab (r/atom :video-audio)]
     (fn []
       (let [settings @(rf/subscribe [:settings])]
         [layout/content-container
          [layout/content-header "Settings"]
          [:div.mt-4
           [layout/tabs
-           [{:id        :appearance
+           [{:id        :video-audio
+             :label     "Video and audio"
+             :left-icon [:i.fa-solid.fa-headphones]}
+            {:id        :appearance
              :label     "Appearance"
              :left-icon [:i.fa-solid.fa-palette]}
             {:id        :content
@@ -84,6 +97,7 @@
            :on-change #(reset! !active-tab %)]
           [:form.flex.flex-wrap.py-4.gap-y-4
            (case @!active-tab
-             :appearance [appearance-settings settings]
-             :content    [content-settings settings]
-             [appearance-settings settings])]]]))))
+             :appearance  [appearance-settings settings]
+             :content     [content-settings settings]
+             :video-audio [video-audio-settings settings]
+             [video-audio-settings settings])]]]))))

+ 2 - 1
src/frontend/tubo/stream/views.cljs

@@ -141,7 +141,8 @@
         [:<>
          (when-not page-loading?
            [:div.flex.flex-col.justify-center.items-center
-            [player/video-player stream !player]])
+            [player/video-player stream !player {}
+             #(rf/dispatch [:player/set-stream stream !player])]])
          [layout/content-container
           [metadata stream]
           [description stream]