Browse Source

feat: add main player and replace video.js with vidstack

Miguel Ángel Moreno 10 months ago
parent
commit
30a03d4fbf

+ 2 - 5
package.json

@@ -6,13 +6,10 @@
   },
   "dependencies": {
     "@fortawesome/fontawesome-free": "^6.4.2",
-    "@silvermine/videojs-quality-selector": "^1.3.1",
-    "buffer": "^6.0.3",
+    "@vidstack/react": "^1.11.21",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "timeago.js": "^4.0.2",
-    "video.js": "^8.5.2",
-    "videojs-mobile-ui": "^1.1.1"
+    "timeago.js": "^4.0.2"
   },
   "devDependencies": {
     "@fontsource/nunito-sans": "^5.0.12",

+ 3 - 4
resources/src/styles/index.scss

@@ -1,4 +1,3 @@
-@use "./videojs";
 @use "@fontsource/nunito-sans/scss/mixins" as NunitoSans;
 $fontsourceDir: "~@fontsource";
 @include NunitoSans.faces($weights: (200, 300, 400, 500, 600, 700, 800, 900));
@@ -7,9 +6,9 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
 @import "@fortawesome/fontawesome-free/scss/regular";
 @import "@fortawesome/fontawesome-free/scss/solid";
 @import "@fortawesome/fontawesome-free/scss/fontawesome";
+@import '@vidstack/react/player/styles/default/theme.css';
+@import '@vidstack/react/player/styles/default/layouts/video.css';
+@import '@vidstack/react/player/styles/default/layouts/audio.css';
 @import "tailwindcss/base";
 @import "tailwindcss/components";
 @import "tailwindcss/utilities";
-@import "video.js/dist/video-js.css";
-@import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
-@import "@silvermine/videojs-quality-selector/dist/css/quality-selector.css";

+ 0 - 138
resources/src/styles/videojs.scss

@@ -1,138 +0,0 @@
-.vjs-tubo .vjs-poster img {
-  object-fit: cover;
-}
-
-.vjs-tubo .vjs-control-bar {
-  background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%);
-}
-
-.vjs-tubo .vjs-menu-button-popup .vjs-menu .vjs-menu-content {
-  background: rgba(0, 0, 0, 0.5);
-  backdrop-filter: blur(6px);
-}
-
-.vjs-tubo .vjs-current-time, .vjs-tubo .vjs-time-divider, .vjs-tubo .vjs-duration {
-  display: block;
-}
-
-.vjs-tubo .vjs-big-play-button, .vjs-tubo.vjs-paused .vjs-big-play-button, .vjs-tubo:hover .vjs-big-play-button {
-  content: '';
-  background-size: 60%;
-  background-position: center center;
-  background-repeat: no-repeat;
-  background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M8 5v14l11-7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
-  background-color: transparent;
-  border: none;
-}
-
-.vjs-tubo .vjs-progress-control {
-  position: absolute;
-  width: calc(100% - 4em);
-  top: -.5em;
-  height: 0.5em;
-  margin-left: 2em;
-  margin-right: 2em;
-}
-
-.vjs-tubo .vjs-progress-control .vjs-progress-holder {
-  position: relative;
-  margin: 0px !important;
-  top: 0;
-  width: 100%;
-  font-size: 13px;
-  padding: 8px 0 15px 0;
-  background: none;
-}
-
-.vjs-tubo .vjs-progress-control .vjs-progress-holder::before {
-  content: '';
-  display: block;
-  width: 100%;
-  height: 5px;
-  background: rgba(115, 133, 159, 0.3);
-  border-radius: 20px;
-}
-
-.vjs-tubo .vjs-progress-control .vjs-load-progress,
-.vjs-tubo .vjs-progress-control .vjs-play-progress {
-  height: 5px;
-  top: 8px;
-  border-radius: 20px;
-}
-
-.vjs-tubo .vjs-progress-control .vjs-load-progress > div:first-of-type {
-  border-radius: 20px;
-}
-
-.vjs-tubo .vjs-progress-control .vjs-mouse-display {
-  background: none;
-}
-
-.vjs-tubo .vjs-spacer {
-  display: flex;
-  flex: 1 1 auto;
-}
-
-.vjs-tubo .vjs-volume-bar.vjs-slider-horizontal {
-  background: none;
-  padding: 0px 0 15px 0;
-}
-
-.vjs-tubo .vjs-volume-bar.vjs-slider-horizontal::before {
-  content: '';
-  display: block;
-  width: 100%;
-  height: 3px;
-  background: rgba(115, 133, 159, 0.3);
-}
-
-.vjs-tubo .vjs-volume-bar.vjs-slider-horizontal .vjs-volume-level {
-  height: 3px;
-  top: 0;
-}
-
-.vjs-tubo .vjs-volume-bar.vjs-slider-horizontal .vjs-mouse-display {
-  display: none;
-}
-
-.vjs-tubo.vjs-layout-x-small .vjs-progress-control,
-.vjs-tubo.vjs-layout-tiny .vjs-progress-control {
-  display: block;
-}
-
-.vjs-tubo.vjs-layout-x-small .vjs-control-bar .vjs-play-control,
-.vjs-tubo.vjs-layout-tiny .vjs-control-bar .vjs-play-control {
-  display: none;
-}
-
-.vjs-tubo.vjs-layout-small .vjs-current-time,
-.vjs-tubo.vjs-layout-small .vjs-time-divider,
-.vjs-tubo.vjs-layout-small .vjs-duration,
-.vjs-tubo.vjs-layout-x-small .vjs-current-time,
-.vjs-tubo.vjs-layout-x-small .vjs-time-divider,
-.vjs-tubo.vjs-layout-x-small .vjs-duration,
-.vjs-tubo.vjs-layout-tiny .vjs-current-time,
-.vjs-tubo.vjs-layout-tiny .vjs-time-divider,
-.vjs-tubo.vjs-layout-tiny .vjs-duration {
-  display: inline-block;
-}
-
-.vjs-tubo.vjs-layout-small .vjs-progress-control .vjs-load-progress,
-.vjs-tubo.vjs-layout-small .vjs-progress-control .vjs-play-progress,
-.vjs-tubo.vjs-layout-x-small .vjs-progress-control .vjs-load-progress,
-.vjs-tubo.vjs-layout-x-small .vjs-progress-control .vjs-play-progress,
-.vjs-tubo.vjs-layout-tiny .vjs-progress-control .vjs-load-progress
-.vjs-tubo.vjs-layout-tiny .vjs-progress-control .vjs-play-progress {
-  top: 0;
-}
-
-.vjs-tubo.vjs-layout-small .vjs-progress-control .vjs-progress-holder,
-.vjs-tubo.vjs-layout-x-small .vjs-progress-control .vjs-progress-holder,
-.vjs-tubo.vjs-layout-tiny-small .vjs-progress-control .vjs-progress-holder {
-  padding: 0 0 15px 0;
-}
-
-.vjs-tubo.vjs-layout-tiny .vjs-menu-button-popup .vjs-menu .vjs-menu-content,
-.vjs-tubo.vjs-layout-x-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content {
-  max-height: 10em;
-}

+ 88 - 6
src/frontend/tubo/components/player.cljs

@@ -1,7 +1,89 @@
 (ns tubo.components.player
   (:require
    [reagent.core :as r]
-   [re-frame.core :as rf]))
+   [re-frame.core :as rf]
+   [reagent.core :as r]
+   [reagent.dom :as rdom]
+   ["@vidstack/react" :refer (MediaPlayer MediaProvider Poster)]
+   ["@vidstack/react/player/layouts/default" :refer (defaultLayoutIcons DefaultVideoLayout DefaultAudioLayout)]))
+
+(defn get-player-sources
+  [available-streams]
+  (map (fn [{:keys [content]}] {:src content :type "video/mp4"}) available-streams))
+
+(defn video-player
+  [stream !player]
+  (let [!elapsed-time @(rf/subscribe [:elapsed-time])
+        !main-player-first? (r/atom true)]
+    (r/create-class
+     {:component-will-unmount #(rf/dispatch [:main-player/ready false])
+      :reagent-render
+      (fn [{:keys [name video-streams audio-streams thumbnail-url]} !player]
+        (let [show-main-player? @(rf/subscribe [:main-player/show])]
+          [:> MediaPlayer
+           {:title          name
+            :src            (get-player-sources (into video-streams audio-streams))
+            :poster         thumbnail-url
+            :class          "h-[500px] lg:h-[600px] w-full xl:w-3/5 overflow-x-hidden"
+            :ref            #(reset! !player %)
+            :loop           (when show-main-player? (= @(rf/subscribe [:loop-playback]) :stream))
+            :onSeeked       (when show-main-player?
+                              #(reset! !elapsed-time (.-currentTime @!player)))
+            :onTimeUpdate   (when show-main-player?
+                              #(reset! !elapsed-time (.-currentTime @!player)))
+            :onEnded        #(when show-main-player?
+                               (rf/dispatch [:queue/change-pos (inc @(rf/subscribe [:queue-pos]))])
+                               (reset! !elapsed-time 0))
+            :onLoadedData   (fn []
+                              (when show-main-player?
+                                (rf/dispatch [:main-player/start]))
+                              (when (and @!main-player-first? show-main-player?)
+                                (reset! !main-player-first? false)))
+            :onPlay         #(rf/dispatch [:main-player/play])
+            :onCanPlay      #(rf/dispatch [:main-player/ready true])
+            :onSourceChange #(when-not @!main-player-first?
+                               (reset! !elapsed-time 0))}
+           [:> MediaProvider
+            [:> Poster {:src   thumbnail-url
+                        :alt   name
+                        :class :vds-poster}]]
+           [:> DefaultVideoLayout {:icons defaultLayoutIcons}]]))})))
+
+(defn audio-player
+  [stream !player]
+  (let [!elapsed-time     @(rf/subscribe [:elapsed-time])
+        !bg-player-first? (r/atom nil)]
+    (r/create-class
+     {:component-will-unmount #(rf/dispatch [:background-player/ready false])
+      :reagent-render
+      (fn [{:keys [name video-streams audio-streams thumbnail-url]} !player]
+        [:> MediaPlayer
+         {:title          name
+          :class          "invisible fixed"
+          :controls       []
+          :src            (get-player-sources audio-streams)
+          :viewType       "audio"
+          :ref            #(reset! !player %)
+          :loop           (= @(rf/subscribe [:loop-playback]) :stream)
+          :onCanPlay      #(rf/dispatch [:background-player/ready true])
+          :onSeeked       #(reset! !elapsed-time (.-currentTime @!player))
+          :onTimeUpdate   #(reset! !elapsed-time (.-currentTime @!player))
+          :onEnded        (fn []
+                            (rf/dispatch [:queue/change-pos (inc @(rf/subscribe [:queue-pos]))])
+                            (reset! !elapsed-time 0))
+          :onPlay         #(rf/dispatch [:background-player/play])
+          :onReplay       (fn []
+                            (rf/dispatch [:background-player/set-paused false])
+                            (reset! !elapsed-time 0))
+          :onPause        #(rf/dispatch [:background-player/set-paused true])
+          :onLoadedData   (fn []
+                            (rf/dispatch [:background-player/start])
+                            (when-not @!bg-player-first?
+                              (reset! !bg-player-first? true)))
+          :onSourceChange #(when @!bg-player-first?
+                             (reset! !elapsed-time 0))}
+         [:> MediaProvider]
+         [:> DefaultAudioLayout {:icons defaultLayoutIcons}]])})))
 
 (defonce base-slider-classes
   ["h-2" "cursor-pointer" "appearance-none" "bg-neutral-300" "dark:bg-neutral-600"
@@ -41,14 +123,14 @@
 (defn time-slider [!player !elapsed-time service-color]
   (let [styles (concat base-slider-classes
                        (get-slider-bg-classes service-color)
-                       (get-slider-shadow-classes service-color))]
+                       (get-slider-shadow-classes service-color))
+        bg-player-ready? @(rf/subscribe [:background-player/ready])]
     [:input.w-full
      {:class     styles
       :type      "range"
       :on-input  #(reset! !elapsed-time (.. % -target -value))
-      :on-change #(and @!player (> (.-readyState @!player) 0)
-                       (set! (.-currentTime @!player) @!elapsed-time))
-      :max       (if (and @!player (> (.-readyState @!player) 0))
+      :on-change #(when (and bg-player-ready? @!player) (set! (.-currentTime @!player) @!elapsed-time))
+      :max       (if (and bg-player-ready? @!player (not (js/isNaN (.-duration @!player))))
                    (.floor js/Math (.-duration @!player))
                    100)
       :value     @!elapsed-time}]))
@@ -90,7 +172,7 @@
           :on-mouse-out  #(reset! show-slider? false)}
          [button
           :icon (if muted? [:i.fa-solid.fa-volume-xmark] [:i.fa-solid.fa-volume-low])
-          :on-click #(rf/dispatch [:player/mute (not muted?) player])
+          :on-click #(rf/dispatch [:background-player/mute (not muted?) player])
           :extra-classes [:pl-3 :pr-2]]
          (when @show-slider?
            [:input.absolute.w-24.ml-2.m-1.bottom-16

+ 9 - 2
src/frontend/tubo/events.cljs

@@ -37,7 +37,7 @@
        :loop-playback          (if-nil (:loop-playback store) :playlist)
        :queue-pos              (if-nil (:queue-pos store) 0)
        :volume-level           (if-nil (:volume-level store) 100)
-       :show-background-player (:show-background-player store)
+       :background-player/show (:background-player/show store)
        :bookmarks
        (if-nil (:bookmarks store) [{:id (nano-id) :name "Liked Streams"}])
        :settings
@@ -115,7 +115,9 @@
                          (assoc :show-pagination-loading false))
       :scroll-to-top nil
       :body-overflow false
-      :fx            [[:dispatch [:queue/show false]]
+      :fx            [(when (:main-player/show db)
+                        [:dispatch [:player/switch-from-main]])
+                      [:dispatch [:queue/show false]]
                       [:dispatch [:services/fetch-all
                                   [:services/load] [:bad-response]]]
                       [:dispatch [:kiosks/fetch-all (:service-id db)
@@ -163,3 +165,8 @@
  :fetch-homepage
  (fn [{:keys [db]} _]
    {:fx [[:dispatch [:services/fetch-all [:load-homepage] [:bad-response]]]]}))
+
+(rf/reg-event-fx
+ :change-view
+ (fn [{:keys [db]} [_ view]]
+   {:db (assoc-in db [:current-match :data :view] view)}))

+ 134 - 84
src/frontend/tubo/player/events.cljs

@@ -1,5 +1,7 @@
 (ns tubo.player.events
   (:require
+   [tubo.components.player :as player :refer [get-player-sources]]
+   [tubo.stream.views :as stream]
    [tubo.utils :as utils]
    [goog.object :as gobj]
    [re-frame.core :as rf]
@@ -8,21 +10,24 @@
 (rf/reg-fx
  :volume
  (fn [{:keys [player volume]}]
-   (when (and @player (> (.-readyState @player) 0))
+   (when @player
      (set! (.-volume @player) (/ volume 100)))))
 
 (rf/reg-fx
  :mute
  (fn [{:keys [player muted?]}]
-   (when (and @player (> (.-readyState @player) 0))
+   (when @player
      (set! (.-muted @player) muted?))))
 
 (rf/reg-fx
  :src
  (fn [{:keys [player src current-pos]}]
-   (set! (.-src @player) src)
-   (set! (.-onended @player)
-         #(rf/dispatch [:queue/change-pos (inc current-pos)]))))
+   (set! (.-source @player) (clj->js src))))
+
+(rf/reg-fx
+ :loop
+ (fn [{:keys [player loop]}]
+   (set! (.-loop @player) loop)))
 
 (rf/reg-fx
  :current-time
@@ -30,73 +35,83 @@
    (set! (.-currentTime @player) time)))
 
 (rf/reg-event-fx
- :player/seek
+ :background-player/seek
  [(rf/inject-cofx ::inject/sub [:player])]
  (fn [{:keys [db player]} [_ time]]
-   {:current-time {:time time :player player}}))
+   (when (:background-player/ready db)
+     {:current-time {:time time :player player}})))
+
+(rf/reg-event-fx
+ :main-player/seek
+ [(rf/inject-cofx ::inject/sub [:main-player])]
+ (fn [{:keys [db main-player]} [_ time]]
+   {:current-time {:time time :player main-player}}))
 
 (rf/reg-fx
- :pause
+ :pause!
  (fn [{:keys [paused? player]}]
-   (when (and @player (> (.-readyState @player) 0))
-     (if paused?
-       (.play @player)
-       (.pause @player)))))
+   (when @player
+     (set! (.-paused @player) paused?))))
 
 (rf/reg-event-db
- :player/set-paused
+ :background-player/set-paused
  (fn [db [_ val]]
    (assoc db :paused val)))
 
 (rf/reg-event-fx
- :player/pause
+ :background-player/pause
  [(rf/inject-cofx ::inject/sub [:player])]
  (fn [{:keys [db player]} [_ paused?]]
-   {:pause {:paused? (not paused?)
-            :player  player}}))
+   {:pause! {:paused? paused?
+             :player  player}
+    :db (assoc db :paused paused?)}))
 
 (rf/reg-event-fx
- :player/stop
- (fn [{:keys [db]}]
-   {:fx [[:dispatch [:player/pause true]]
-         [:dispatch [:player/seek 0]]]}))
+ :main-player/pause
+ [(rf/inject-cofx ::inject/sub [:main-player])]
+ (fn [{:keys [db main-player]} [_ paused?]]
+   (when (:main-player/ready db)
+     {:pause! {:paused? paused?
+               :player  main-player}})))
 
 (rf/reg-event-fx
- :player/start-in-background
- [(rf/inject-cofx ::inject/sub [:player])
-  (rf/inject-cofx ::inject/sub [:elapsed-time])]
- (fn [{:keys [db player]} _]
-   {:fx [[:dispatch [:player/set-paused true]]
-         [:dispatch [:player/pause false]]
-         [:dispatch [:player/change-volume (:volume-level db) player]]]
-    :db (assoc db :player-ready (and @player (> (.-readyState @player) 0)))}))
+ :background-player/play
+ [(rf/inject-cofx ::inject/sub [:elapsed-time])
+  (rf/inject-cofx ::inject/sub [:main-player])]
+ (fn [{:keys [db elapsed-time main-player]}]
+   {:fx [[:dispatch [:background-player/seek @elapsed-time]]
+         (when (and (:main-player/ready db) @main-player)
+           [:dispatch [:main-player/pause true]])]}))
 
-(rf/reg-fx
- :audio-poster-mode
- (fn [{:keys [player options]}]
-   (.audioPosterMode
-    @player
-    (-> (filter #(= (:src %) (.src @player)) (:sources options))
-        first
-        :label
-        (clojure.string/includes? "audio-only")))))
+(rf/reg-event-fx
+ :main-player/play
+ [(rf/inject-cofx ::inject/sub [:elapsed-time])
+  (rf/inject-cofx ::inject/sub [:player])]
+ (fn [{:keys [db elapsed-time player]}]
+   {:fx [(when (and (:background-player/ready db) @player)
+           [:dispatch [:background-player/pause true]])]}))
 
-(rf/reg-fx
- :slider-color
- (fn [{:keys [player color]}]
-   (doseq [class [".vjs-play-progress" ".vjs-volume-level" ".vjs-slider-bar"]]
-     (set! (.. (.$ (.getChild ^videojs/VideoJsPlayer @player "ControlBar") class) -style -background) color))))
+(rf/reg-event-fx
+ :background-player/stop
+ (fn [{:keys [db]}]
+   {:fx [[:dispatch [:background-player/pause true]]
+         [:dispatch [:background-player/seek 0]]]}))
 
 (rf/reg-event-fx
- :player/set-slider-color
- (fn [_ [_ !player service-id]]
-   {:slider-color {:player !player :color (utils/get-service-color service-id)}}))
+ :background-player/start
+ [(rf/inject-cofx ::inject/sub [:player])
+  (rf/inject-cofx ::inject/sub [:elapsed-time])]
+ (fn [{:keys [db player elapsed-time]} _]
+   {:fx [[:dispatch [:background-player/pause false]]
+         [:dispatch [:player/change-volume (:volume-level db) player]]]}))
 
 (rf/reg-event-fx
- :player/start-in-main
+ :main-player/start
  [(rf/inject-cofx ::inject/sub [:elapsed-time])]
- (fn [{:keys [db]} [_ !player options service-id]]
-   {:fx [[:audio-poster-mode {:player !player :options options}]]}))
+ (fn [{:keys [db elapsed-time]} _]
+   {:fx [[:dispatch [:main-player/pause false]]
+         (when (and (:main-player/show db) (not (:background-player/ready db)))
+           [:dispatch [:main-player/seek @elapsed-time]])]}))
 
 (rf/reg-fx
  :media-session-metadata
@@ -114,13 +129,13 @@
            #(.setPositionState js/navigator.mediaSession
                                {:duration     (.-duration @player)
                                 :playbackRate (.-playbackRate @player)
-                                :position     (.-currentTime @player)})
+                                :position     current-time})
            seek #(do (rf/dispatch [:seek %]) (update-position))
            events
            {"play"          #(.play @player)
             "pause"         #(.pause @player)
-            "previoustrack" #(rf/dispatch [:change-queue-pos (dec current-pos)])
-            "nexttrack"     #(rf/dispatch [:change-queue-pos (inc current-pos)])
+            "previoustrack" #(rf/dispatch [:queue/change-pos (dec current-pos)])
+            "nexttrack"     #(rf/dispatch [:queue/change-pos (inc current-pos)])
             "seekbackward"  (fn [^js/navigator.MediaSessionActionDetails details]
                               (seek (- current-time (or (.-seekOffset details) 10))))
             "seekforward"   (fn [^js/navigator.MediaSessionActionDetails details]
@@ -140,7 +155,7 @@
     :volume {:player player :volume value}}))
 
 (rf/reg-event-fx
- :player/mute
+ :background-player/mute
  [(rf/inject-cofx :store)]
  (fn [{:keys [db store]} [_ value player]]
    {:db    (assoc db :muted value)
@@ -159,38 +174,71 @@
       :store (assoc store :loop-playback loop-state)})))
 
 (rf/reg-event-fx
- :player/dispose
+ :background-player/dispose
  [(rf/inject-cofx :store)]
  (fn [{:keys [db store]} _]
    (let [remove-entries
          (fn [elem]
            (-> elem
-               (update :show-background-player #(not %))
-               (assoc :player-ready false)
+               (assoc :background-player/show false)
                (assoc :queue [])
                (assoc :queue-pos 0)))]
      {:db    (remove-entries db)
       :store (remove-entries store)
-      :fx    [[:dispatch [:player/pause true]]
-              [:dispatch [:player/seek 0]]]})))
+      :fx    [[:dispatch [:background-player/pause true]]
+              [:dispatch [:background-player/seek 0]]]})))
+
+(rf/reg-event-db
+ :background-player/ready
+ (fn [db [_ ready]]
+   (assoc db :background-player/ready ready)))
+
+(rf/reg-event-db
+ :main-player/ready
+ (fn [db [_ ready]]
+   (assoc db :main-player/ready ready)))
 
 (rf/reg-event-fx
  :player/switch-to-background
  [(rf/inject-cofx :store)]
- (fn [{:keys [db store]} [_ stream]]
+ (fn [{:keys [db store]} [_ stream notify?]]
    (let [updated-db (update db :queue conj stream)
          idx        (.indexOf (:queue updated-db) stream)]
-     {:db    (-> updated-db
-                 (assoc :show-background-player true))
+     {:db    (assoc updated-db :background-player/show (not (:main-player/show db)))
       :store (-> store
-                 (assoc :show-background-player true)
+                 (assoc :background-player/show (not (:main-player/show db)))
                  (assoc :queue (:queue updated-db)))
       :fx    [[:dispatch [:player/fetch-stream
                           (:url stream) idx (= (count (:queue db)) 0)]]
-              (when-not (= (count (:queue db)) 0)
+              (when (and notify? (not (= (count (:queue db)) 0)))
                 [:dispatch [:notifications/add
-                            {:status-text (str "Added stream to queue")
-                             :failure     :success}]])]})))
+                            {:status-text "Added stream to queue"
+                             :failure     :info}]])]})))
+
+(rf/reg-event-fx
+ :player/show-main-player
+ (fn [{:keys [db]} [_ val]]
+   {:db (assoc db :main-player/show val)
+    :body-overflow val}))
+
+(rf/reg-event-fx
+ :player/switch-from-main
+ [(rf/inject-cofx :store)
+  (rf/inject-cofx ::inject/sub [:elapsed-time])]
+ (fn [{:keys [db store elapsed-time]} [_ stream]]
+   {:db (assoc db :background-player/show true)
+    :store (assoc store :background-player/show true)
+    :fx [[:dispatch [:player/show-main-player false]]
+         [:dispatch [:main-player/pause true]]]}))
+
+(rf/reg-event-fx
+ :player/switch-to-main
+ [(rf/inject-cofx :store)]
+ (fn [{:keys [db store]} [_ stream]]
+   {:fx [[:dispatch [:player/show-main-player true]]]
+    :db (assoc db :background-player/show false)
+    :store (assoc store :background-player/show false)
+    :scroll-to-top nil}))
 
 (rf/reg-event-fx
  :player/load-related-streams
@@ -203,49 +251,42 @@
  [(rf/inject-cofx ::inject/sub [:player])]
  (fn [{:keys [db player]} [_ idx play? res]]
    (let [stream-res (js->clj res :keywordize-keys true)]
-     {:db (assoc db :show-background-player-loading false)
-      :fx (apply conj [[:dispatch [:queue/change-stream-source
-                                   (-> stream-res :audio-streams first :content)
-                                   idx]]]
-                 (when play?
-                   [[:src
-                     {:player      player
-                      :src         (-> stream-res :audio-streams first :content)
-                      :current-pos (:queue-pos db)}]
-                    [:media-session-metadata
+     {:db (assoc db :background-player/loading false)
+      :fx (apply conj [(when play? [:dispatch [:queue/change-stream stream-res idx]])]
+                 (when (and (:background-player/ready db) play?)
+                   [[:media-session-metadata
                      {:title   (:name stream-res)
                       :artist  (:uploader-name stream-res)
                       :artwork [{:src (:thumbnail-url stream-res)}]}]
                     [:media-session-handlers
                      {:current-pos (:queue-pos db)
-                      :player player}]]))})))
+                      :player      player}]]))})))
 
 (rf/reg-event-fx
  :player/bad-response
- (fn [{:keys [db]} [_ play? res]]
+ (fn [{:keys [db]} [_ idx play? res]]
    {:db (assoc db
-               :show-background-player-loading false
-               :player-ready true)
+               :background-player/loading false)
     :fx [[:dispatch [:bad-response res]]
          (when play?
            (if (> (-> db :queue count) 1)
-             [:dispatch [:queue/change-pos (-> db :queue-pos inc)]]
-             [:dispatch [:player/dispose]]))]}))
+             [:dispatch [:queue/change-pos (inc idx)]]
+             [:dispatch [:background-player/dispose]]))]}))
 
 (rf/reg-event-fx
  :player/fetch-related-streams
  (fn [{:keys [db]} [_ url]]
    {:fx [[:dispatch [:stream/fetch url
                      [:player/load-related-streams]] [:bad-response]]]
-    :db (assoc db :show-background-player-loading true)}))
+    :db (assoc db :background-player/loading true)}))
 
 (rf/reg-event-fx
  :player/fetch-stream
  (fn [{:keys [db]} [_ url idx play?]]
    {:fx [[:dispatch [:stream/fetch url
                      [:player/load-stream idx play?]
-                     [:player/bad-response play?]]]]
-    :db (assoc db :show-background-player-loading true)}))
+                     [:player/bad-response idx play?]]]]
+    :db (assoc db :background-player/loading play?)}))
 
 (rf/reg-event-fx
  :player/start-radio
@@ -253,4 +294,13 @@
    {:fx [[:dispatch [:player/switch-to-background stream]]
          (when (not= (count (:queue db)) 0)
            [:dispatch [:queue/change-pos (count (:queue db))]])
-         [:dispatch [:player/fetch-related-streams (:url stream)]]]}))
+         [:dispatch [:player/fetch-related-streams (:url stream)]]
+         [:dispatch [:notifications/add
+                     {:status-text "Started stream radio"
+                      :failure     :info}]]]}))
+
+(rf/reg-event-db
+ :main-player/toggle-layout
+ (fn [db [_ layout]]
+   (assoc-in db [:queue (:queue-pos db) layout]
+             (not (get-in db [:queue (:queue-pos db) layout])))))

+ 22 - 6
src/frontend/tubo/player/subs.cljs

@@ -4,6 +4,7 @@
    [reagent.core :as r]))
 
 (defonce !player (atom nil))
+(defonce !main-player (atom nil))
 (defonce !elapsed-time (r/atom 0))
 
 (rf/reg-sub
@@ -12,19 +13,29 @@
    !player))
 
 (rf/reg-sub
- :player-ready
+ :main-player
  (fn [db _]
-   (:player-ready db)))
+   !main-player))
 
 (rf/reg-sub
- :show-background-player
+ :background-player/ready
  (fn [db _]
-   (:show-background-player db)))
+   (:background-player/ready db)))
 
 (rf/reg-sub
- :show-background-player-loading
+ :main-player/ready
  (fn [db _]
-   (:show-background-player-loading db)))
+   (:main-player/ready db)))
+
+(rf/reg-sub
+ :background-player/show
+ (fn [db _]
+   (:background-player/show db)))
+
+(rf/reg-sub
+ :background-player/loading
+ (fn [db _]
+   (:background-player/loading db)))
 
 (rf/reg-sub
  :loop-playback
@@ -50,3 +61,8 @@
  :elapsed-time
  (fn [db _]
    !elapsed-time))
+
+(rf/reg-sub
+ :main-player/show
+ (fn [db _]
+   (:main-player/show db)))

+ 85 - 81
src/frontend/tubo/player/views.cljs

@@ -2,41 +2,14 @@
   (:require
    [re-frame.core :as rf]
    [reagent.core :as r]
-   [reagent.dom :as rdom]
    [reitit.frontend.easy :as rfe]
    [tubo.bookmarks.modals :as modals]
    [tubo.components.layout :as layout]
    [tubo.components.player :as player]
+   [tubo.queue.views :as queue]
+   [tubo.stream.views :as stream]
    [tubo.utils :as utils]
-   ["video.js" :as videojs]
-   ["videojs-mobile-ui"]
-   ["@silvermine/videojs-quality-selector" :as VideojsQualitySelector]))
-
-(defn audio
-  [!player]
-  (let [{:keys [stream]} @(rf/subscribe [:queue-stream])
-        queue-pos        @(rf/subscribe [:queue-pos])]
-    (r/create-class
-     {:component-did-mount
-      (fn [this]
-        (set! (.-onended (rdom/dom-node this))
-              #(rf/dispatch [:queue/change-pos (inc queue-pos)]))
-        (when stream
-          (set! (.-src (rdom/dom-node this)) stream)))
-      :reagent-render
-      (fn [!player]
-        (let [!elapsed-time @(rf/subscribe [:elapsed-time])
-              muted?        @(rf/subscribe [:muted])
-              volume-level  @(rf/subscribe [:volume-level])
-              loop-playback @(rf/subscribe [:loop-playback])]
-          [:audio
-           {:ref            #(reset! !player %)
-            :loop           (= loop-playback :stream)
-            :muted          muted?
-            :on-loaded-data #(rf/dispatch [:player/start-in-background])
-            :on-time-update #(reset! !elapsed-time (.-currentTime @!player))
-            :on-pause       #(rf/dispatch [:player/set-paused true])
-            :on-play        #(rf/dispatch [:player/set-paused false])}]))})))
+   ["@vidstack/react" :refer (useStore MediaPlayerInstance)]))
 
 (defn stream-metadata
   [{:keys [thumbnail-url url name uploader-url uploader-name]}]
@@ -56,13 +29,15 @@
 
 (defn main-controls
   [!player color]
-  (let [queue          @(rf/subscribe [:queue])
-        queue-pos      @(rf/subscribe [:queue-pos])
-        loading?       @(rf/subscribe [:show-background-player-loading])
-        !elapsed-time  @(rf/subscribe [:elapsed-time])
-        loop-playback  @(rf/subscribe [:loop-playback])
-        paused?        @(rf/subscribe [:paused])
-        player-ready?  @(rf/subscribe [:player-ready])]
+  (let [queue              @(rf/subscribe [:queue])
+        queue-pos          @(rf/subscribe [:queue-pos])
+        loading?           @(rf/subscribe [:background-player/loading])
+        loop-playback      @(rf/subscribe [:loop-playback])
+        !main-player       @(rf/subscribe [:main-player])
+        bg-player-ready?   @(rf/subscribe [:background-player/ready])
+        main-player-ready? @(rf/subscribe [:main-player/ready])
+        paused?            @(rf/subscribe [:paused])
+        !elapsed-time      @(rf/subscribe [:elapsed-time])]
     [:div.flex.flex-col.items-center.ml-auto
      [:div.flex.justify-end
       [player/loop-button loop-playback color]
@@ -72,35 +47,35 @@
        :disabled? (not (and queue (not= queue-pos 0)))]
       [player/button
        :icon [:i.fa-solid.fa-backward]
-       :on-click #(rf/dispatch [:player/seek (- @!elapsed-time 5)])]
+       :on-click #(rf/dispatch [:background-player/seek (- @!elapsed-time 5)])]
       [player/button
-       :icon (if (or (not loading?) player-ready?)
+       :icon (if (and (not loading?) @!player)
                (if paused?
                  [:i.fa-solid.fa-play]
                  [:i.fa-solid.fa-pause])
                [layout/loading-icon color "lg:text-2xl"])
-       :on-click #(rf/dispatch [:player/pause (not paused?)])
+       :on-click #(rf/dispatch [:background-player/pause (not (.-paused @!player))])
        :show-on-mobile? true
        :extra-classes ["lg:text-2xl"]]
       [player/button
        :icon [:i.fa-solid.fa-forward]
-       :on-click #(rf/dispatch [:player/seek (+ @!elapsed-time 5)])]
+       :on-click #(rf/dispatch [:background-player/seek (+ @!elapsed-time 5)])]
       [player/button
        :icon [:i.fa-solid.fa-forward-step]
        :on-click #(rf/dispatch [:queue/change-pos (inc queue-pos)])
        :disabled? (not (and queue (< (inc queue-pos) (count queue))))]]
      [:div.hidden.lg:flex.items-center.text-sm
       [:span.mx-2
-       (if (and @!player @!elapsed-time) (utils/format-duration @!elapsed-time) "--:--")]
+       (if (and bg-player-ready? @!player @!elapsed-time) (utils/format-duration @!elapsed-time) "--:--")]
       [:div.w-20.lg:w-64.mx-2.flex.items-center
        [player/time-slider !player !elapsed-time color]]
       [:span.mx-2
-       (if (and @!player player-ready?) (utils/format-duration (.-duration @!player)) "--:--")]]]))
+       (if (and bg-player-ready? @!player) (utils/format-duration (.-duration @!player)) "--:--")]]]))
 
 (defn extra-controls
   [!player {:keys [url uploader-url] :as stream} color]
   (let [!menu-active? (r/atom nil)]
-    (fn []
+    (fn [!player {:keys [url uploader-url] :as stream} color]
       (let [muted?    @(rf/subscribe [:muted])
             volume    @(rf/subscribe [:volume-level])
             queue     @(rf/subscribe [:queue])
@@ -131,6 +106,9 @@
            {:label    "Remove from queue"
             :icon     [:i.fa-solid.fa-trash]
             :on-click #(rf/dispatch [:queue/remove queue-pos])}
+           {:label    "Switch to main"
+            :icon     [:i.fa-solid.fa-display]
+            :on-click #(rf/dispatch [:player/switch-to-main])}
            {:label    "Show channel details"
             :icon     [:i.fa-solid.fa-user]
             :on-click #(rf/dispatch [:navigate
@@ -139,46 +117,72 @@
                                       :query  {:url uploader-url}}])}
            {:label    "Close player"
             :icon     [:i.fa-solid.fa-close]
-            :on-click #(rf/dispatch [:player/dispose])}]
+            :on-click #(rf/dispatch [:background-player/dispose])}]
           :menu-styles {:bottom "30px" :top nil :right "10px"}
           :extra-classes [:pt-1 :!pl-4 :px-3]]]))))
 
 (defn background-player
   []
-  (let [!player      @(rf/subscribe [:player])
-        stream       @(rf/subscribe [:queue-stream])
-        show-player? @(rf/subscribe [:show-background-player])
-        show-queue?  @(rf/subscribe [:show-queue])
-        dark-theme?  @(rf/subscribe [:dark-theme])
-        color        (-> stream :service-id utils/get-service-color)
-        bg-color     (str "rgba(" (if dark-theme? "23,23,23" "255,255,255") ",0.95)")
-        bg-image     (str "linear-gradient(" bg-color "," bg-color "),url(" (:thumbnail-url stream) ")")]
-    (when show-player?
-      [:div.sticky.absolute.left-0.bottom-0.z-10.p-3.transition-all.ease-in
-       {:style
-        {:visibility          (when show-queue? "hidden")
-         :opacity             (if show-queue? 0 1)
-         :background-image    bg-image
-         :background-size     "cover"
-         :background-position "center"
-         :background-repeat   "no-repeat"}}
-       [:div.flex.items-center.justify-between
-        [audio !player]
-        [stream-metadata stream]
-        [main-controls !player color]
-        [extra-controls !player stream color]]])))
+  (let [!show-tooltip? (r/atom nil)]
+    (fn []
+      (let [!player       @(rf/subscribe [:player])
+            stream        @(rf/subscribe [:queue-stream])
+            show-queue?   @(rf/subscribe [:show-queue])
+            show-player?  @(rf/subscribe [:background-player/show])
+            dark-theme?   @(rf/subscribe [:dark-theme])
+            muted?        @(rf/subscribe [:muted])
+            loop-playback @(rf/subscribe [:loop-playback])
+            color         (-> stream :service-id utils/get-service-color)
+            bg-color      (str "rgba(" (if dark-theme? "23,23,23" "255,255,255") ",0.95)")
+            bg-image      (str "linear-gradient(" bg-color "," bg-color "),url(" (:thumbnail-url stream) ")")]
+        (when show-player?
+          [:div.sticky.absolute.left-0.bottom-0.z-10.p-3.transition-all.ease-in.relative
+           {:on-mouse-over #(reset! !show-tooltip? true)
+            :on-mouse-out #(reset! !show-tooltip? false)
+            :style
+            {:visibility          (when show-queue? "hidden")
+             :opacity             (if show-queue? 0 1)
+             :background-image    bg-image
+             :background-size     "cover"
+             :background-position "center"
+             :background-repeat   "no-repeat"}}
+           [:div.absolute.flex.items-center.justify-center.w-full.transition.ease-in-out.h-fit.bottom-full.left-0.py-1
+            {:class [(when-not @!show-tooltip? :invisible) (if @!show-tooltip? :opacity-1 :opacity-0)]}
+            [:button.px-5.rounded.rounded-lg.border.border.border-neutral-300.dark:border-stone-700
+             {:on-click #(do (rf/dispatch [:player/switch-to-main stream]) (reset! !show-tooltip? false))
+              :style {:background bg-color}}
+             [:i.fa-solid.fa-caret-up]]]
+           [:div.flex.items-center
+            [player/audio-player stream !player]
+            [stream-metadata stream]
+            [main-controls !player color]
+            [extra-controls !player stream color]]])))))
 
-(defn main-player
-  [options service-id]
-  (let [!player (atom nil)]
-    (r/create-class
-     {:component-did-mount
-      (fn [^videojs/VideoJsPlayer this]
-        (VideojsQualitySelector videojs)
-        (reset! !player (videojs (rdom/dom-node this) (clj->js options)))
-        (.on @!player "ready" (fn []
-                                (.mobileUi ^videojs/VideoJsPlayer @!player)
-                                (rf/dispatch [:player/set-slider-color !player service-id])))
-        (.on @!player "play" #(rf/dispatch [:player/start-in-main !player options])))
-      :component-will-unmount #(when @!player (.dispose @!player))
-      :reagent-render         (fn [options] [:video-js.vjs-tubo])})))
+(defn main-player []
+  (let [queue @(rf/subscribe [:queue])
+        queue-pos @(rf/subscribe [:queue-pos])
+        bookmarks @(rf/subscribe [:bookmarks])
+        !player @(rf/subscribe [:main-player])
+        {:keys [service-id] :as stream} @(rf/subscribe [:queue-stream])
+        show-player? @(rf/subscribe [:main-player/show])]
+    [:div.fixed.w-full.bg-neutral-100.dark:bg-neutral-900.overflow-auto.z-10.transition-all.ease-in-out.shadow-lg.shadow-neutral-900.dark:shadow-neutral-300
+     {:class ["rounded-t-[50px]" "h-[calc(100%-56px)]" (if show-player? "translate-y-0" "translate-y-full")]}
+     [:div.sticky.z-10.right-0.top-0
+      [:button.absolute.text-white.m-8.text-2xl.z-10.right-0
+       {:on-click #(rf/dispatch [:player/switch-from-main nil])}
+       [:i.fa-solid.fa-close
+        {:class "drop-shadow-[0_0_1px_#000]"}]]]
+     (when (and show-player? stream)
+       [:div
+        [: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-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/suggested stream]]])]))

+ 4 - 4
src/frontend/tubo/queue/events.cljs

@@ -18,15 +18,15 @@
       :fx    (if notify?
                [[:dispatch [:notifications/add
                             {:status-text "Added stream to queue"
-                             :failure     :success}]]]
+                             :failure     :info}]]]
                [])})))
 
 (rf/reg-event-fx
  :queue/add-n
  [(rf/inject-cofx :store)]
  (fn [{:keys [db store]} [_ streams notify?]]
-   {:db    (assoc db :show-background-player true)
-    :store (assoc store :show-background-player true)
+   {:db    (assoc db :background-player/show (not (:main-player/show db)))
+    :store (assoc store :background-player/show (not (:main-player/show db)))
     :fx    (into (map (fn [stream] [:dispatch [:queue/add stream]]) streams)
                  [[:dispatch [:player/fetch-stream (-> streams first :url)
                               (count (:queue db)) (= (count (:queue db)) 0)]]
@@ -34,7 +34,7 @@
                     [:dispatch [:notifications/add
                                 {:status-text (str "Added " (count streams)
                                                    " streams to queue")
-                                 :failure     :success}]])])}))
+                                 :failure     :info}]])])}))
 
 (rf/reg-event-fx
  :queue/remove

+ 32 - 29
src/frontend/tubo/queue/views.cljs

@@ -55,10 +55,11 @@
 
 (defn queue-item
   [item queue queue-pos i bookmarks]
-  (let [!menu-active? (r/atom false)]
+  (let [!menu-active? (r/atom false)
+        show-main-player? @(rf/subscribe [:main-player/show])]
     (fn [item queue queue-pos i bookmarks]
       [:div.relative.w-full
-       {:ref #(when (and queue (= queue-pos i)) (rf/dispatch [:scroll-into-view %]))}
+       {:ref #(when (and queue (= queue-pos i) (not show-main-player?)) (rf/dispatch [:scroll-into-view %]))}
        [item-metadata item queue-pos i]
        [popover item i !menu-active? bookmarks]])))
 
@@ -75,25 +76,26 @@
     uploader-name]])
 
 (defn main-controls
-  [{:keys [service-id]} queue queue-pos]
-  (let [loop-playback @(rf/subscribe [:loop-playback])
-        service-color (and service-id (utils/get-service-color service-id))
-        !player       @(rf/subscribe [:player])
-        loading?       @(rf/subscribe [:show-background-player-loading])
-        player-ready? @(rf/subscribe [:player-ready])
-        paused?       @(rf/subscribe [:paused])
-        !elapsed-time @(rf/subscribe [:elapsed-time])
-        queue         @(rf/subscribe [:queue])
-        queue-pos     @(rf/subscribe [:queue-pos])]
+  [{:keys [service-id]} queue queue-pos color]
+  (let [loop-playback      @(rf/subscribe [:loop-playback])
+        !player            @(rf/subscribe [:player])
+        !main-player       @(rf/subscribe [:main-player])
+        loading?           @(rf/subscribe [:background-player/loading])
+        bg-player-ready?   @(rf/subscribe [:background-player/ready])
+        main-player-ready? @(rf/subscribe [:main-player/ready])
+        paused?            @(rf/subscribe [:paused])
+        !elapsed-time      @(rf/subscribe [:elapsed-time])
+        queue              @(rf/subscribe [:queue])
+        queue-pos          @(rf/subscribe [:queue-pos])]
     [:<>
      [:div.flex.flex-auto.py-2.w-full.items-center.text-sm
       [:span.mr-4.whitespace-nowrap
-       (if (and @!player @!elapsed-time) (utils/format-duration @!elapsed-time) "--:--")]
-      [player/time-slider !player !elapsed-time service-color]
+       (if (and bg-player-ready? @!player @!elapsed-time) (utils/format-duration @!elapsed-time) "--:--")]
+      [player/time-slider !player !elapsed-time color]
       [:span.ml-4.whitespace-nowrap
-       (if (and @!player player-ready?) (utils/format-duration (.-duration @!player)) "--:--")]]
+       (if (and bg-player-ready? @!player) (utils/format-duration (.-duration @!player)) "--:--")]]
      [:div.flex.justify-center.items-center
-      [player/loop-button loop-playback service-color true]
+      [player/loop-button loop-playback color true]
       [player/button
        :icon [:i.fa-solid.fa-backward-step]
        :on-click #(rf/dispatch [:queue/change-pos (dec queue-pos)])
@@ -102,21 +104,21 @@
        :show-on-mobile? true]
       [player/button
        :icon [:i.fa-solid.fa-backward]
-       :on-click #(rf/dispatch [:player/seek (- @!elapsed-time 5)])
+       :on-click #(rf/dispatch [:background-player/seek (- @!elapsed-time 5)])
        :extra-classes [:text-xl]
        :show-on-mobile? true]
       [player/button
-       :icon (if (or (not loading?) player-ready?)
+       :icon (if (and (not loading?) @!player)
                (if paused?
                  [:i.fa-solid.fa-play]
                  [:i.fa-solid.fa-pause])
-               [layout/loading-icon service-color :text-3xl])
-       :on-click #(rf/dispatch [:player/pause (not paused?)])
-       :extra-classes [:text-3xl]
-       :show-on-mobile? true]
+               [layout/loading-icon color :text-3xl])
+       :on-click #(rf/dispatch [:background-player/pause (not (.-paused @!player))])
+       :show-on-mobile? true
+       :extra-classes [:text-3xl]]
       [player/button
        :icon [:i.fa-solid.fa-forward]
-       :on-click #(rf/dispatch [:player/seek (+ @!elapsed-time 5)])
+       :on-click #(rf/dispatch [:background-player/seek (+ @!elapsed-time 5)])
        :extra-classes [:text-xl]
        :show-on-mobile? true]
       [player/button
@@ -133,11 +135,12 @@
 
 (defn queue
   []
-  (let [show-queue    @(rf/subscribe [:show-queue])
-        stream        @(rf/subscribe [:queue-stream])
-        bookmarks     @(rf/subscribe [:bookmarks])
-        queue-pos     @(rf/subscribe [:queue-pos])
-        queue         @(rf/subscribe [:queue])]
+  (let [show-queue @(rf/subscribe [:show-queue])
+        stream     @(rf/subscribe [:queue-stream])
+        bookmarks  @(rf/subscribe [:bookmarks])
+        queue-pos  @(rf/subscribe [:queue-pos])
+        queue      @(rf/subscribe [:queue])
+        color      (-> stream :service-id utils/get-service-color)]
     [:div.fixed.flex.flex-col.items-center.min-w-full.w-full.z-10.backdrop-blur
      {:class ["dark:bg-neutral-900/90" "bg-neutral-100/90"
               "min-h-[calc(100dvh-56px)]" "h-[calc(100dvh-56px)]"
@@ -151,4 +154,4 @@
          ^{:key i} [queue-item item queue queue-pos i bookmarks])]
       [:div.flex.flex-col.py-4.shrink-0.px-5
        [queue-metadata stream]
-       [main-controls stream queue queue-pos]]]]))
+       [main-controls stream queue queue-pos color]]]]))

+ 16 - 41
src/frontend/tubo/stream/views.cljs

@@ -3,48 +3,14 @@
    [reagent.core :as r]
    [re-frame.core :as rf]
    [reitit.frontend.easy :as rfe]
+   [tubo.components.player :refer [get-player-sources]]
    [tubo.bookmarks.modals :as modals]
    [tubo.comments.views :as comments]
    [tubo.components.items :as items]
    [tubo.components.layout :as layout]
-   [tubo.player.views :as player]
+   [tubo.components.player :as player]
    [tubo.utils :as utils]))
 
-(def player-elements
-  ["PlayToggle" "ProgressControl" "VolumePanel" "CurrentTimeDisplay"
-   "TimeDivider" "DurationDisplay" "Spacer" "QualitySelector"
-   "PlaybackRateMenuButton" "FullscreenToggle"])
-
-(defn get-player-sources
-  [available-streams]
-  (reverse (map (fn [{:keys [content format resolution averageBitrate]}]
-                  {:src   content
-                   :type  "video/mp4"
-                   :label (str (or resolution "audio-only") " "
-                               format
-                               (when-not resolution
-                                 (str " " averageBitrate "kbit/s")))})
-                available-streams)))
-
-(defn player
-  [{:keys [thumbnail-url audio-streams video-streams service-id]}]
-  (let [page-loading? @(rf/subscribe [:show-page-loading])]
-    (when-not page-loading?
-      [:div.flex.flex-col.flex-auto.items-center.xl:py-6.!pb-0
-       [:div.flex.flex-col.flex-auto.w-full {:class ["xl:w-3/5"]}
-        [:div.flex.justify-center.relative
-         {:class "h-[300px] md:h-[450px] lg:h-[600px]"}
-         [player/main-player
-          {:sources       (get-player-sources (into audio-streams video-streams))
-           :poster        thumbnail-url
-           :controls      true
-           :controlBar    {:children player-elements}
-           :preload       "metadata"
-           :responsive    true
-           :fill          true
-           :playbackRates [0.5 1 1.5 2]}
-          service-id]]]])))
-
 (defn metadata-popover
   [_]
   (let [!menu-active? (r/atom nil)]
@@ -54,7 +20,7 @@
         [layout/popover-menu !menu-active?
          [{:label    "Add to queue"
            :icon     [:i.fa-solid.fa-headphones]
-           :on-click #(rf/dispatch [:player/switch-to-background stream])}
+           :on-click #(rf/dispatch [:player/switch-to-background stream true])}
           {:label    "Play radio"
            :icon     [:i.fa-solid.fa-tower-cell]
            :on-click #(rf/dispatch [:player/start-radio stream])}
@@ -121,7 +87,10 @@
   (let [show? (:show-description @(rf/subscribe [:settings]))]
     (when (and show? (not (empty? description)))
       [layout/show-more-container show-description description
-       #(rf/dispatch [:stream/toggle-layout :show-description])])))
+       #(rf/dispatch [(if @(rf/subscribe [:main-player/show])
+                        :main-player/toggle-layout
+                        :stream/toggle-layout)
+                      :show-description])])))
 
 (defn comments
   [{:keys [comments-page show-comments show-comments-loading url] :as stream}]
@@ -150,7 +119,9 @@
         (when (and show? (not (empty? related-streams)))
           [layout/accordeon
            {:label        "Suggested"
-            :on-open      #(rf/dispatch [:stream/toggle-layout :show-related])
+            :on-open      #(rf/dispatch [(if @(rf/subscribe [:main-player/show])
+                                           :main-player/toggle-layout
+                                           :stream/toggle-layout) :show-related])
             :open?        (not show-related)
             :left-icon    "fa-solid fa-list"
             :right-button [layout/popover-menu !menu-active?
@@ -164,9 +135,13 @@
 
 (defn stream
   []
-  (let [stream @(rf/subscribe [:stream])]
+  (let [{:keys [audio-streams video-streams name thumbnail-url] :as stream} @(rf/subscribe [:stream])
+        !player @(rf/subscribe [:main-player])
+        page-loading? @(rf/subscribe [:show-page-loading])]
     [:<>
-     [player stream]
+     (when-not page-loading?
+       [:div.flex.flex-col.justify-center.items-center.lg:pt-4
+        [player/video-player stream !player]])
      [layout/content-container
       [metadata stream]
       [description stream]

+ 1 - 2
src/frontend/tubo/utils.cljs

@@ -46,5 +46,4 @@
   [num]
   (let [duration (and (not (js/isNaN num)) (js/Date. (* num 1000)))
         slice    (and duration #(.slice % (if (>= (.getUTCHours duration) 1) 11 14) 19))]
-    (when slice
-      (-> duration (.toISOString) slice))))
+    (if slice (-> duration (.toISOString) slice) "--:--")))

+ 1 - 0
src/frontend/tubo/views.cljs

@@ -18,4 +18,5 @@
        (when-let [view (-> current-match :data :view)]
          [view current-match])
        [queue/queue]
+       [player/main-player]
        [player/background-player]]]]))

+ 2 - 1
tailwind.config.js

@@ -13,6 +13,7 @@ module.exports = {
     },
   },
   plugins: [
-    require("@tailwindcss/forms")
+    require("@tailwindcss/forms"),
+    require("@vidstack/react/tailwind.cjs")
   ],
 }