From 81f03c0b449bec1fa60cf2936bfc3f66a5ad58c1 Mon Sep 17 00:00:00 2001 From: Miguel Ángel Moreno Date: Tue, 17 Jan 2023 13:17:40 +0100 Subject: chore: Rename project --- .dir-locals.el | 7 +- README | 30 ++- deps.edn | 2 +- resources/src/css/tau.scss | 10 - resources/src/css/tubo.scss | 10 + shadow-cljs.edn | 4 +- src/backend/tau/api/channels.clj | 29 -- src/backend/tau/api/comments.clj | 45 ---- src/backend/tau/api/items.clj | 47 ---- src/backend/tau/api/playlists.clj | 31 --- src/backend/tau/api/services.clj | 79 ------ src/backend/tau/api/streams.clj | 36 --- src/backend/tau/core.clj | 12 - src/backend/tau/downloader_impl.clj | 71 ----- src/backend/tau/handler.clj | 70 ----- src/backend/tau/http.clj | 24 -- src/backend/tau/router.clj | 58 ---- src/backend/tubo/api/channels.clj | 29 ++ src/backend/tubo/api/comments.clj | 45 ++++ src/backend/tubo/api/items.clj | 46 ++++ src/backend/tubo/api/playlists.clj | 31 +++ src/backend/tubo/api/services.clj | 79 ++++++ src/backend/tubo/api/streams.clj | 36 +++ src/backend/tubo/core.clj | 12 + src/backend/tubo/downloader_impl.clj | 71 +++++ src/backend/tubo/handler.clj | 70 +++++ src/backend/tubo/http.clj | 24 ++ src/backend/tubo/router.clj | 58 ++++ src/frontend/tau/api.cljs | 15 -- src/frontend/tau/components/comments.cljs | 77 ------ src/frontend/tau/components/items.cljs | 93 ------- src/frontend/tau/components/loading.cljs | 8 - src/frontend/tau/components/navigation.cljs | 12 - src/frontend/tau/components/player.cljs | 70 ----- src/frontend/tau/core.cljs | 22 -- src/frontend/tau/events.cljs | 382 --------------------------- src/frontend/tau/routes.cljs | 58 ---- src/frontend/tau/subs.cljs | 98 ------- src/frontend/tau/util.cljs | 24 -- src/frontend/tau/views.cljs | 98 ------- src/frontend/tau/views/channel.cljs | 41 --- src/frontend/tau/views/kiosk.cljs | 26 -- src/frontend/tau/views/playlist.cljs | 40 --- src/frontend/tau/views/search.cljs | 28 -- src/frontend/tau/views/stream.cljs | 122 --------- src/frontend/tubo/api.cljs | 15 ++ src/frontend/tubo/components/comments.cljs | 77 ++++++ src/frontend/tubo/components/items.cljs | 93 +++++++ src/frontend/tubo/components/loading.cljs | 8 + src/frontend/tubo/components/navigation.cljs | 12 + src/frontend/tubo/components/player.cljs | 70 +++++ src/frontend/tubo/core.cljs | 22 ++ src/frontend/tubo/events.cljs | 382 +++++++++++++++++++++++++++ src/frontend/tubo/routes.cljs | 58 ++++ src/frontend/tubo/subs.cljs | 98 +++++++ src/frontend/tubo/util.cljs | 24 ++ src/frontend/tubo/views.cljs | 99 +++++++ src/frontend/tubo/views/channel.cljs | 41 +++ src/frontend/tubo/views/kiosk.cljs | 26 ++ src/frontend/tubo/views/playlist.cljs | 40 +++ src/frontend/tubo/views/search.cljs | 28 ++ src/frontend/tubo/views/stream.cljs | 122 +++++++++ webpack.config.js | 2 +- 63 files changed, 1752 insertions(+), 1745 deletions(-) delete mode 100644 resources/src/css/tau.scss create mode 100644 resources/src/css/tubo.scss delete mode 100644 src/backend/tau/api/channels.clj delete mode 100644 src/backend/tau/api/comments.clj delete mode 100644 src/backend/tau/api/items.clj delete mode 100644 src/backend/tau/api/playlists.clj delete mode 100644 src/backend/tau/api/services.clj delete mode 100644 src/backend/tau/api/streams.clj delete mode 100644 src/backend/tau/core.clj delete mode 100644 src/backend/tau/downloader_impl.clj delete mode 100644 src/backend/tau/handler.clj delete mode 100644 src/backend/tau/http.clj delete mode 100644 src/backend/tau/router.clj create mode 100644 src/backend/tubo/api/channels.clj create mode 100644 src/backend/tubo/api/comments.clj create mode 100644 src/backend/tubo/api/items.clj create mode 100644 src/backend/tubo/api/playlists.clj create mode 100644 src/backend/tubo/api/services.clj create mode 100644 src/backend/tubo/api/streams.clj create mode 100644 src/backend/tubo/core.clj create mode 100644 src/backend/tubo/downloader_impl.clj create mode 100644 src/backend/tubo/handler.clj create mode 100644 src/backend/tubo/http.clj create mode 100644 src/backend/tubo/router.clj delete mode 100644 src/frontend/tau/api.cljs delete mode 100644 src/frontend/tau/components/comments.cljs delete mode 100644 src/frontend/tau/components/items.cljs delete mode 100644 src/frontend/tau/components/loading.cljs delete mode 100644 src/frontend/tau/components/navigation.cljs delete mode 100644 src/frontend/tau/components/player.cljs delete mode 100644 src/frontend/tau/core.cljs delete mode 100644 src/frontend/tau/events.cljs delete mode 100644 src/frontend/tau/routes.cljs delete mode 100644 src/frontend/tau/subs.cljs delete mode 100644 src/frontend/tau/util.cljs delete mode 100644 src/frontend/tau/views.cljs delete mode 100644 src/frontend/tau/views/channel.cljs delete mode 100644 src/frontend/tau/views/kiosk.cljs delete mode 100644 src/frontend/tau/views/playlist.cljs delete mode 100644 src/frontend/tau/views/search.cljs delete mode 100644 src/frontend/tau/views/stream.cljs create mode 100644 src/frontend/tubo/api.cljs create mode 100644 src/frontend/tubo/components/comments.cljs create mode 100644 src/frontend/tubo/components/items.cljs create mode 100644 src/frontend/tubo/components/loading.cljs create mode 100644 src/frontend/tubo/components/navigation.cljs create mode 100644 src/frontend/tubo/components/player.cljs create mode 100644 src/frontend/tubo/core.cljs create mode 100644 src/frontend/tubo/events.cljs create mode 100644 src/frontend/tubo/routes.cljs create mode 100644 src/frontend/tubo/subs.cljs create mode 100644 src/frontend/tubo/util.cljs create mode 100644 src/frontend/tubo/views.cljs create mode 100644 src/frontend/tubo/views/channel.cljs create mode 100644 src/frontend/tubo/views/kiosk.cljs create mode 100644 src/frontend/tubo/views/playlist.cljs create mode 100644 src/frontend/tubo/views/search.cljs create mode 100644 src/frontend/tubo/views/stream.cljs diff --git a/.dir-locals.el b/.dir-locals.el index 0261cff..5ab24c3 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -5,10 +5,11 @@ . "(do (require '[shadow.cljs.devtools.api :as shadow]) (require '[shadow.cljs.devtools.server :as server]) (server/start!) -(shadow/watch :tau) -(shadow/nrepl-select :tau))") +(shadow/watch :tubo) +(shadow/nrepl-select :tubo))") (cider-merge-sessions . :project) - (eval . (when (boundp 'cider-jack-in-nrepl-middlewares) + (eval . (if (not (boundp 'cider-jack-in-nrepl-middlewares)) + (require 'cider) (make-variable-buffer-local 'cider-jack-in-nrepl-middlewares) (add-to-list 'cider-jack-in-nrepl-middlewares "cider.nrepl/cider-middleware") (add-to-list 'cider-jack-in-nrepl-middlewares "shadow.cljs.devtools.server.nrepl/middleware")))))) diff --git a/README b/README index 8f38c0e..fcdbb6f 100644 --- a/README +++ b/README @@ -1,7 +1,7 @@ # -*- mode: org; org-html-head-include-default-style: nil; org-html-postamble: nil; -*- #+OPTIONS: toc:nil -* Tau -An alternative front-end to various streaming sites. The aim of Tau is to free you from the world of ad-ridden streaming sites full of vendor lock-ins by providing you with a minimal interface to enjoy your favorite content. It currently supports the following platforms: +* Tubo +An alternative front-end to various streaming sites. The aim of Tubo is to free you from the world of ad-ridden streaming sites full of vendor lock-ins by providing you with a minimal interface to enjoy your favorite content. It currently supports the following platforms: - YouTube - SoundCloud @@ -9,22 +9,22 @@ An alternative front-end to various streaming sites. The aim of Tau is to free y - PeerTube - Bandcamp -To retrieve the data, it leverages the excellent [[https://github.com/TeamNewPipe/NewPipeExtractor][NewPipe Extractor]] library that powers the popular [[https://github.com/TeamNewPipe/NewPipe][NewPipe]] Android app. Tau exposes the extracted data over a REST API that is consumed by a local re-frame SPA. +To retrieve the data, it leverages the excellent [[https://github.com/TeamNewPipe/NewPipeExtractor][NewPipe Extractor]] library that powers the popular [[https://github.com/TeamNewPipe/NewPipe][NewPipe]] Android app. Tubo exposes the extracted data over a REST API that is consumed by a local re-frame SPA. -The ultimate goal behind Tau is to replicate the Newpipe experience on the web, so that it's accessible to those that don't use an Android device. +The ultimate goal behind Tubo is to replicate the Newpipe experience on the web, so that it's accessible to those that don't use an Android device. ** Installation -The easiest way to set up Tau's dependencies is via the [[https://guix.gnu.org/][GNU Guix]] package manager. Simply invoke what follows: +The easiest way to set up Tubo's dependencies is via the [[https://guix.gnu.org/][GNU Guix]] package manager. Simply invoke what follows: #+begin_src sh -cd /path/to/tau +cd /path/to/tubo guix shell #+end_src To run the application, first compile the downloader ahead-of-time. #+begin_src sh -clojure -M -e "(compile 'tau.downloader-impl)" +clojure -M -e "(compile 'tubo.downloader-impl)" #+end_src Fetch the front-end dependencies and build the front-end assets. @@ -37,7 +37,7 @@ npm run build Then, compile the front-end. #+begin_src sh -clojure -M:frontend compile tau +clojure -M:frontend compile tubo #+end_src You can now start a local server that listens on port 3000 by running the following: @@ -48,10 +48,16 @@ clojure -M:run Access the front-end in your browser at =http://localhost:3000=. +** Road-map +- [X] Basic audio player +- [ ] Track queuing system +- [ ] Playlists +- [ ] User settings + ** Screenshots -https://raw.githubusercontent.com/efimerspan/tau/master/assets/kiosk.jpg -https://raw.githubusercontent.com/efimerspan/tau/master/assets/channel.jpg -https://raw.githubusercontent.com/efimerspan/tau/master/assets/stream.jpg +[[https://raw.githubusercontent.com/efimerspan/tubo/master/assets/kiosk.jpg]] +[[https://raw.githubusercontent.com/efimerspan/tubo/master/assets/channel.jpg]] +[[https://raw.githubusercontent.com/efimerspan/tubo/master/assets/stream.jpg]] ** Contributing -You can use the project's [[https://lists.sr.ht/~conses/tau][mailing list]] to send feedback, patches or open discussions. Bugs should be reported on the project's [[https://todo.sr.ht/~conses/tau][bug-tracker]]. +You can use the project's [[https://lists.sr.ht/~conses/tubo][mailing list]] to send feedback, patches or open discussions. Bugs should be reported on the project's [[https://todo.sr.ht/~conses/tubo][bug-tracker]]. diff --git a/deps.edn b/deps.edn index f401d5d..ec6b8b0 100644 --- a/deps.edn +++ b/deps.edn @@ -23,4 +23,4 @@ day8.re-frame/http-fx {:mvn/version "0.2.4"} cljs-ajax/cljs-ajax {:mvn/version "0.8.4"}} :main-opts ["-m" "shadow.cljs.devtools.cli"]} - :run {:main-opts ["-m" "tau.core"]}}} + :run {:main-opts ["-m" "tubo.core"]}}} diff --git a/resources/src/css/tau.scss b/resources/src/css/tau.scss deleted file mode 100644 index 9119144..0000000 --- a/resources/src/css/tau.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import "typography"; -@import "variables"; -@import "@fortawesome/fontawesome-free/scss/brands"; -@import "@fortawesome/fontawesome-free/scss/regular"; -@import "@fortawesome/fontawesome-free/scss/solid"; -@import "@fortawesome/fontawesome-free/scss/fontawesome"; -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; -@import "video.js/dist/video-js.css"; diff --git a/resources/src/css/tubo.scss b/resources/src/css/tubo.scss new file mode 100644 index 0000000..9119144 --- /dev/null +++ b/resources/src/css/tubo.scss @@ -0,0 +1,10 @@ +@import "typography"; +@import "variables"; +@import "@fortawesome/fontawesome-free/scss/brands"; +@import "@fortawesome/fontawesome-free/scss/regular"; +@import "@fortawesome/fontawesome-free/scss/solid"; +@import "@fortawesome/fontawesome-free/scss/fontawesome"; +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; +@import "video.js/dist/video-js.css"; diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 16b4c38..cfb9930 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -1,7 +1,7 @@ {:deps {:aliases [:frontend]} :builds - {:tau + {:tubo {:target :browser :output-dir "resources/public/js" :asset-path "/js" - :modules {:main {:init-fn tau.core/init}}}}} + :modules {:main {:init-fn tubo.core/init}}}}} diff --git a/src/backend/tau/api/channels.clj b/src/backend/tau/api/channels.clj deleted file mode 100644 index 2c82767..0000000 --- a/src/backend/tau/api/channels.clj +++ /dev/null @@ -1,29 +0,0 @@ -(ns tau.api.channels - (:require - [clojure.java.data :as j] - [ring.util.codec :refer [url-decode]] - [tau.api.items :as items]) - (:import - org.schabi.newpipe.extractor.channel.ChannelInfo - org.schabi.newpipe.extractor.NewPipe - org.schabi.newpipe.extractor.Page)) - -(defn get-channel - ([url] - (let [info (ChannelInfo/getInfo (url-decode url))] - {:id (.getId info) - :name (.getName info) - :verified? (.isVerified info) - :banner (.getBannerUrl info) - :avatar (.getAvatarUrl info) - :description (.getDescription info) - :subscriber-count (when-not (= (.getSubscriberCount info) -1) (.getSubscriberCount info)) - :donation-links (.getDonationLinks info) - :next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getRelatedItems info)) - :service-id (.getServiceId info)})) - ([url page-url] - (let [service (NewPipe/getServiceByUrl (url-decode url)) - info (ChannelInfo/getMoreItems service (url-decode url) (Page. (url-decode page-url)))] - {:related-streams (items/get-items (.getItems info)) - :next-page (j/from-java (.getNextPage info))}))) diff --git a/src/backend/tau/api/comments.clj b/src/backend/tau/api/comments.clj deleted file mode 100644 index c0891ea..0000000 --- a/src/backend/tau/api/comments.clj +++ /dev/null @@ -1,45 +0,0 @@ -(ns tau.api.comments - (:require - [clojure.java.data :as j] - [ring.util.codec :refer [url-decode]]) - (:import - org.schabi.newpipe.extractor.NewPipe - org.schabi.newpipe.extractor.Page - org.schabi.newpipe.extractor.ListExtractor - org.schabi.newpipe.extractor.comments.CommentsInfoItem - org.schabi.newpipe.extractor.comments.CommentsInfo)) - -(defn get-comment-item - [item extractor] - {:id (.getCommentId item) - :text (.getCommentText item) - :uploader-name (.getUploaderName item) - :uploader-avatar (.getUploaderAvatarUrl item) - :uploader-url (.getUploaderUrl item) - :uploader-verified? (.isUploaderVerified item) - :upload-date (.getTextualUploadDate item) - :like-count (when-not (= (.getLikeCount item) -1) (.getLikeCount item)) - :reply-count (when-not (= (.getReplyCount item) -1) (.getReplyCount item)) - :hearted-by-uploader? (.isHeartedByUploader item) - :pinned? (.isPinned item) - :stream-position (when-not (= (.getStreamPosition item) -1) (.getStreamPosition item)) - :replies (when (.getReplies item) - (if extractor - (let [comments-page (.getPage extractor (.getReplies item))] - {:next-page (when (.hasNextPage comments-page) (j/from-java (.getNextPage comments-page))) - :items (map #(get-comment-item % extractor) (.getItems comments-page))}) - (j/from-java (.getReplies item))))}) - -(defn get-comments - ([url] - (let [info (CommentsInfo/getInfo (url-decode url)) - extractor (.getCommentsExtractor info)] - {:comments (map #(get-comment-item % extractor) (.getRelatedItems info)) - :next-page (j/from-java (.getNextPage info)) - :disabled? (.isCommentsDisabled info)})) - ([url page-url] - (let [service (NewPipe/getServiceByUrl (url-decode url)) - info (CommentsInfo/getMoreItems service (url-decode url) (Page. (url-decode page-url)))] - {:comments (map #(get-comment-item % nil) (.getItems info)) - :next-page (j/from-java (.getNextPage info)) - :disabled? false}))) diff --git a/src/backend/tau/api/items.clj b/src/backend/tau/api/items.clj deleted file mode 100644 index 676af3e..0000000 --- a/src/backend/tau/api/items.clj +++ /dev/null @@ -1,47 +0,0 @@ -(ns tau.api.items) - -(defn get-stream-item - [stream] - {:type :stream - :url (.getUrl stream) - :name (.getName stream) - :thumbnail-url (.getThumbnailUrl stream) - :uploader-name (.getUploaderName stream) - :uploader-url (.getUploaderUrl stream) - :uploader-avatar (.getUploaderAvatarUrl stream) - :upload-date (.getTextualUploadDate stream) - :short-description (.getShortDescription stream) - :duration (.getDuration stream) - :view-count (when-not (= (.getViewCount stream) -1) (.getViewCount stream)) - :uploaded (if (.getUploadDate stream) - (.. stream (getUploadDate) (offsetDateTime) (toInstant) (toEpochMilli)) - false) - :verified? (.isUploaderVerified stream)}) - -(defn get-channel-item - [channel] - {:type :channel - :url (.getUrl channel) - :name (.getName channel) - :thumbnail-url (.getThumbnailUrl channel) - :description (.getDescription channel) - :subscriber-count (when-not (= (.getSubscriberCount channel) -1) (.getSubscriberCount channel)) - :stream-count (when-not (= (.getStreamCount channel) -1) (.getStreamCount channel)) - :verified? (.isVerified channel)}) - -(defn get-playlist-item - [playlist] - {:type :playlist - :url (.getUrl playlist) - :name (.getName playlist) - :thumbnail-url (.getThumbnailUrl playlist) - :uploader-name (.getUploaderName playlist) - :stream-count (when-not (= (.getStreamCount playlist) -1) (.getStreamCount playlist))}) - -(defn get-items - [items] - (map #(case (.name (.getInfoType %)) - "STREAM" (get-stream-item %) - "CHANNEL" (get-channel-item %) - "PLAYLIST" (get-playlist-item %)) - items)) diff --git a/src/backend/tau/api/playlists.clj b/src/backend/tau/api/playlists.clj deleted file mode 100644 index 5520d2b..0000000 --- a/src/backend/tau/api/playlists.clj +++ /dev/null @@ -1,31 +0,0 @@ -(ns tau.api.playlists - (:require - [clojure.java.data :as j] - [ring.util.codec :refer [url-decode]] - [tau.api.items :as items]) - (:import - org.schabi.newpipe.extractor.playlist.PlaylistInfo - org.schabi.newpipe.extractor.Page - org.schabi.newpipe.extractor.NewPipe)) - -(defn get-playlist - ([url] - (let [service (NewPipe/getServiceByUrl (url-decode url)) - info (PlaylistInfo/getInfo service (url-decode url))] - {:id (.getId info) - :name (.getName info) - :playlist-type (j/from-java (.getPlaylistType info)) - :thumbnail-url (.getThumbnailUrl info) - :banner-url (.getBannerUrl info) - :uploader-name (.getUploaderName info) - :uploader-url (.getUploaderUrl info) - :uploader-avatar (.getUploaderAvatarUrl info) - :stream-count (.getStreamCount info) - :next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getRelatedItems info)) - :service-id (.getServiceId info)})) - ([url page-url] - (let [service (NewPipe/getServiceByUrl (url-decode url)) - info (PlaylistInfo/getMoreItems service url (Page. (url-decode page-url)))] - {:next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getItems info))}))) diff --git a/src/backend/tau/api/services.clj b/src/backend/tau/api/services.clj deleted file mode 100644 index 8b9638f..0000000 --- a/src/backend/tau/api/services.clj +++ /dev/null @@ -1,79 +0,0 @@ -(ns tau.api.services - (:require - [clojure.java.data :as j] - [ring.util.codec :refer [url-encode url-decode]] - [tau.api.items :as items]) - (:import - org.schabi.newpipe.extractor.kiosk.KioskInfo - org.schabi.newpipe.extractor.kiosk.KioskList - org.schabi.newpipe.extractor.InfoItem - org.schabi.newpipe.extractor.NewPipe - org.schabi.newpipe.extractor.Page - org.schabi.newpipe.extractor.StreamingService - org.schabi.newpipe.extractor.search.SearchInfo)) - -(defn search - ([service-id query content-filters sort-filter] - (let [service (NewPipe/getService service-id) - query-handler (.. service - (getSearchQHFactory) - (fromQuery query (or content-filters '()) (or sort-filter ""))) - info (SearchInfo/getInfo service query-handler)] - {:items (items/get-items (.getRelatedItems info)) - :next-page (j/from-java (.getNextPage info)) - :search-suggestion (.getSearchSuggestion info) - :corrected-search? (.isCorrectedSearch info)})) - ([service-id query content-filters sort-filter page-url] - (let [service (NewPipe/getService service-id) - url (url-decode page-url) - query-handler (.. service - (getSearchQHFactory) - (fromQuery query (or content-filters '()) (or sort-filter ""))) - info (SearchInfo/getMoreItems service query-handler (Page. url))] - {:items (items/get-items (.getItems info)) - :next-page (j/from-java (.getNextPage info))}))) - -(defn get-kiosk - ([service-id] - (let [service (NewPipe/getService service-id) - extractor (doto (.getDefaultKioskExtractor (.getKioskList service)) - (.fetchPage)) - info (KioskInfo/getInfo extractor)] - {:id (.getId info) - :url (.getUrl info) - :next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getRelatedItems info))})) - ([kiosk-id service-id] - (let [service (NewPipe/getService service-id) - extractor (doto (.getExtractorById (.getKioskList service) kiosk-id nil) - (.fetchPage)) - info (KioskInfo/getInfo extractor)] - {:id (.getId info) - :url (.getUrl info) - :next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getRelatedItems info))})) - ([kiosk-id service-id page-url] - (let [service (NewPipe/getService service-id) - extractor (.getExtractorById (.getKioskList service) kiosk-id nil) - url (url-decode page-url) - kiosk-info (KioskInfo/getInfo extractor) - info (KioskInfo/getMoreItems service (.getUrl kiosk-info) (Page. url))] - {:next-page (j/from-java (.getNextPage info)) - :related-streams (items/get-items (.getItems info))}))) - -(defn get-kiosks - [service-id] - (let [service (NewPipe/getService service-id) - kiosks (.getKioskList service)] - {:default-kiosk (.getDefaultKioskId kiosks) - :available-kiosks (.getAvailableKiosks kiosks)})) - -(defn get-service - [service] - {:id (.getServiceId service) - :info (j/from-java (.getServiceInfo service)) - :base-url (.getBaseUrl service)}) - -(defn get-services - [] - (map get-service (NewPipe/getServices))) diff --git a/src/backend/tau/api/streams.clj b/src/backend/tau/api/streams.clj deleted file mode 100644 index e1bd637..0000000 --- a/src/backend/tau/api/streams.clj +++ /dev/null @@ -1,36 +0,0 @@ -(ns tau.api.streams - (:require - [clojure.java.data :as j] - [ring.util.codec :refer [url-decode]] - [tau.api.items :as items]) - (:import - org.schabi.newpipe.extractor.stream.StreamInfo - org.schabi.newpipe.extractor.NewPipe - org.schabi.newpipe.extractor.localization.DateWrapper - java.time.Instant)) - -(defn get-stream - [url] - (let [info (StreamInfo/getInfo (url-decode url))] - {:name (.getName info) - :url (.getUrl info) - :description (.. info (getDescription) (getContent)) - :upload-date (.getTextualUploadDate info) - :uploader-name (.getUploaderName info) - :uploader-url (.getUploaderUrl info) - :uploader-avatar (.getUploaderAvatarUrl info) - :uploader-verified? (.isUploaderVerified info) - :service-id (.getServiceId info) - :thumbnail-url (.getThumbnailUrl info) - :duration (.getDuration info) - :tags (.getTags info) - :category (.getCategory info) - :view-count (.getViewCount info) - :like-count (when-not (= (.getLikeCount info) -1) (.getLikeCount info)) - :dislike-count (when-not (= (.getDislikeCount info) -1) (.getDislikeCount info)) - :subscriber-count (when-not (= (.getUploaderSubscriberCount info) -1) (.getUploaderSubscriberCount info)) - :audio-streams (j/from-java (.getAudioStreams info)) - :video-streams (j/from-java (.getVideoStreams info)) - :hls-url (.getHlsUrl info) - :dash-mpd-url (.getDashMpdUrl info) - :related-streams (items/get-items (.getRelatedStreams info))})) diff --git a/src/backend/tau/core.clj b/src/backend/tau/core.clj deleted file mode 100644 index 662f5b9..0000000 --- a/src/backend/tau/core.clj +++ /dev/null @@ -1,12 +0,0 @@ -(ns tau.core - (:gen-class) - (:require - [tau.http :as http])) - -(defn -main - [& _] - (http/start-server!)) - -(defn reset - [] - (http/stop-server!)) diff --git a/src/backend/tau/downloader_impl.clj b/src/backend/tau/downloader_impl.clj deleted file mode 100644 index cf355e1..0000000 --- a/src/backend/tau/downloader_impl.clj +++ /dev/null @@ -1,71 +0,0 @@ -(ns tau.downloader-impl - (:import - [org.schabi.newpipe.extractor.downloader Response Request] - [okhttp3 Request$Builder OkHttpClient$Builder RequestBody])) - -(gen-class - :name tau.DownloaderImpl - :constructors {[okhttp3.OkHttpClient$Builder] []} - :extends org.schabi.newpipe.extractor.downloader.Downloader - :init downloader-impl) - -(gen-class - :name tau.DownloaderImpl - :constructors {[okhttp3.OkHttpClient$Builder] []} - :extends org.schabi.newpipe.extractor.downloader.Downloader - :prefix "-" - :main false - :state state - :init downloader-impl - :methods [#^{:static true} [init [] tau.DownloaderImpl] - #^{:static true} [getInstance [] tau.DownloaderImpl]]) - -(def user-agent "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") -(def instance (atom nil)) - -(defn -downloader-impl [builder] - [[] (atom {:client - (.. builder - (readTimeout 30 (java.util.concurrent.TimeUnit/SECONDS)) - (build))})]) - -(defn -init - ([] - (-init (OkHttpClient$Builder.))) - ([builder] - (reset! instance (tau.DownloaderImpl. builder)))) - -(defn -getInstance [] - (or @instance (-init))) - -(defn -execute [this request] - (let [http-method (.httpMethod request) - url (.url request) - headers (.headers request) - data-to-send (.dataToSend request) - request-body (when data-to-send (RequestBody/create nil data-to-send)) - request-builder (.. (Request$Builder.) - (method http-method request-body) - (url url) - (addHeader "User-Agent" user-agent))] - (doseq [pair (.entrySet headers)] - (let [header-name (.getKey pair) - header-value-list (.getValue pair)] - (if (> (.size header-value-list) 1) - (do - (.removeHeader request-builder header-name) - (doseq [header-value header-value-list] - (.addHeader request-builder header-name header-value))) - (if (= (.size header-value-list) 1) - (.header request-builder header-name (.get header-value-list 0)))))) - (let [response (.. (@(.state this) :client) (newCall (.build request-builder)) (execute)) - body (.body response) - response-body-to-return (when body (.string body)) - latest-url (.. response (request) (url) (toString))] - (when (= (.code response) 429) - (.close response)) - (Response. (.code response) - (.message response) - (.. response (headers) (toMultimap)) - response-body-to-return - latest-url)))) diff --git a/src/backend/tau/handler.clj b/src/backend/tau/handler.clj deleted file mode 100644 index 3ec4469..0000000 --- a/src/backend/tau/handler.clj +++ /dev/null @@ -1,70 +0,0 @@ -(ns tau.handler - (:require - [clojure.string :as str] - [hiccup.page :as hiccup] - [ring.util.response :refer [response]] - [tau.api.streams :as streams] - [tau.api.channels :as channels] - [tau.api.playlists :as playlists] - [tau.api.comments :as comments] - [tau.api.services :as services])) - -(defn index - [_] - (response - (hiccup/html5 - [:head - [:meta {:charset "UTF-8"}] - [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] - [:title "Tau"] - (hiccup/include-css "/css/tau.css")] - [:body - [:div#app] - (hiccup/include-js "/js/main.js") - [:script "tau.core.init();"]]))) - -(defn search - [{:keys [parameters] :as req}] - (let [{:keys [service-id]} (:path parameters) - {:keys [q]} (:query parameters) - {:strs [contentFilters sortFilter nextPage]} (:query-params req) - content-filters (and contentFilters (str/split contentFilters #","))] - (response (if nextPage - (services/search service-id q contentFilters sortFilter nextPage) - (services/search service-id q contentFilters sortFilter))))) - -(defn channel - [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] - (response (if nextPage - (channels/get-channel url nextPage) - (channels/get-channel url)))) - -(defn playlist - [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] - (response (if nextPage - (playlists/get-playlist url nextPage) - (playlists/get-playlist url)))) - -(defn comments - [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] - (response (if nextPage - (comments/get-comments url nextPage) - (comments/get-comments url)))) - -(defn services - [_] - (response (services/get-services))) - -(defn kiosks - [{{{:keys [service-id]} :path} :parameters}] - (response (services/get-kiosks service-id))) - -(defn kiosk - [{{{:keys [kiosk-id service-id]} :path} :parameters {:strs [nextPage]} :query-params}] - (response (cond - (and kiosk-id service-id nextPage) (services/get-kiosk kiosk-id service-id nextPage) - (and kiosk-id service-id) (services/get-kiosk kiosk-id service-id) - :else (services/get-kiosk service-id)))) - -(defn stream [{{:keys [url]} :path-params}] - (response (streams/get-stream url))) diff --git a/src/backend/tau/http.clj b/src/backend/tau/http.clj deleted file mode 100644 index cbb5896..0000000 --- a/src/backend/tau/http.clj +++ /dev/null @@ -1,24 +0,0 @@ -(ns tau.http - (:require - [org.httpkit.server :refer [run-server]] - [tau.router :as router]) - (:import - tau.DownloaderImpl - org.schabi.newpipe.extractor.NewPipe - org.schabi.newpipe.extractor.localization.Localization)) - -(defonce server (atom nil)) - -(defn start-server! - ([] - (start-server! 3000)) - ([port] - (NewPipe/init (DownloaderImpl/init) (Localization. "en" "GB")) - (reset! server (run-server #'router/app {:port port})) - (println "Server running in port" port))) - -(defn stop-server! - [] - (when @server - (@server :timeout 100) - (reset! server nil))) diff --git a/src/backend/tau/router.clj b/src/backend/tau/router.clj deleted file mode 100644 index 5bab278..0000000 --- a/src/backend/tau/router.clj +++ /dev/null @@ -1,58 +0,0 @@ -(ns tau.router - (:require - [malli.experimental.lite :as l] - [reitit.ring :as ring] - [reitit.coercion :as coercion] - [reitit.ring.coercion :as rrc] - [reitit.coercion.malli] - [ring.middleware.reload :refer [wrap-reload]] - [ring.middleware.params :refer [wrap-params]] - [ring.middleware.json :refer [wrap-json-response]] - [ring.middleware.cors :refer [wrap-cors]] - [tau.handler :as handler])) - -(def router - (ring/router - [["/" handler/index] - ["/search" handler/index] - ["/stream" handler/index] - ["/channel" handler/index] - ["/playlist" handler/index] - ["/kiosk" handler/index] - ["/api" - ["/services" - ["" {:get handler/services}] - ["/:service-id/search" - {:get {:coercion reitit.coercion.malli/coercion - :parameters {:path {:service-id int?} - :query {:q string?}} - :handler handler/search}}] - ["/:service-id" - ["/default-kiosk" {:get {:coercion reitit.coercion.malli/coercion - :parameters {:path {:service-id int?}} - :handler handler/kiosk}}] - ["/kiosks" - ["" {:get {:coercion reitit.coercion.malli/coercion - :parameters {:path {:service-id int?}} - :handler handler/kiosks}}] - ["/:kiosk-id" {:get {:coercion reitit.coercion.malli/coercion - :parameters {:path {:service-id int? :kiosk-id string?}} - :handler handler/kiosk}}]]]] - ["/streams/:url" {:get handler/stream}] - ["/channels/:url" {:get handler/channel}] - ["/playlists/:url" {:get handler/playlist}] - ["/comments/:url" {:get handler/comments}]]] - {:data {:middleware [rrc/coerce-request-middleware - rrc/coerce-response-middleware - rrc/coerce-exceptions-middleware]}})) - -(def app - (ring/ring-handler - router - (ring/routes - (ring/create-resource-handler {:path "/"}) - (ring/create-default-handler - {:not-found (constantly {:status 404, :body "Not found"})})) - {:middleware [wrap-params - [wrap-json-response {:pretty true}] - wrap-reload]})) diff --git a/src/backend/tubo/api/channels.clj b/src/backend/tubo/api/channels.clj new file mode 100644 index 0000000..f19a65e --- /dev/null +++ b/src/backend/tubo/api/channels.clj @@ -0,0 +1,29 @@ +(ns tubo.api.channels + (:require + [clojure.java.data :as j] + [ring.util.codec :refer [url-decode]] + [tubo.api.items :as items]) + (:import + org.schabi.newpipe.extractor.channel.ChannelInfo + org.schabi.newpipe.extractor.NewPipe + org.schabi.newpipe.extractor.Page)) + +(defn get-channel + ([url] + (let [info (ChannelInfo/getInfo (url-decode url))] + {:id (.getId info) + :name (.getName info) + :verified? (.isVerified info) + :banner (.getBannerUrl info) + :avatar (.getAvatarUrl info) + :description (.getDescription info) + :subscriber-count (when-not (= (.getSubscriberCount info) -1) (.getSubscriberCount info)) + :donation-links (.getDonationLinks info) + :next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getRelatedItems info)) + :service-id (.getServiceId info)})) + ([url page-url] + (let [service (NewPipe/getServiceByUrl (url-decode url)) + info (ChannelInfo/getMoreItems service (url-decode url) (Page. (url-decode page-url)))] + {:related-streams (items/get-items (.getItems info)) + :next-page (j/from-java (.getNextPage info))}))) diff --git a/src/backend/tubo/api/comments.clj b/src/backend/tubo/api/comments.clj new file mode 100644 index 0000000..b4f03ba --- /dev/null +++ b/src/backend/tubo/api/comments.clj @@ -0,0 +1,45 @@ +(ns tubo.api.comments + (:require + [clojure.java.data :as j] + [ring.util.codec :refer [url-decode]]) + (:import + org.schabi.newpipe.extractor.NewPipe + org.schabi.newpipe.extractor.Page + org.schabi.newpipe.extractor.ListExtractor + org.schabi.newpipe.extractor.comments.CommentsInfoItem + org.schabi.newpipe.extractor.comments.CommentsInfo)) + +(defn get-comment-item + [item extractor] + {:id (.getCommentId item) + :text (.getCommentText item) + :uploader-name (.getUploaderName item) + :uploader-avatar (.getUploaderAvatarUrl item) + :uploader-url (.getUploaderUrl item) + :uploader-verified? (.isUploaderVerified item) + :upload-date (.getTextualUploadDate item) + :like-count (when-not (= (.getLikeCount item) -1) (.getLikeCount item)) + :reply-count (when-not (= (.getReplyCount item) -1) (.getReplyCount item)) + :hearted-by-uploader? (.isHeartedByUploader item) + :pinned? (.isPinned item) + :stream-position (when-not (= (.getStreamPosition item) -1) (.getStreamPosition item)) + :replies (when (.getReplies item) + (if extractor + (let [comments-page (.getPage extractor (.getReplies item))] + {:next-page (when (.hasNextPage comments-page) (j/from-java (.getNextPage comments-page))) + :items (map #(get-comment-item % extractor) (.getItems comments-page))}) + (j/from-java (.getReplies item))))}) + +(defn get-comments + ([url] + (let [info (CommentsInfo/getInfo (url-decode url)) + extractor (.getCommentsExtractor info)] + {:comments (map #(get-comment-item % extractor) (.getRelatedItems info)) + :next-page (j/from-java (.getNextPage info)) + :disabled? (.isCommentsDisabled info)})) + ([url page-url] + (let [service (NewPipe/getServiceByUrl (url-decode url)) + info (CommentsInfo/getMoreItems service (url-decode url) (Page. (url-decode page-url)))] + {:comments (map #(get-comment-item % nil) (.getItems info)) + :next-page (j/from-java (.getNextPage info)) + :disabled? false}))) diff --git a/src/backend/tubo/api/items.clj b/src/backend/tubo/api/items.clj new file mode 100644 index 0000000..63678c1 --- /dev/null +++ b/src/backend/tubo/api/items.clj @@ -0,0 +1,46 @@ +(ns tubo.api.items) + +(defn get-stream-item + [stream] + {:type :stream + :url (.getUrl stream) + :name (.getName stream) + :thumbnail-url (.getThumbnailUrl stream) + :uploader-name (.getUploaderName stream) + :uploader-url (.getUploaderUrl stream) + :uploader-avatar (.getUploaderAvatarUrl stream) + :upload-date (.getTextualUploadDate stream) + :short-description (.getShortDescription stream) + :duration (.getDuration stream) + :view-count (when-not (= (.getViewCount stream) -1) (.getViewCount stream)) + :uploaded (when (.getUploadDate stream) + (.. stream (getUploadDate) (offsetDateTime) (toInstant) (toEpochMilli))) + :verified? (.isUploaderVerified stream)}) + +(defn get-channel-item + [channel] + {:type :channel + :url (.getUrl channel) + :name (.getName channel) + :thumbnail-url (.getThumbnailUrl channel) + :description (.getDescription channel) + :subscriber-count (when-not (= (.getSubscriberCount channel) -1) (.getSubscriberCount channel)) + :stream-count (when-not (= (.getStreamCount channel) -1) (.getStreamCount channel)) + :verified? (.isVerified channel)}) + +(defn get-playlist-item + [playlist] + {:type :playlist + :url (.getUrl playlist) + :name (.getName playlist) + :thumbnail-url (.getThumbnailUrl playlist) + :uploader-name (.getUploaderName playlist) + :stream-count (when-not (= (.getStreamCount playlist) -1) (.getStreamCount playlist))}) + +(defn get-items + [items] + (map #(case (.name (.getInfoType %)) + "STREAM" (get-stream-item %) + "CHANNEL" (get-channel-item %) + "PLAYLIST" (get-playlist-item %)) + items)) diff --git a/src/backend/tubo/api/playlists.clj b/src/backend/tubo/api/playlists.clj new file mode 100644 index 0000000..9ded200 --- /dev/null +++ b/src/backend/tubo/api/playlists.clj @@ -0,0 +1,31 @@ +(ns tubo.api.playlists + (:require + [clojure.java.data :as j] + [ring.util.codec :refer [url-decode]] + [tubo.api.items :as items]) + (:import + org.schabi.newpipe.extractor.playlist.PlaylistInfo + org.schabi.newpipe.extractor.Page + org.schabi.newpipe.extractor.NewPipe)) + +(defn get-playlist + ([url] + (let [service (NewPipe/getServiceByUrl (url-decode url)) + info (PlaylistInfo/getInfo service (url-decode url))] + {:id (.getId info) + :name (.getName info) + :playlist-type (j/from-java (.getPlaylistType info)) + :thumbnail-url (.getThumbnailUrl info) + :banner-url (.getBannerUrl info) + :uploader-name (.getUploaderName info) + :uploader-url (.getUploaderUrl info) + :uploader-avatar (.getUploaderAvatarUrl info) + :stream-count (.getStreamCount info) + :next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getRelatedItems info)) + :service-id (.getServiceId info)})) + ([url page-url] + (let [service (NewPipe/getServiceByUrl (url-decode url)) + info (PlaylistInfo/getMoreItems service url (Page. (url-decode page-url)))] + {:next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getItems info))}))) diff --git a/src/backend/tubo/api/services.clj b/src/backend/tubo/api/services.clj new file mode 100644 index 0000000..0bc44af --- /dev/null +++ b/src/backend/tubo/api/services.clj @@ -0,0 +1,79 @@ +(ns tubo.api.services + (:require + [clojure.java.data :as j] + [ring.util.codec :refer [url-encode url-decode]] + [tubo.api.items :as items]) + (:import + org.schabi.newpipe.extractor.kiosk.KioskInfo + org.schabi.newpipe.extractor.kiosk.KioskList + org.schabi.newpipe.extractor.InfoItem + org.schabi.newpipe.extractor.NewPipe + org.schabi.newpipe.extractor.Page + org.schabi.newpipe.extractor.StreamingService + org.schabi.newpipe.extractor.search.SearchInfo)) + +(defn search + ([service-id query content-filters sort-filter] + (let [service (NewPipe/getService service-id) + query-handler (.. service + (getSearchQHFactory) + (fromQuery query (or content-filters '()) (or sort-filter ""))) + info (SearchInfo/getInfo service query-handler)] + {:items (items/get-items (.getRelatedItems info)) + :next-page (j/from-java (.getNextPage info)) + :search-suggestion (.getSearchSuggestion info) + :corrected-search? (.isCorrectedSearch info)})) + ([service-id query content-filters sort-filter page-url] + (let [service (NewPipe/getService service-id) + url (url-decode page-url) + query-handler (.. service + (getSearchQHFactory) + (fromQuery query (or content-filters '()) (or sort-filter ""))) + info (SearchInfo/getMoreItems service query-handler (Page. url))] + {:items (items/get-items (.getItems info)) + :next-page (j/from-java (.getNextPage info))}))) + +(defn get-kiosk + ([service-id] + (let [service (NewPipe/getService service-id) + extractor (doto (.getDefaultKioskExtractor (.getKioskList service)) + (.fetchPage)) + info (KioskInfo/getInfo extractor)] + {:id (.getId info) + :url (.getUrl info) + :next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getRelatedItems info))})) + ([kiosk-id service-id] + (let [service (NewPipe/getService service-id) + extractor (doto (.getExtractorById (.getKioskList service) kiosk-id nil) + (.fetchPage)) + info (KioskInfo/getInfo extractor)] + {:id (.getId info) + :url (.getUrl info) + :next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getRelatedItems info))})) + ([kiosk-id service-id page-url] + (let [service (NewPipe/getService service-id) + extractor (.getExtractorById (.getKioskList service) kiosk-id nil) + url (url-decode page-url) + kiosk-info (KioskInfo/getInfo extractor) + info (KioskInfo/getMoreItems service (.getUrl kiosk-info) (Page. url))] + {:next-page (j/from-java (.getNextPage info)) + :related-streams (items/get-items (.getItems info))}))) + +(defn get-kiosks + [service-id] + (let [service (NewPipe/getService service-id) + kiosks (.getKioskList service)] + {:default-kiosk (.getDefaultKioskId kiosks) + :available-kiosks (.getAvailableKiosks kiosks)})) + +(defn get-service + [service] + {:id (.getServiceId service) + :info (j/from-java (.getServiceInfo service)) + :base-url (.getBaseUrl service)}) + +(defn get-services + [] + (map get-service (NewPipe/getServices))) diff --git a/src/backend/tubo/api/streams.clj b/src/backend/tubo/api/streams.clj new file mode 100644 index 0000000..274d5f2 --- /dev/null +++ b/src/backend/tubo/api/streams.clj @@ -0,0 +1,36 @@ +(ns tubo.api.streams + (:require + [clojure.java.data :as j] + [ring.util.codec :refer [url-decode]] + [tubo.api.items :as items]) + (:import + org.schabi.newpipe.extractor.stream.StreamInfo + org.schabi.newpipe.extractor.NewPipe + org.schabi.newpipe.extractor.localization.DateWrapper + java.time.Instant)) + +(defn get-stream + [url] + (let [info (StreamInfo/getInfo (url-decode url))] + {:name (.getName info) + :url (.getUrl info) + :description (.. info (getDescription) (getContent)) + :upload-date (.getTextualUploadDate info) + :uploader-name (.getUploaderName info) + :uploader-url (.getUploaderUrl info) + :uploader-avatar (.getUploaderAvatarUrl info) + :uploader-verified? (.isUploaderVerified info) + :service-id (.getServiceId info) + :thumbnail-url (.getThumbnailUrl info) + :duration (.getDuration info) + :tags (.getTags info) + :category (.getCategory info) + :view-count (.getViewCount info) + :like-count (when-not (= (.getLikeCount info) -1) (.getLikeCount info)) + :dislike-count (when-not (= (.getDislikeCount info) -1) (.getDislikeCount info)) + :subscriber-count (when-not (= (.getUploaderSubscriberCount info) -1) (.getUploaderSubscriberCount info)) + :audio-streams (j/from-java (.getAudioStreams info)) + :video-streams (j/from-java (.getVideoStreams info)) + :hls-url (.getHlsUrl info) + :dash-mpd-url (.getDashMpdUrl info) + :related-streams (items/get-items (.getRelatedStreams info))})) diff --git a/src/backend/tubo/core.clj b/src/backend/tubo/core.clj new file mode 100644 index 0000000..a587909 --- /dev/null +++ b/src/backend/tubo/core.clj @@ -0,0 +1,12 @@ +(ns tubo.core + (:gen-class) + (:require + [tubo.http :as http])) + +(defn -main + [& _] + (http/start-server!)) + +(defn reset + [] + (http/stop-server!)) diff --git a/src/backend/tubo/downloader_impl.clj b/src/backend/tubo/downloader_impl.clj new file mode 100644 index 0000000..1582159 --- /dev/null +++ b/src/backend/tubo/downloader_impl.clj @@ -0,0 +1,71 @@ +(ns tubo.downloader-impl + (:import + [org.schabi.newpipe.extractor.downloader Response Request] + [okhttp3 Request$Builder OkHttpClient$Builder RequestBody])) + +(gen-class + :name tubo.DownloaderImpl + :constructors {[okhttp3.OkHttpClient$Builder] []} + :extends org.schabi.newpipe.extractor.downloader.Downloader + :init downloader-impl) + +(gen-class + :name tubo.DownloaderImpl + :constructors {[okhttp3.OkHttpClient$Builder] []} + :extends org.schabi.newpipe.extractor.downloader.Downloader + :prefix "-" + :main false + :state state + :init downloader-impl + :methods [#^{:static true} [init [] tubo.DownloaderImpl] + #^{:static true} [getInstance [] tubo.DownloaderImpl]]) + +(def user-agent "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") +(def instance (atom nil)) + +(defn -downloader-impl [builder] + [[] (atom {:client + (.. builder + (readTimeout 30 (java.util.concurrent.TimeUnit/SECONDS)) + (build))})]) + +(defn -init + ([] + (-init (OkHttpClient$Builder.))) + ([builder] + (reset! instance (tubo.DownloaderImpl. builder)))) + +(defn -getInstance [] + (or @instance (-init))) + +(defn -execute [this request] + (let [http-method (.httpMethod request) + url (.url request) + headers (.headers request) + data-to-send (.dataToSend request) + request-body (when data-to-send (RequestBody/create nil data-to-send)) + request-builder (.. (Request$Builder.) + (method http-method request-body) + (url url) + (addHeader "User-Agent" user-agent))] + (doseq [pair (.entrySet headers)] + (let [header-name (.getKey pair) + header-value-list (.getValue pair)] + (if (> (.size header-value-list) 1) + (do + (.removeHeader request-builder header-name) + (doseq [header-value header-value-list] + (.addHeader request-builder header-name header-value))) + (if (= (.size header-value-list) 1) + (.header request-builder header-name (.get header-value-list 0)))))) + (let [response (.. (@(.state this) :client) (newCall (.build request-builder)) (execute)) + body (.body response) + response-body-to-return (when body (.string body)) + latest-url (.. response (request) (url) (toString))] + (when (= (.code response) 429) + (.close response)) + (Response. (.code response) + (.message response) + (.. response (headers) (toMultimap)) + response-body-to-return + latest-url)))) diff --git a/src/backend/tubo/handler.clj b/src/backend/tubo/handler.clj new file mode 100644 index 0000000..2012504 --- /dev/null +++ b/src/backend/tubo/handler.clj @@ -0,0 +1,70 @@ +(ns tubo.handler + (:require + [clojure.string :as str] + [hiccup.page :as hiccup] + [ring.util.response :refer [response]] + [tubo.api.streams :as streams] + [tubo.api.channels :as channels] + [tubo.api.playlists :as playlists] + [tubo.api.comments :as comments] + [tubo.api.services :as services])) + +(defn index + [_] + (response + (hiccup/html5 + [:head + [:meta {:charset "UTF-8"}] + [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] + [:title "Tubo"] + (hiccup/include-css "/css/tubo.css")] + [:body + [:div#app] + (hiccup/include-js "/js/main.js") + [:script "tubo.core.init();"]]))) + +(defn search + [{:keys [parameters] :as req}] + (let [{:keys [service-id]} (:path parameters) + {:keys [q]} (:query parameters) + {:strs [contentFilters sortFilter nextPage]} (:query-params req) + content-filters (and contentFilters (str/split contentFilters #","))] + (response (if nextPage + (services/search service-id q contentFilters sortFilter nextPage) + (services/search service-id q contentFilters sortFilter))))) + +(defn channel + [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] + (response (if nextPage + (channels/get-channel url nextPage) + (channels/get-channel url)))) + +(defn playlist + [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] + (response (if nextPage + (playlists/get-playlist url nextPage) + (playlists/get-playlist url)))) + +(defn comments + [{{:keys [url]} :path-params {:strs [nextPage]} :query-params}] + (response (if nextPage + (comments/get-comments url nextPage) + (comments/get-comments url)))) + +(defn services + [_] + (response (services/get-services))) + +(defn kiosks + [{{{:keys [service-id]} :path} :parameters}] + (response (services/get-kiosks service-id))) + +(defn kiosk + [{{{:keys [kiosk-id service-id]} :path} :parameters {:strs [nextPage]} :query-params}] + (response (cond + (and kiosk-id service-id nextPage) (services/get-kiosk kiosk-id service-id nextPage) + (and kiosk-id service-id) (services/get-kiosk kiosk-id service-id) + :else (services/get-kiosk service-id)))) + +(defn stream [{{:keys [url]} :path-params}] + (response (streams/get-stream url))) diff --git a/src/backend/tubo/http.clj b/src/backend/tubo/http.clj new file mode 100644 index 0000000..9e1359f --- /dev/null +++ b/src/backend/tubo/http.clj @@ -0,0 +1,24 @@ +(ns tubo.http + (:require + [org.httpkit.server :refer [run-server]] + [tubo.router :as router]) + (:import + tubo.DownloaderImpl + org.schabi.newpipe.extractor.NewPipe + org.schabi.newpipe.extractor.localization.Localization)) + +(defonce server (atom nil)) + +(defn start-server! + ([] + (start-server! 3000)) + ([port] + (NewPipe/init (DownloaderImpl/init) (Localization. "en" "GB")) + (reset! server (run-server #'router/app {:port port})) + (println "Server running in port" port))) + +(defn stop-server! + [] + (when @server + (@server :timeout 100) + (reset! server nil))) diff --git a/src/backend/tubo/router.clj b/src/backend/tubo/router.clj new file mode 100644 index 0000000..73e75ee --- /dev/null +++ b/src/backend/tubo/router.clj @@ -0,0 +1,58 @@ +(ns tubo.router + (:require + [malli.experimental.lite :as l] + [reitit.ring :as ring] + [reitit.coercion :as coercion] + [reitit.ring.coercion :as rrc] + [reitit.coercion.malli] + [ring.middleware.reload :refer [wrap-reload]] + [ring.middleware.params :refer [wrap-params]] + [ring.middleware.json :refer [wrap-json-response]] + [ring.middleware.cors :refer [wrap-cors]] + [tubo.handler :as handler])) + +(def router + (ring/router + [["/" handler/index] + ["/search" handler/index] + ["/stream" handler/index] + ["/channel" handler/index] + ["/playlist" handler/index] + ["/kiosk" handler/index] + ["/api" + ["/services" + ["" {:get handler/services}] + ["/:service-id/search" + {:get {:coercion reitit.coercion.malli/coercion + :parameters {:path {:service-id int?} + :query {:q string?}} + :handler handler/search}}] + ["/:service-id" + ["/default-kiosk" {:get {:coercion reitit.coercion.malli/coercion + :parameters {:path {:service-id int?}} + :handler handler/kiosk}}] + ["/kiosks" + ["" {:get {:coercion reitit.coercion.malli/coercion + :parameters {:path {:service-id int?}} + :handler handler/kiosks}}] + ["/:kiosk-id" {:get {:coercion reitit.coercion.malli/coercion + :parameters {:path {:service-id int? :kiosk-id string?}} + :handler handler/kiosk}}]]]] + ["/streams/:url" {:get handler/stream}] + ["/channels/:url" {:get handler/channel}] + ["/playlists/:url" {:get handler/playlist}] + ["/comments/:url" {:get handler/comments}]]] + {:data {:middleware [rrc/coerce-request-middleware + rrc/coerce-response-middleware + rrc/coerce-exceptions-middleware]}})) + +(def app + (ring/ring-handler + router + (ring/routes + (ring/create-resource-handler {:path "/"}) + (ring/create-default-handler + {:not-found (constantly {:status 404, :body "Not found"})})) + {:middleware [wrap-params + [wrap-json-response {:pretty true}] + wrap-reload]})) diff --git a/src/frontend/tau/api.cljs b/src/frontend/tau/api.cljs deleted file mode 100644 index e69bc9c..0000000 --- a/src/frontend/tau/api.cljs +++ /dev/null @@ -1,15 +0,0 @@ -(ns tau.api - (:require - [ajax.core :as ajax])) - -(defn get-request - ([uri on-success on-failure] - (get-request uri on-success on-failure {})) - ([uri on-success on-failure params] - {:http-xhrio {:method :get - :uri uri - :params params - :format (ajax/json-request-format) - :response-format (ajax/json-response-format {:keywords? true}) - :on-success on-success - :on-failure on-failure}})) diff --git a/src/frontend/tau/components/comments.cljs b/src/frontend/tau/components/comments.cljs deleted file mode 100644 index bfbf822..0000000 --- a/src/frontend/tau/components/comments.cljs +++ /dev/null @@ -1,77 +0,0 @@ -(ns tau.components.comments - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.components.loading :as loading] - [tau.events :as events] - [tau.util :as util])) - -(defn comment-item - [{:keys [id text uploader-name uploader-avatar uploader-url stream-position - upload-date uploader-verified? like-count hearted-by-uploader? - pinned? replies reply-count key show-replies author-name author-avatar]}] - [:div.flex.my-4 {:key key} - (when uploader-avatar - [:div.flex.items-center.py-3.box-border.h-12 - (when uploader-url - [:div.w-12 - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url}) :title uploader-name} - [:img.rounded-full.object-cover.min-w-full.min-h-full {:src uploader-avatar}]]])]) - [:div.ml-4 - [:div.flex.items-center - (when pinned? - [:i.fa-solid.fa-thumbtack.mr-2.text-xs]) - (when uploader-name - [:div.flex.items-center - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url}) :title uploader-name} - [:h1.text-gray-300.font-bold uploader-name]] - (when stream-position - [:p.mx-1.text-xs (str "at " (util/format-duration stream-position))])]) - (when uploader-verified? - [:i.fa-solid.fa-circle-check.ml-2])] - [:div.my-2 - [:p text]] - [:div..flex.items-center.my-2 - [:div.mr-4 - [:p (util/format-date upload-date)]] - (when (and like-count (> like-count 0)) - [:div.flex.items-center.my-2 - [:i.fa-solid.fa-thumbs-up.text-xs] - [:p.mx-1 like-count]]) - (when hearted-by-uploader? - [:div.relative.w-4.h-4.mx-2 - [:i.fa-solid.fa-heart.absolute.-bottom-1.-right-1.text-xs.text-red-500] - [:img.rounded-full.object-covermax-w-full.min-h-full - {:src author-avatar :title (str author-name " hearted this comment")}]])] - [:div.flex.items-center.cursor-pointer - {:on-click #(rf/dispatch [::events/toggle-comment-replies id])} - (when replies - (if show-replies - [:<> - [:p.font-bold "Hide replies"] - [:i.fa-solid.fa-turn-up.mx-2.text-xs]] - [:<> - [:p.font-bold (str reply-count (if (= reply-count 1) " reply" " replies"))] - [:i.fa-solid.fa-turn-down.mx-2.text-xs]]))]]]) - -(defn comments - [{:keys [comments next-page disabled?]} author-name author-avatar url] - (let [pagination-loading? @(rf/subscribe [:show-pagination-loading]) - service-color @(rf/subscribe [:service-color])] - [:div.flex.flex-col - [:div - (for [[i {:keys [replies show-replies] :as comment}] (map-indexed vector comments)] - [:div.flex.flex-col {:key i} - [comment-item (assoc comment :key i :author-name author-name :author-avatar author-avatar)] - (when (and replies show-replies) - [:div {:style {:marginLeft "32px"}} - (for [[i reply] (map-indexed vector (:items replies))] - [comment-item (assoc reply :key i :author-name author-name :author-avatar author-avatar)])])])] - (when (:url next-page) - (if pagination-loading? - (loading/loading-icon service-color) - [:div.flex.items-center.justify-center - {:style {:cursor "pointer"} - :on-click #(rf/dispatch [::events/comments-pagination url (:url next-page)])} - [:i.fa-solid.fa-plus] - [:p.px-2 "Show more comments"]]))])) diff --git a/src/frontend/tau/components/items.cljs b/src/frontend/tau/components/items.cljs deleted file mode 100644 index dccd43f..0000000 --- a/src/frontend/tau/components/items.cljs +++ /dev/null @@ -1,93 +0,0 @@ -(ns tau.components.items - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.components.loading :as loading] - [tau.util :as util])) - -(defn thumbnail - [thumbnail-url route url name duration] - [:div.flex.py-2.box-border.h-44.xs:h-28 - [:div.relative.min-w-full - [:a.absolute.min-w-full.min-h-full.z-10 {:href route :title name}] - [:img.rounded.object-cover.min-h-full.max-h-full.min-w-full {:src thumbnail-url}] - (when duration - [:div.rounded.p-2.absolute {:style {:bottom 5 :right 5 :background "rgba(0,0,0,.7)" :zIndex "0"}} - [:p {:style {:fontSize "14px"}} - (if (= duration 0) - "LIVE" - (util/format-duration duration))]])]]) - -(defn item-content - [{:keys [url name thumbnail-url description subscriber-count - stream-count verified? key uploader-name uploader-url - uploader-avatar upload-date short-description view-count]} item-route] - [:<> - (when name - [:div.my-2 - [:a {:href item-route :title name} - [:h1.line-clamp-2.my-1 name]]]) - (when-not (empty? uploader-name) - [:div.flex.items-center.my-2 - (if uploader-url - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url}) :title uploader-name} - [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 uploader-name]] - [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 uploader-name]) - (when verified? - [:i.fa-solid.fa-circle-check])]) - (when subscriber-count - [:div.flex.items-center - [:i.fa-solid.fa-users.text-xs] - [:p.mx-2 subscriber-count]]) - (when stream-count - [:div.flex.items-center - [:i.fa-solid.fa-video.text-xs] - [:p.mx-2 stream-count]]) - [:div.flex.my-1.justify-between - [:p (util/format-date upload-date)] - (when view-count - [:div.flex.items-center.h-full.pl-2 - [:i.fa-solid.fa-eye.text-xs] - [:p.pl-1.5 (util/format-quantity view-count)]])]]) - -(defn stream-item - [{:keys [url name thumbnail-url duration] :as item}] - [:<> - [thumbnail thumbnail-url (rfe/href :tau.routes/stream nil {:url url}) url name duration] - [item-content item (rfe/href :tau.routes/stream nil {:url url})]]) - -(defn channel-item - [{:keys [url name thumbnail-url] :as item}] - [:<> - [thumbnail thumbnail-url (rfe/href :tau.routes/channel nil {:url url}) url name nil] - [item-content item (rfe/href :tau.routes/channel nil {:url url})]]) - -(defn playlist-item - [{:keys [url name thumbnail-url] :as item}] - [:<> - [thumbnail thumbnail-url (rfe/href :tau.routes/playlist nil {:url url}) url name nil] - [item-content item (rfe/href :tau.routes/playlist nil {:url url})]]) - -(defn generic-item - [item] - [:div.w-full.xs:w-56.h-80.xs:h-72.my-2 {:key key} - [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full - (case (:type item) - "stream" [stream-item item] - "channel" [channel-item item] - "playlist" [playlist-item item])]]) - -(defn related-streams - [related-streams next-page-url] - (let [service-color @(rf/subscribe [:service-color]) - pagination-loading? @(rf/subscribe [:show-pagination-loading])] - [:div.flex.flex-col.justify-center.items-center.flex-auto.my-2.md:my-8 - (if (empty? related-streams) - [:div.flex.items-center - [:p "No available streams"]] - [:div.flex.justify-center.flex-wrap - (for [[i item] (map-indexed vector related-streams) - :let [keyed-item (assoc item :key i)]] - [generic-item keyed-item])]) - (when-not (empty? next-page-url) - [loading/loading-icon service-color "text-2xl" (when-not pagination-loading? "invisible")])])) diff --git a/src/frontend/tau/components/loading.cljs b/src/frontend/tau/components/loading.cljs deleted file mode 100644 index 40f6db3..0000000 --- a/src/frontend/tau/components/loading.cljs +++ /dev/null @@ -1,8 +0,0 @@ -(ns tau.components.loading) - -(defn loading-icon - [service-color & styles] - [:div.w-full.flex.justify-center.items-center.flex-auto - [:i.fas.fa-circle-notch.fa-spin - {:class (apply str (if (> (count styles) 1) (interpose " " styles) styles)) - :style {:color service-color}}]]) diff --git a/src/frontend/tau/components/navigation.cljs b/src/frontend/tau/components/navigation.cljs deleted file mode 100644 index 87e6dc5..0000000 --- a/src/frontend/tau/components/navigation.cljs +++ /dev/null @@ -1,12 +0,0 @@ -(ns tau.components.navigation - (:require - [re-frame.core :as rf] - [tau.events :as events])) - -(defn back-button [service-color] - [:div.flex.items-center - [:button.py-4.px-2 - {:on-click #(rf/dispatch [::events/history-back])} - [:i.fa-solid.fa-chevron-left - {:style {:color service-color}}] - [:span " Back"]]]) diff --git a/src/frontend/tau/components/player.cljs b/src/frontend/tau/components/player.cljs deleted file mode 100644 index 11cc9c5..0000000 --- a/src/frontend/tau/components/player.cljs +++ /dev/null @@ -1,70 +0,0 @@ -(ns tau.components.player - (:require - [reagent.core :as r] - [reagent.dom :as rdom] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.events :as events] - ["video.js" :as videojs])) - -(defn global-player - [] - (let [!player (r/atom nil) - !loop? (r/atom nil)] - (fn [] - (let [{:keys [uploader-name uploader-url name stream url service-color]} @(rf/subscribe [:global-stream]) - show-global-player? @(rf/subscribe [:show-global-player])] - (when show-global-player? - [:div.sticky.bottom-0.z-50.bg-neutral-900.p-5.absolute.box-border.m-0 - {:style {:borderColor service-color :borderTopWidth "2px" :borderStyle "solid"}} - [:div.flex.items-center.justify-between - [:div.flex.flex-wrap.items-center - [:div.flex.flex-col - [:a.text-xs - {:href (rfe/href :tau.router/stream nil {:url url})} name] - [:a.text-xs.text-gray-300 - {:href (rfe/href :tau.router/channel nil {:url uploader-url})} uploader-name]] - [:div.px-2.py-0.md:pt-4 - [:audio {:src stream :ref #(reset! !player %) :loop @!loop?}]] - [:div.mx-2 - [:button.focus:ring-transparent.mx-2 - {:on-click (fn [] (swap! !loop? #(not %)))} - [:i.fa-solid.fa-repeat - {:style {:color (when @!loop? service-color)}}]] - [:button.focus:ring-transparent.mx-2 - {:on-click #(when-let [player @!player] - (if (.-paused player) - (.play player) - (.pause player)))} - (if @!player - (if (.-paused @!player) - [:i.fa-solid.fa-play] - [:i.fa-solid.fa-pause]) - [:i.fa-solid.fa-play])]]] - [:div.px-2 - [:i.fa-solid.fa-close.cursor-pointer - {:on-click (fn [] - (rf/dispatch [::events/toggle-global-player]) - (.pause @!player))}]]]]))))) - -(defn stream-player - [options url] - (let [!player (atom nil)] - (r/create-class - {:display-name "StreamPlayer" - :component-did-mount - (fn [this] - (reset! !player (videojs (rdom/dom-node this) (clj->js options)))) - :component-did-update - (fn [this [_ prev-argv prev-more]] - (when (and @!player (not= prev-more (first (r/children this)))) - (.src @!player (apply array (map #(js-obj "type" % "src" (first (r/children this))) - ["video/mp4" "video/webm"]))) - (.ready @!player #(.play @!player)))) - :component-will-unmount - (fn [_] - (when @!player - (.dispose @!player))) - :reagent-render - (fn [options url] - [:video-js.vjs-default-skin.vjs-big-play-centered.bottom-0.object-cover.min-h-full.max-h-full.min-w-full])}))) diff --git a/src/frontend/tau/core.cljs b/src/frontend/tau/core.cljs deleted file mode 100644 index 7665a20..0000000 --- a/src/frontend/tau/core.cljs +++ /dev/null @@ -1,22 +0,0 @@ -(ns tau.core - (:require - ["react-dom/client" :as rdom] - [reagent.core :as r] - [re-frame.core :as rf] - [tau.events :as events] - [tau.routes :as routes] - [tau.subs] - [tau.views :as views])) - -(defonce root (rdom/createRoot (.querySelector js/document "#app"))) - -(defn ^:dev/after-load mount-root - [] - (rf/clear-subscription-cache!) - (routes/start-routes!) - (.render root (r/as-element [views/app]))) - -(defn ^:export init - [] - (rf/dispatch-sync [::events/initialize-db]) - (mount-root)) diff --git a/src/frontend/tau/events.cljs b/src/frontend/tau/events.cljs deleted file mode 100644 index d854b0e..0000000 --- a/src/frontend/tau/events.cljs +++ /dev/null @@ -1,382 +0,0 @@ -(ns tau.events - (:require - [day8.re-frame.http-fx] - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [reitit.frontend.controllers :as rfc] - [tau.api :as api])) - -(rf/reg-event-db - ::initialize-db - (fn [_ _] - {:global-search "" - :service-id 0 - :service-color "#cc0000" - :stream {} - :search-results [] - :services [] - :current-match nil - :page-scroll 0})) - -(rf/reg-fx - ::scroll-to-top - (fn [_] - (.scrollTo js/window #js {"top" 0 "behavior" "smooth"}))) - -(rf/reg-fx - ::history-back! - (fn [_] - (.back js/window.history))) - -(rf/reg-event-fx - ::history-back - (fn [_ _] - {::history-back! nil})) - -(rf/reg-event-db - ::page-scroll - (fn [db _] - (when (> (.-scrollY js/window) 0) - (assoc db :page-scroll (+ (.-scrollY js/window) (.-innerHeight js/window)))))) - -(rf/reg-event-db - ::reset-page-scroll - (fn [db _] - (assoc db :page-scroll 0))) - -(rf/reg-event-db - ::toggle-mobile-nav - (fn [db _] - (assoc db :show-mobile-nav (not (:show-mobile-nav db))))) - -(rf/reg-event-fx - ::navigated - (fn [{:keys [db]} [_ new-match]] - (let [old-match (:current-match db) - controllers (rfc/apply-controllers (:controllers old-match) new-match) - match (assoc new-match :controllers controllers)] - {:db (-> db - (assoc :current-match match) - (assoc :show-pagination-loading false)) - ::scroll-to-top nil}))) - -(rf/reg-event-fx - ::navigate - (fn [_ [_ route]] - {::navigate! route})) - -(rf/reg-fx - ::navigate! - (fn [{:keys [name params query]}] - (rfe/push-state name params query))) - -(rf/reg-event-db - ::bad-response - (fn [db [_ res]] - (js/console.log res) - (assoc db :http-response (get-in res [:response :error])))) - -(rf/reg-event-db - ::change-global-search - (fn [db [_ res]] - (assoc db :global-search res))) - -(rf/reg-event-db - ::change-service-color - (fn [db [_ service-id]] - (assoc db :service-color - (case service-id - 0 "#cc0000" - 1 "#ff7700" - 2 "#333333" - 3 "#F2690D" - 4 "#629aa9")))) - -(rf/reg-event-fx - ::change-service-id - (fn [{:keys [db]} [_ service-id]] - {:db (assoc db :service-id service-id) - :fx [[:dispatch [::change-service-color service-id]]]})) - -(rf/reg-event-db - ::load-paginated-channel-results - (fn [db [_ res]] - (-> db - (update-in [:channel :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:channel :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::channel-pagination - (fn [{:keys [db]} [_ uri next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/api/channels/" (js/encodeURIComponent uri) ) - [::load-paginated-channel-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-paginated-playlist-results - (fn [db [_ res]] - (-> db - (update-in [:playlist :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:playlist :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::playlist-pagination - (fn [{:keys [db]} [_ uri next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/api/playlists/" (js/encodeURIComponent uri)) - [::load-paginated-playlist-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-paginated-search-results - (fn [db [_ res]] - (-> db - (update-in [:search-results :items] #(apply conj %1 %2) - (:items (js->clj res :keywordize-keys true))) - (assoc-in [:search-results :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::search-pagination - (fn [{:keys [db]} [_ query id next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/api/services/" id "/search") - [::load-paginated-search-results] [::bad-response] - {:q query - :nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::change-global-stream - (fn [db [_ global-stream]] - (assoc db :global-stream global-stream))) - -(rf/reg-event-db - ::toggle-global-player - (fn [db _] - (assoc db :show-global-player (not (:show-global-player db))))) - -(rf/reg-event-fx - ::switch-to-global-player - (fn [{:keys [db]} [_ global-stream]] - {:db (assoc db :show-global-player true) - :fx [[:dispatch [::change-global-stream global-stream]]]})) - -(rf/reg-event-db - ::load-services - (fn [db [_ res]] - (assoc db :services (js->clj res :keywordize-keys true)))) - -(rf/reg-event-fx - ::get-services - (fn [{:keys [db]} _] - (api/get-request "/api/services" [::load-services] [::bad-response]))) - -(rf/reg-event-db - ::load-comments - (fn [db [_ res]] - (-> db - (assoc-in [:stream :comments-page] (js->clj res :keywordize-keys true)) - (assoc-in [:stream :show-comments-loading] false)))) - -(rf/reg-event-fx - ::get-comments - (fn [{:keys [db]} [_ url]] - (assoc - (api/get-request (str "/api/comments/" (js/encodeURIComponent url)) - [::load-comments] [::bad-response]) - :db (-> db - (assoc-in [:stream :show-comments-loading] true) - (assoc-in [:stream :show-comments] true))))) - -(rf/reg-event-db - ::toggle-comments - (fn [db _] - (assoc-in db [:stream :show-comments] (not (-> db :stream :show-comments))))) - -(rf/reg-event-db - ::toggle-comment-replies - (fn [db [_ comment-id]] - (update-in db [:stream :comments-page :comments] - (fn [comments] - (map #(if (= (:id %) comment-id) - (assoc % :show-replies (not (:show-replies %))) - %) - comments))))) - -(rf/reg-event-db - ::load-paginated-comments - (fn [db [_ res]] - (-> db - (update-in [:stream :comments-page :comments] #(apply conj %1 %2) - (:comments (js->clj res :keywordize-keys true))) - (assoc-in [:stream :comments-page :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::comments-pagination - (fn [{:keys [db]} [_ url next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request (str "/api/comments/" (js/encodeURIComponent url)) - [::load-paginated-comments] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-db - ::load-kiosks - (fn [db [_ res]] - (assoc db :kiosks (js->clj res :keywordize-keys true)))) - -(rf/reg-event-fx - ::get-kiosks - (fn [{:keys [db]} [_ id]] - (api/get-request (str "/api/services/" id "/kiosks") [::load-kiosks] [::bad-response]))) - -(rf/reg-event-db - ::load-kiosk - (fn [db [_ res]] - (assoc db :kiosk (js->clj res :keywordize-keys true) - :show-page-loading false))) - -(rf/reg-event-fx - ::get-default-kiosk - (fn [{:keys [db]} [_ service-id]] - (assoc - (api/get-request (str "/api/services/" service-id "/default-kiosk") - [::load-kiosk] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-fx - ::get-kiosk - (fn [{:keys [db]} [_ service-id kiosk-id]] - (if kiosk-id - (assoc - (api/get-request (str "/api/services/" service-id "/kiosks/" - (js/encodeURIComponent kiosk-id)) - [::load-kiosk] [::bad-response]) - :db (assoc db :show-page-loading true)) - {:fx [[:dispatch [::get-default-kiosk service-id]]]}))) - -(rf/reg-event-fx - ::change-service - (fn [{:keys [db]} [_ service-id]] - {:fx [[:dispatch - [::navigate {:name :tau.routes/kiosk - :params {} - :query {:serviceId service-id}}]]]})) - -(rf/reg-event-db - ::load-paginated-kiosk-results - (fn [db [_ res]] - (-> db - (update-in [:kiosk :related-streams] #(apply conj %1 %2) - (:related-streams (js->clj res :keywordize-keys true))) - (assoc-in [:kiosk :next-page] - (:next-page (js->clj res :keywordize-keys true))) - (assoc :show-pagination-loading false)))) - -(rf/reg-event-fx - ::kiosk-pagination - (fn [{:keys [db]} [_ service-id kiosk-id next-page-url]] - (if (empty? next-page-url) - {:db (assoc db :show-pagination-loading false)} - (assoc - (api/get-request - (str "/api/services/" service-id "/kiosks/" (js/encodeURIComponent kiosk-id)) - [::load-paginated-kiosk-results] [::bad-response] - {:nextPage (js/encodeURIComponent next-page-url)}) - :db (assoc db :show-pagination-loading true))))) - -(rf/reg-event-fx - ::load-stream - (fn [{:keys [db]} [_ res]] - (let [stream-res (js->clj res :keywordize-keys true)] - {:db (assoc db :stream stream-res - :show-page-loading false) - :fx [[:dispatch [::change-stream-format nil]] - [:dispatch [::get-comments (:url stream-res)]]]}))) - -(rf/reg-event-fx - ::get-stream - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request (str "/api/streams/" (js/encodeURIComponent uri)) - [::load-stream] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-db - ::change-stream-format - (fn [{:keys [stream] :as db} [_ format-id]] - (let [{:keys [audio-streams video-streams]} stream] - (if format-id - (assoc db :stream-format - (first (filter #(= format-id (:id %)) (apply conj audio-streams video-streams)))) - (assoc db :stream-format (-> (if (empty? video-streams) audio-streams video-streams) - last)))))) - -(rf/reg-event-db - ::load-channel - (fn [db [_ res]] - (assoc db :channel (js->clj res :keywordize-keys true) - :show-page-loading false))) - -(rf/reg-event-fx - ::get-channel - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request - (str "/api/channels/" (js/encodeURIComponent uri)) - [::load-channel] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-db - ::load-playlist - (fn [db [_ res]] - (assoc db :playlist (js->clj res :keywordize-keys true) - :show-page-loading false))) - -(rf/reg-event-fx - ::get-playlist - (fn [{:keys [db]} [_ uri]] - (assoc - (api/get-request (str "/api/playlists/" (js/encodeURIComponent uri)) - [::load-playlist] [::bad-response]) - :db (assoc db :show-page-loading true)))) - -(rf/reg-event-db - ::load-search-results - (fn [db [_ res]] - (assoc db :search-results (js->clj res :keywordize-keys true) - :show-page-loading false - :global-search ""))) - -(rf/reg-event-fx - ::get-search-results - (fn [{:keys [db]} [_ service-id query]] - (assoc - (api/get-request (str "/api/services/" service-id "/search") - [::load-search-results] [::bad-response] - {:q query}) - :db (assoc db :show-page-loading true)))) diff --git a/src/frontend/tau/routes.cljs b/src/frontend/tau/routes.cljs deleted file mode 100644 index a5fb579..0000000 --- a/src/frontend/tau/routes.cljs +++ /dev/null @@ -1,58 +0,0 @@ -(ns tau.routes - (:require - [reitit.frontend :as ref] - [reitit.frontend.easy :as rfe] - [re-frame.core :as rf] - [tau.events :as events] - [tau.views.channel :as channel] - [tau.views.kiosk :as kiosk] - [tau.views.playlist :as playlist] - [tau.views.search :as search] - [tau.views.stream :as stream])) - -(def routes - (ref/router - [["/" {:view kiosk/kiosk - :name ::home - :controllers [{:start (fn [_] - (rf/dispatch [::events/change-service-id 0]) - (rf/dispatch [::events/get-default-kiosk 0]) - (rf/dispatch [::events/get-kiosks 0]))}]}] - ["/search" {:view search/search - :name ::search - :controllers [{:parameters {:query [:q :serviceId]} - :start (fn [{{:keys [serviceId q]} :query}] - (rf/dispatch [::events/change-service-id (js/parseInt serviceId)]) - (rf/dispatch [::events/get-search-results serviceId q]))}]}] - ["/stream" {:view stream/stream - :name ::stream - :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-stream url]))}]}] - ["/channel" {:view channel/channel - :name ::channel - :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-channel url]))}]}] - ["/playlist" {:view playlist/playlist - :name ::playlist - :controllers [{:parameters {:query [:url]} - :start (fn [{{:keys [url]} :query}] - (rf/dispatch [::events/get-playlist url]))}]}] - ["/kiosk" {:view kiosk/kiosk - :name ::kiosk - :controllers [{:parameters {:query [:kioskId :serviceId]} - :start (fn [{{:keys [serviceId kioskId]} :query}] - (rf/dispatch [::events/change-service-id (js/parseInt serviceId)]) - (rf/dispatch [::events/get-kiosk serviceId kioskId]) - (rf/dispatch [::events/get-kiosks serviceId]))}]}]])) - -(defn on-navigate - [new-match] - (rf/dispatch [::events/reset-page-scroll]) - (when new-match - (rf/dispatch [::events/navigated new-match]))) - -(defn start-routes! - [] - (rfe/start! routes on-navigate {:use-fragment false})) diff --git a/src/frontend/tau/subs.cljs b/src/frontend/tau/subs.cljs deleted file mode 100644 index 41bbb67..0000000 --- a/src/frontend/tau/subs.cljs +++ /dev/null @@ -1,98 +0,0 @@ -(ns tau.subs - (:require - [re-frame.core :as rf])) - -(rf/reg-sub - :http-response - (fn [db _] - (:http-response db))) - -(rf/reg-sub - :search-results - (fn [db _] - (:search-results db))) - -(rf/reg-sub - :stream - (fn [db _] - (:stream db))) - -(rf/reg-sub - :stream-format - (fn [db _] - (:stream-format db))) - -(rf/reg-sub - :playlist - (fn [db _] - (:playlist db))) - -(rf/reg-sub - :channel - (fn [db _] - (:channel db))) - -(rf/reg-sub - :global-search - (fn [db _] - (:global-search db))) - -(rf/reg-sub - :service-id - (fn [db _] - (:service-id db))) - -(rf/reg-sub - :service-color - (fn [db _] - (:service-color db))) - -(rf/reg-sub - :services - (fn [db _] - (:services db))) - -(rf/reg-sub - :kiosks - (fn [db _] - (:kiosks db))) - -(rf/reg-sub - :kiosk - (fn [db _] - (:kiosk db))) - -(rf/reg-sub - :current-match - (fn [db _] - (:current-match db))) - -(rf/reg-sub - :page-scroll - (fn [db _] - (:page-scroll db))) - -(rf/reg-sub - :global-stream - (fn [db _] - (:global-stream db))) - -(rf/reg-sub - :show-global-player - (fn [db _] - (:show-global-player db))) - -(rf/reg-sub - :show-page-loading - (fn [db _] - (:show-page-loading db))) - -(rf/reg-sub - :show-pagination-loading - (fn [db _] - (:show-pagination-loading db))) - -(rf/reg-sub - :show-mobile-nav - (fn [db _] - (:show-mobile-nav db))) diff --git a/src/frontend/tau/util.cljs b/src/frontend/tau/util.cljs deleted file mode 100644 index d23ef9a..0000000 --- a/src/frontend/tau/util.cljs +++ /dev/null @@ -1,24 +0,0 @@ -(ns tau.util - (:require - ["timeago.js" :as timeago])) - -(defn format-date - [date] - (if (-> date js/Date.parse js/isNaN) - date - (timeago/format date))) - -(defn format-quantity - [num] - (.format - (js/Intl.NumberFormat - "en-US" #js {"notation" "compact" "maximumFractionDigits" 1}) - num)) - -(defn format-duration - [num] - (let [duration (js/Date. (* num 1000)) - slice (if (> (.getHours duration) 1) - #(.slice % 11 19) - #(.slice % 14 19))] - (-> duration (.toISOString) slice))) diff --git a/src/frontend/tau/views.cljs b/src/frontend/tau/views.cljs deleted file mode 100644 index 6a4789f..0000000 --- a/src/frontend/tau/views.cljs +++ /dev/null @@ -1,98 +0,0 @@ -(ns tau.views - (:require - [reitit.frontend.easy :as rfe] - [re-frame.core :as rf] - [reagent.ratom :as ratom] - [tau.components.navigation :as navigation] - [tau.components.player :as player] - [tau.events :as events] - [tau.routes :as routes])) - -(defonce scroll-hook (.addEventListener js/window "scroll" #(rf/dispatch [::events/page-scroll]))) -(defonce mobile-touch-hook (.addEventListener js/document.body "touchmove" #(rf/dispatch [::events/page-scroll]))) -(defonce services (rf/dispatch [::events/get-services])) -(defonce kiosks (rf/dispatch [::events/get-kiosks 0])) - -(defn navbar - [{{:keys [serviceId]} :query-params}] - (let [service-id @(rf/subscribe [:service-id]) - service-color @(rf/subscribe [:service-color]) - global-search @(rf/subscribe [:global-search]) - services @(rf/subscribe [:services]) - id (js/parseInt (or serviceId service-id)) - mobile-nav? @(rf/subscribe [:show-mobile-nav]) - {:keys [available-kiosks default-kiosk]} @(rf/subscribe [:kiosks])] - [:nav.flex.px-2.py-2.5.items-center.sticky.top-0.z-50.font-nunito - {:style {:background service-color}} - [:div.flex.items-center.justify-between.flex-auto - [:div.py-2 - [:a.px-5.text-white.font-bold.font-nunito - {:href (rfe/href ::routes/home) :dangerouslySetInnerHTML {:__html "τ"}}]] - [:form.flex.items-center.relative - {:on-submit (fn [e] - (.preventDefault e) - (rf/dispatch [::events/navigate - {:name ::routes/search - :params {} - :query {:q global-search :serviceId service-id}}]))} - [:div - [:input.bg-transparent.border-none.rounded.py-2.px-1.focus:ring-transparent.placeholder-white.box-border.w-40.xs:w-auto - {:type "text" - :value global-search - :on-change #(rf/dispatch [::events/change-global-search (.. % -target -value)]) - :placeholder "Search for something"}]] - [:div.flex.items-center.px-2 - [:button.text-white - {:type "submit"} - [:i.fas.fa-search]]]] - [:div.cursor-pointer.px-2.ml:hidden - {:on-click #(rf/dispatch [::events/toggle-mobile-nav])} - [:i.fa-solid.fa-bars]] - [:div.bg-neutral-900.items-center.fixed.overflow-x-hidden.min-h-screen.w-60.top-0.shadow-xl.shadow-black.pt-8 - {:class (str "ease-in-out delay-75 transition-[right] " - "ml:w-full ml:flex ml:min-h-0 ml:relative ml:text-white ml:bg-transparent ml:shadow-none ml:p-0 ml:right-0 " - (if mobile-nav? "right-0" "right-[-245px]"))} - [:div.cursor-pointer.px-2.ml:hidden.absolute.top-1.right-2 - {:on-click #(rf/dispatch [::events/toggle-mobile-nav])} - [:i.fa-solid.fa-close.text-xl]] - [:div.relative.flex.flex-col.items-center.justify-center.ml:flex-row - [:div.w-full.box-border.z-10 - [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.font-bold.font-nunito.px-5.w-full - {:on-change #(rf/dispatch [::events/change-service (js/parseInt (.. % -target -value))]) - :value service-id - :style {:background "transparent"}} - (when services - (for [service services] - [:option.bg-neutral-900.border-none {:value (:id service) :key (:id service)} - (-> service :info :name)]))]] - [:div.flex.absolute.min-h-full.top-0.right-4.ml:right-0.items-center.justify-end.z-0 - [:i.fa-solid.fa-caret-down]]] - [:div.relative.flex-auto.ml:pl-4 - [:ul.flex.font-roboto.flex-col.ml:flex-row - (for [kiosk available-kiosks] - [:li.px-5.py-2 {:key kiosk} - [:a {:href (rfe/href ::routes/kiosk nil {:serviceId service-id - :kioskId kiosk})} - kiosk]])]]]]])) - -(defn footer - [] - [:footer - [:div.bg-black.text-gray-300.p-5.text-center.w-full - [:div.flex.flex-col.justify-center - [:div - [:p.px-2 (str "Tau " (.getFullYear (js/Date.)))]] - [:div.pt-4 - [:a {:href "https://sr.ht/~conses/tau"} - [:i.fa-solid.fa-code]]]]]]) - -(defn app - [] - (let [current-match @(rf/subscribe [:current-match])] - [:div.min-h-screen.flex.flex-col.h-full.text-white.bg-neutral-900.relative - [navbar current-match] - [:div.flex.flex-col.justify-between.relative.font-nunito {:class "min-h-[calc(100vh-58px)]"} - (when-let [view (-> current-match :data :view)] - [view current-match]) - [footer] - [player/global-player]]])) diff --git a/src/frontend/tau/views/channel.cljs b/src/frontend/tau/views/channel.cljs deleted file mode 100644 index 205474d..0000000 --- a/src/frontend/tau/views/channel.cljs +++ /dev/null @@ -1,41 +0,0 @@ -(ns tau.views.channel - (:require - [re-frame.core :as rf] - [tau.components.items :as items] - [tau.components.loading :as loading] - [tau.components.navigation :as navigation] - [tau.events :as events])) - -(defn channel - [{{:keys [url]} :query-params}] - (let [{:keys [banner avatar name description subscriber-count - related-streams next-page]} @(rf/subscribe [:channel]) - next-page-url (:url next-page) - service-color @(rf/subscribe [:service-color]) - page-loading? @(rf/subscribe [:show-page-loading]) - pagination-loading? @(rf/subscribe [:show-pagination-loading]) - page-scroll @(rf/subscribe [:page-scroll]) - scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] - (when scrolled-to-bottom? - (rf/dispatch [::events/channel-pagination url next-page-url])) - [:div.flex.flex-col.items-center.px-5.py-2.flex-auto - (if page-loading? - [loading/loading-icon service-color "text-5xl"] - [:div.flex.flex-col.flex-auto {:class "ml:w-4/5 xl:w-3/5"} - [navigation/back-button service-color] - (when banner - [:div.flex.justify-center - [:img {:src banner}]]) - [:div.flex.items-center.my-4.mx-2 - (when avatar - [:div.relative.w-16.h-16 - [:img.rounded-full.object-cover.max-w-full.min-h-full {:src avatar :alt name}]]) - [:div.m-4 - [:h1.text-xl name] - (when subscriber-count - [:div.flex.my-2.items-center - [:i.fa-solid.fa-users.text-xs] - [:span.mx-2 (.toLocaleString subscriber-count)]])]] - [:div.my-2 - [:p description]] - [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tau/views/kiosk.cljs b/src/frontend/tau/views/kiosk.cljs deleted file mode 100644 index 824d85b..0000000 --- a/src/frontend/tau/views/kiosk.cljs +++ /dev/null @@ -1,26 +0,0 @@ -(ns tau.views.kiosk - (:require - [re-frame.core :as rf] - [tau.components.items :as items] - [tau.components.loading :as loading] - [tau.components.navigation :as navigation] - [tau.events :as events])) - -(defn kiosk - [{{:keys [serviceId kioskId]} :query-params}] - (let [{:keys [id url related-streams next-page]} @(rf/subscribe [:kiosk]) - next-page-url (:url next-page) - service-color @(rf/subscribe [:service-color]) - page-loading? @(rf/subscribe [:show-page-loading]) - page-scroll @(rf/subscribe [:page-scroll]) - scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] - (when scrolled-to-bottom? - (rf/dispatch [::events/kiosk-pagination serviceId id next-page-url])) - [:div.flex.flex-col.items-center.px-5.py-2.flex-auto - (if page-loading? - [loading/loading-icon service-color "text-5xl"] - [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"} - [:div.flex.justify-center.items-center.my-4.mx-2 - [:div.m-4 - [:h1.text-2xl id]]] - [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tau/views/playlist.cljs b/src/frontend/tau/views/playlist.cljs deleted file mode 100644 index 756846c..0000000 --- a/src/frontend/tau/views/playlist.cljs +++ /dev/null @@ -1,40 +0,0 @@ -(ns tau.views.playlist - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.components.items :as items] - [tau.components.loading :as loading] - [tau.components.navigation :as navigation] - [tau.events :as events])) - -(defn playlist - [{{:keys [url]} :query-params}] - (let [{:keys [id name playlist-type thumbnail-url banner-url - uploader-name uploader-url uploader-avatar stream-count - next-page related-streams]} @(rf/subscribe [:playlist]) - next-page-url (:url next-page) - service-color @(rf/subscribe [:service-color]) - page-loading? @(rf/subscribe [:show-page-loading]) - page-scroll @(rf/subscribe [:page-scroll]) - scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] - (when scrolled-to-bottom? - (rf/dispatch [::events/playlist-pagination url next-page-url])) - [:div.flex.flex-col.items-center.px-5.pt-4.flex-auto - (if page-loading? - [loading/loading-icon service-color "text-5xl"] - [:div.flex.flex-col.flex-auto.w-full {:class "ml:w-4/5"} - [navigation/back-button service-color] - (when banner-url - [:div - [:img {:src banner-url}]]) - [:div.flex.items-center.justify-center.my-4.mx-2 - [:div.flex.flex-col.justify-center.items-center - [:h1.text-2xl.font-bold name] - [:div.flex.items-center.pt-4 - [:span.mr-2 "By"] - [:div.flex.items-center.py-3.box-border.h-12 - [:div.w-12 - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url}) :title uploader-name} - [:img.rounded-full.object-cover.min-h-full.min-w-full {:src uploader-avatar :alt uploader-name}]]]]] - [:p.pt-4 (str stream-count " streams")]]] - [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tau/views/search.cljs b/src/frontend/tau/views/search.cljs deleted file mode 100644 index bfb0b20..0000000 --- a/src/frontend/tau/views/search.cljs +++ /dev/null @@ -1,28 +0,0 @@ -(ns tau.views.search - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.components.items :as items] - [tau.components.loading :as loading] - [tau.events :as events])) - -(defn search - [{{:keys [q serviceId]} :query-params}] - (let [{:keys [items next-page] :as search-results} @(rf/subscribe [:search-results]) - next-page-url (:url next-page) - services @(rf/subscribe [:services]) - service-id @(rf/subscribe [:service-id]) - service-color @(rf/subscribe [:service-color]) - page-scroll @(rf/subscribe [:page-scroll]) - page-loading? @(rf/subscribe [:show-page-loading]) - scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] - (when scrolled-to-bottom? - (rf/dispatch [::events/search-pagination q serviceId next-page-url])) - [:div.flex.flex-col.items-center.flex-auto - [:div.flex.flex-col.items-center.w-full.pt-4.flex-initial - [:h2 (str "Showing search results for: \"" q "\"")] - [:h1 (str "Number of search results: " (count items))]] - (if page-loading? - [loading/loading-icon service-color "text-5xl"] - [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"} - [items/related-streams items next-page-url]])])) diff --git a/src/frontend/tau/views/stream.cljs b/src/frontend/tau/views/stream.cljs deleted file mode 100644 index 3363d6a..0000000 --- a/src/frontend/tau/views/stream.cljs +++ /dev/null @@ -1,122 +0,0 @@ -(ns tau.views.stream - (:require - [re-frame.core :as rf] - [reitit.frontend.easy :as rfe] - [tau.events :as events] - [tau.components.items :as items] - [tau.components.loading :as loading] - [tau.components.navigation :as navigation] - [tau.components.comments :as comments] - [tau.components.player :as player] - [tau.util :as util])) - -(defn stream - [match] - (let [{:keys [name url video-streams audio-streams view-count - subscriber-count like-count dislike-count - description uploader-avatar uploader-name - uploader-url upload-date related-streams - thumbnail-url show-comments-loading comments-page - show-comments service-id] :as stream} @(rf/subscribe [:stream]) - available-streams (apply conj audio-streams video-streams) - {:keys [content id] :as stream-format} @(rf/subscribe [:stream-format]) - page-loading? @(rf/subscribe [:show-page-loading]) - service-color @(rf/subscribe [:service-color])] - [:div.flex.flex-col.items-center.justify-center.text-white.flex-auto - (if page-loading? - [loading/loading-icon service-color "text-5xl"] - [:div.w-full.pb-4.relative {:class "ml:w-4/5 xl:w-3/5"} - [navigation/back-button service-color] - [:div.flex.justify-center.relative - {:class "ml:h-[450px] lg:h-[600px]"} - (when stream-format - [player/stream-player {"sources" [{"src" content "type" "video/mp4"} - {"src" content "type" "video/webm"}] - "poster" thumbnail-url - "controls" true} - content])] - [:div.px-4.ml:p-0 - [:div.flex.flex.w-full.mt-3 - (when stream-format - [:div.relative.flex.bg-stone-800.flex-col.items-center.justify-center.z-10.mr-2.border.rounded.border-black - [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.pl-4.pr-8.w-full - {:on-change #(rf/dispatch [::events/change-stream-format (.. % -target -value)]) - :value id - :style {:background "transparent"}} - (when available-streams - (for [[i {:keys [id format resolution averageBitrate]}] (map-indexed vector available-streams)] - [:option.bg-neutral-900.border-none {:value id :key i} - (str (or resolution "audio-only") " " format (when-not resolution (str " " averageBitrate "kbit/s")))]))] - [:div.flex.absolute.min-h-full.top-0.right-4.items-center.justify-end - {:style {:zIndex "-1"}} - [:i.fa-solid.fa-caret-down]]]) - [:button.border.rounded.border-black.px-3.py-1.bg-stone-800 - {:on-click #(rf/dispatch [::events/switch-to-global-player - {:uploader-name uploader-name :uploader-url uploader-url - :name name :url url :stream content :service-color service-color}])} - [:i.fa-solid.fa-headphones]] - [:button.border.rounded.border-black.px-3.py-1.bg-stone-800.mx-2 - [:a {:href url} - [:i.fa-solid.fa-external-link-alt]]]] - [:div.flex.flex-col.py-1.comments - [:div.min-w-full.py-3 - [:h1.text-2xl.font-extrabold.line-clamp-1 name]] - [:div.flex.justify-between.py-2 - [:div.flex.items-center.flex-auto - (when uploader-avatar - [:div.relative.w-16.h-16 - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url}) :title uploader-name} - [:img.rounded-full.object-cover.max-w-full.min-h-full {:src uploader-avatar :alt uploader-name}]]]) - [:div.mx-2 - [:a {:href (rfe/href :tau.routes/channel nil {:url uploader-url})} uploader-name] - (when subscriber-count - [:div.flex.my-2.items-center - [:i.fa-solid.fa-users.text-xs] - [:p.mx-2 (util/format-quantity subscriber-count)]])]] - [:div.flex.flex-col.items-end - (when view-count - [:div - [:i.fa-solid.fa-eye.text-xs] - [:span.ml-2 (.toLocaleString view-count)]]) - [:div.flex - (when like-count - [:div.items-center - [:i.fa-solid.fa-thumbs-up.text-xs] - [:span.ml-2 (.toLocaleString like-count)]]) - (when dislike-count - [:div.ml-2.items-center - [:i.fa-solid.fa-thumbs-down.text-xs] - [:span.ml-2 dislike-count]])] - (when upload-date - [:div - [:i.fa-solid.fa-calendar.mx-2.text-xs] - [:span - (-> upload-date - js/Date.parse - js/Date. - .toDateString)]])]] - [:div.min-w-full.py-3 - [:h1 name] - [:div {:dangerouslySetInnerHTML {:__html description}}]] - [:div.py-6 - [:div.flex.items-center - [:i.fa-solid.fa-comments] - [:p.px-2.py-4 "Comments"] - (if show-comments - [:i.fa-solid.fa-chevron-up {:on-click #(rf/dispatch [::events/toggle-comments]) - :style {:cursor "pointer"}}] - [:i.fa-solid.fa-chevron-down {:on-click #(if (or show-comments comments-page) - (rf/dispatch [::events/toggle-comments]) - (rf/dispatch [::events/get-comments url])) - :style {:cursor "pointer"}}])] - [:div - (if show-comments-loading - [loading/loading-icon service-color "text-2xl"] - (when (and show-comments comments-page) - [comments/comments comments-page uploader-name uploader-avatar url]))]] - (when-not (empty? related-streams) - [:div.py-3 - [:div.flex.items-center - [:i.fa-solid.fa-list] - [:h1.px-2.text-lg.bold "Related Results"]] - [items/related-streams related-streams nil]])]]])])) diff --git a/src/frontend/tubo/api.cljs b/src/frontend/tubo/api.cljs new file mode 100644 index 0000000..2ca442f --- /dev/null +++ b/src/frontend/tubo/api.cljs @@ -0,0 +1,15 @@ +(ns tubo.api + (:require + [ajax.core :as ajax])) + +(defn get-request + ([uri on-success on-failure] + (get-request uri on-success on-failure {})) + ([uri on-success on-failure params] + {:http-xhrio {:method :get + :uri uri + :params params + :format (ajax/json-request-format) + :response-format (ajax/json-response-format {:keywords? true}) + :on-success on-success + :on-failure on-failure}})) diff --git a/src/frontend/tubo/components/comments.cljs b/src/frontend/tubo/components/comments.cljs new file mode 100644 index 0000000..a65b6d8 --- /dev/null +++ b/src/frontend/tubo/components/comments.cljs @@ -0,0 +1,77 @@ +(ns tubo.components.comments + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.loading :as loading] + [tubo.events :as events] + [tubo.util :as util])) + +(defn comment-item + [{:keys [id text uploader-name uploader-avatar uploader-url stream-position + upload-date uploader-verified? like-count hearted-by-uploader? + pinned? replies reply-count key show-replies author-name author-avatar]}] + [:div.flex.my-4 {:key key} + (when uploader-avatar + [:div.flex.items-center.py-3.box-border.h-12 + (when uploader-url + [:div.w-12 + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} + [:img.rounded-full.object-cover.min-w-full.min-h-full {:src uploader-avatar}]]])]) + [:div.ml-4 + [:div.flex.items-center + (when pinned? + [:i.fa-solid.fa-thumbtack.mr-2.text-xs]) + (when uploader-name + [:div.flex.items-center + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} + [:h1.text-gray-300.font-bold uploader-name]] + (when stream-position + [:p.mx-1.text-xs (str "at " (util/format-duration stream-position))])]) + (when uploader-verified? + [:i.fa-solid.fa-circle-check.ml-2])] + [:div.my-2 + [:p text]] + [:div..flex.items-center.my-2 + [:div.mr-4 + [:p (util/format-date upload-date)]] + (when (and like-count (> like-count 0)) + [:div.flex.items-center.my-2 + [:i.fa-solid.fa-thumbs-up.text-xs] + [:p.mx-1 like-count]]) + (when hearted-by-uploader? + [:div.relative.w-4.h-4.mx-2 + [:i.fa-solid.fa-heart.absolute.-bottom-1.-right-1.text-xs.text-red-500] + [:img.rounded-full.object-covermax-w-full.min-h-full + {:src author-avatar :title (str author-name " hearted this comment")}]])] + [:div.flex.items-center.cursor-pointer + {:on-click #(rf/dispatch [::events/toggle-comment-replies id])} + (when replies + (if show-replies + [:<> + [:p.font-bold "Hide replies"] + [:i.fa-solid.fa-turn-up.mx-2.text-xs]] + [:<> + [:p.font-bold (str reply-count (if (= reply-count 1) " reply" " replies"))] + [:i.fa-solid.fa-turn-down.mx-2.text-xs]]))]]]) + +(defn comments + [{:keys [comments next-page disabled?]} author-name author-avatar url] + (let [pagination-loading? @(rf/subscribe [:show-pagination-loading]) + service-color @(rf/subscribe [:service-color])] + [:div.flex.flex-col + [:div + (for [[i {:keys [replies show-replies] :as comment}] (map-indexed vector comments)] + [:div.flex.flex-col {:key i} + [comment-item (assoc comment :key i :author-name author-name :author-avatar author-avatar)] + (when (and replies show-replies) + [:div {:style {:marginLeft "32px"}} + (for [[i reply] (map-indexed vector (:items replies))] + [comment-item (assoc reply :key i :author-name author-name :author-avatar author-avatar)])])])] + (when (:url next-page) + (if pagination-loading? + (loading/loading-icon service-color) + [:div.flex.items-center.justify-center + {:style {:cursor "pointer"} + :on-click #(rf/dispatch [::events/comments-pagination url (:url next-page)])} + [:i.fa-solid.fa-plus] + [:p.px-2 "Show more comments"]]))])) diff --git a/src/frontend/tubo/components/items.cljs b/src/frontend/tubo/components/items.cljs new file mode 100644 index 0000000..95e2251 --- /dev/null +++ b/src/frontend/tubo/components/items.cljs @@ -0,0 +1,93 @@ +(ns tubo.components.items + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.loading :as loading] + [tubo.util :as util])) + +(defn thumbnail + [thumbnail-url route url name duration] + [:div.flex.py-2.box-border.h-44.xs:h-28 + [:div.relative.min-w-full + [:a.absolute.min-w-full.min-h-full.z-10 {:href route :title name}] + [:img.rounded.object-cover.min-h-full.max-h-full.min-w-full {:src thumbnail-url}] + (when duration + [:div.rounded.p-2.absolute {:style {:bottom 5 :right 5 :background "rgba(0,0,0,.7)" :zIndex "0"}} + [:p {:style {:fontSize "14px"}} + (if (= duration 0) + "LIVE" + (util/format-duration duration))]])]]) + +(defn item-content + [{:keys [url name thumbnail-url description subscriber-count + stream-count verified? key uploader-name uploader-url + uploader-avatar upload-date short-description view-count]} item-route] + [:<> + (when name + [:div.my-2 + [:a {:href item-route :title name} + [:h1.line-clamp-2.my-1 name]]]) + (when-not (empty? uploader-name) + [:div.flex.items-center.my-2 + (if uploader-url + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} + [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 uploader-name]] + [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 uploader-name]) + (when verified? + [:i.fa-solid.fa-circle-check])]) + (when subscriber-count + [:div.flex.items-center + [:i.fa-solid.fa-users.text-xs] + [:p.mx-2 subscriber-count]]) + (when stream-count + [:div.flex.items-center + [:i.fa-solid.fa-video.text-xs] + [:p.mx-2 stream-count]]) + [:div.flex.my-1.justify-between + [:p (util/format-date upload-date)] + (when view-count + [:div.flex.items-center.h-full.pl-2 + [:i.fa-solid.fa-eye.text-xs] + [:p.pl-1.5 (util/format-quantity view-count)]])]]) + +(defn stream-item + [{:keys [url name thumbnail-url duration] :as item}] + [:<> + [thumbnail thumbnail-url (rfe/href :tubo.routes/stream nil {:url url}) url name duration] + [item-content item (rfe/href :tubo.routes/stream nil {:url url})]]) + +(defn channel-item + [{:keys [url name thumbnail-url] :as item}] + [:<> + [thumbnail thumbnail-url (rfe/href :tubo.routes/channel nil {:url url}) url name nil] + [item-content item (rfe/href :tubo.routes/channel nil {:url url})]]) + +(defn playlist-item + [{:keys [url name thumbnail-url] :as item}] + [:<> + [thumbnail thumbnail-url (rfe/href :tubo.routes/playlist nil {:url url}) url name nil] + [item-content item (rfe/href :tubo.routes/playlist nil {:url url})]]) + +(defn generic-item + [item] + [:div.w-full.xs:w-56.h-80.xs:h-72.my-2 {:key key} + [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full + (case (:type item) + "stream" [stream-item item] + "channel" [channel-item item] + "playlist" [playlist-item item])]]) + +(defn related-streams + [related-streams next-page-url] + (let [service-color @(rf/subscribe [:service-color]) + pagination-loading? @(rf/subscribe [:show-pagination-loading])] + [:div.flex.flex-col.justify-center.items-center.flex-auto.my-2.md:my-8 + (if (empty? related-streams) + [:div.flex.items-center + [:p "No available streams"]] + [:div.flex.justify-center.flex-wrap + (for [[i item] (map-indexed vector related-streams) + :let [keyed-item (assoc item :key i)]] + [generic-item keyed-item])]) + (when-not (empty? next-page-url) + [loading/loading-icon service-color "text-2xl" (when-not pagination-loading? "invisible")])])) diff --git a/src/frontend/tubo/components/loading.cljs b/src/frontend/tubo/components/loading.cljs new file mode 100644 index 0000000..08c37de --- /dev/null +++ b/src/frontend/tubo/components/loading.cljs @@ -0,0 +1,8 @@ +(ns tubo.components.loading) + +(defn loading-icon + [service-color & styles] + [:div.w-full.flex.justify-center.items-center.flex-auto + [:i.fas.fa-circle-notch.fa-spin + {:class (apply str (if (> (count styles) 1) (interpose " " styles) styles)) + :style {:color service-color}}]]) diff --git a/src/frontend/tubo/components/navigation.cljs b/src/frontend/tubo/components/navigation.cljs new file mode 100644 index 0000000..a48854c --- /dev/null +++ b/src/frontend/tubo/components/navigation.cljs @@ -0,0 +1,12 @@ +(ns tubo.components.navigation + (:require + [re-frame.core :as rf] + [tubo.events :as events])) + +(defn back-button [service-color] + [:div.flex.items-center + [:button.py-4.px-2 + {:on-click #(rf/dispatch [::events/history-back])} + [:i.fa-solid.fa-chevron-left + {:style {:color service-color}}] + [:span " Back"]]]) diff --git a/src/frontend/tubo/components/player.cljs b/src/frontend/tubo/components/player.cljs new file mode 100644 index 0000000..c4b5df7 --- /dev/null +++ b/src/frontend/tubo/components/player.cljs @@ -0,0 +1,70 @@ +(ns tubo.components.player + (:require + [reagent.core :as r] + [reagent.dom :as rdom] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.events :as events] + ["video.js" :as videojs])) + +(defn global-player + [] + (let [!player (r/atom nil) + !loop? (r/atom nil)] + (fn [] + (let [{:keys [uploader-name uploader-url name stream url service-color]} @(rf/subscribe [:global-stream]) + show-global-player? @(rf/subscribe [:show-global-player])] + (when show-global-player? + [:div.sticky.bottom-0.z-50.bg-neutral-900.p-5.absolute.box-border.m-0 + {:style {:borderColor service-color :borderTopWidth "2px" :borderStyle "solid"}} + [:div.flex.items-center.justify-between + [:div.flex.flex-wrap.items-center + [:div.flex.flex-col + [:a.text-xs + {:href (rfe/href :tubo.router/stream nil {:url url})} name] + [:a.text-xs.text-gray-300 + {:href (rfe/href :tubo.router/channel nil {:url uploader-url})} uploader-name]] + [:div.px-2.py-0.md:pt-4 + [:audio {:src stream :ref #(reset! !player %) :loop @!loop?}]] + [:div.mx-2 + [:button.focus:ring-transparent.mx-2 + {:on-click (fn [] (swap! !loop? #(not %)))} + [:i.fa-solid.fa-repeat + {:style {:color (when @!loop? service-color)}}]] + [:button.focus:ring-transparent.mx-2 + {:on-click #(when-let [player @!player] + (if (.-paused player) + (.play player) + (.pause player)))} + (if @!player + (if (.-paused @!player) + [:i.fa-solid.fa-play] + [:i.fa-solid.fa-pause]) + [:i.fa-solid.fa-play])]]] + [:div.px-2 + [:i.fa-solid.fa-close.cursor-pointer + {:on-click (fn [] + (rf/dispatch [::events/toggle-global-player]) + (.pause @!player))}]]]]))))) + +(defn stream-player + [options url] + (let [!player (atom nil)] + (r/create-class + {:display-name "StreamPlayer" + :component-did-mount + (fn [this] + (reset! !player (videojs (rdom/dom-node this) (clj->js options)))) + :component-did-update + (fn [this [_ prev-argv prev-more]] + (when (and @!player (not= prev-more (first (r/children this)))) + (.src @!player (apply array (map #(js-obj "type" % "src" (first (r/children this))) + ["video/mp4" "video/webm"]))) + (.ready @!player #(.play @!player)))) + :component-will-unmount + (fn [_] + (when @!player + (.dispose @!player))) + :reagent-render + (fn [options url] + [:video-js.vjs-default-skin.vjs-big-play-centered.bottom-0.object-cover.min-h-full.max-h-full.min-w-full])}))) diff --git a/src/frontend/tubo/core.cljs b/src/frontend/tubo/core.cljs new file mode 100644 index 0000000..4834dc2 --- /dev/null +++ b/src/frontend/tubo/core.cljs @@ -0,0 +1,22 @@ +(ns tubo.core + (:require + ["react-dom/client" :as rdom] + [reagent.core :as r] + [re-frame.core :as rf] + [tubo.events :as events] + [tubo.routes :as routes] + [tubo.subs] + [tubo.views :as views])) + +(defonce root (rdom/createRoot (.querySelector js/document "#app"))) + +(defn ^:dev/after-load mount-root + [] + (rf/clear-subscription-cache!) + (routes/start-routes!) + (.render root (r/as-element [views/app]))) + +(defn ^:export init + [] + (rf/dispatch-sync [::events/initialize-db]) + (mount-root)) diff --git a/src/frontend/tubo/events.cljs b/src/frontend/tubo/events.cljs new file mode 100644 index 0000000..d919e31 --- /dev/null +++ b/src/frontend/tubo/events.cljs @@ -0,0 +1,382 @@ +(ns tubo.events + (:require + [day8.re-frame.http-fx] + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [reitit.frontend.controllers :as rfc] + [tubo.api :as api])) + +(rf/reg-event-db + ::initialize-db + (fn [_ _] + {:global-search "" + :service-id 0 + :service-color "#cc0000" + :stream {} + :search-results [] + :services [] + :current-match nil + :page-scroll 0})) + +(rf/reg-fx + ::scroll-to-top + (fn [_] + (.scrollTo js/window #js {"top" 0 "behavior" "smooth"}))) + +(rf/reg-fx + ::history-back! + (fn [_] + (.back js/window.history))) + +(rf/reg-event-fx + ::history-back + (fn [_ _] + {::history-back! nil})) + +(rf/reg-event-db + ::page-scroll + (fn [db _] + (when (> (.-scrollY js/window) 0) + (assoc db :page-scroll (+ (.-scrollY js/window) (.-innerHeight js/window)))))) + +(rf/reg-event-db + ::reset-page-scroll + (fn [db _] + (assoc db :page-scroll 0))) + +(rf/reg-event-db + ::toggle-mobile-nav + (fn [db _] + (assoc db :show-mobile-nav (not (:show-mobile-nav db))))) + +(rf/reg-event-fx + ::navigated + (fn [{:keys [db]} [_ new-match]] + (let [old-match (:current-match db) + controllers (rfc/apply-controllers (:controllers old-match) new-match) + match (assoc new-match :controllers controllers)] + {:db (-> db + (assoc :current-match match) + (assoc :show-pagination-loading false)) + ::scroll-to-top nil}))) + +(rf/reg-event-fx + ::navigate + (fn [_ [_ route]] + {::navigate! route})) + +(rf/reg-fx + ::navigate! + (fn [{:keys [name params query]}] + (rfe/push-state name params query))) + +(rf/reg-event-db + ::bad-response + (fn [db [_ res]] + (js/console.log res) + (assoc db :http-response (get-in res [:response :error])))) + +(rf/reg-event-db + ::change-global-search + (fn [db [_ res]] + (assoc db :global-search res))) + +(rf/reg-event-db + ::change-service-color + (fn [db [_ service-id]] + (assoc db :service-color + (case service-id + 0 "#cc0000" + 1 "#ff7700" + 2 "#333333" + 3 "#F2690D" + 4 "#629aa9")))) + +(rf/reg-event-fx + ::change-service-id + (fn [{:keys [db]} [_ service-id]] + {:db (assoc db :service-id service-id) + :fx [[:dispatch [::change-service-color service-id]]]})) + +(rf/reg-event-db + ::load-paginated-channel-results + (fn [db [_ res]] + (-> db + (update-in [:channel :related-streams] #(apply conj %1 %2) + (:related-streams (js->clj res :keywordize-keys true))) + (assoc-in [:channel :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + ::channel-pagination + (fn [{:keys [db]} [_ uri next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request + (str "/api/channels/" (js/encodeURIComponent uri) ) + [::load-paginated-channel-results] [::bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) + +(rf/reg-event-db + ::load-paginated-playlist-results + (fn [db [_ res]] + (-> db + (update-in [:playlist :related-streams] #(apply conj %1 %2) + (:related-streams (js->clj res :keywordize-keys true))) + (assoc-in [:playlist :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + ::playlist-pagination + (fn [{:keys [db]} [_ uri next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request + (str "/api/playlists/" (js/encodeURIComponent uri)) + [::load-paginated-playlist-results] [::bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) + +(rf/reg-event-db + ::load-paginated-search-results + (fn [db [_ res]] + (-> db + (update-in [:search-results :items] #(apply conj %1 %2) + (:items (js->clj res :keywordize-keys true))) + (assoc-in [:search-results :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + ::search-pagination + (fn [{:keys [db]} [_ query id next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request + (str "/api/services/" id "/search") + [::load-paginated-search-results] [::bad-response] + {:q query + :nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) + +(rf/reg-event-db + ::change-global-stream + (fn [db [_ global-stream]] + (assoc db :global-stream global-stream))) + +(rf/reg-event-db + ::toggle-global-player + (fn [db _] + (assoc db :show-global-player (not (:show-global-player db))))) + +(rf/reg-event-fx + ::switch-to-global-player + (fn [{:keys [db]} [_ global-stream]] + {:db (assoc db :show-global-player true) + :fx [[:dispatch [::change-global-stream global-stream]]]})) + +(rf/reg-event-db + ::load-services + (fn [db [_ res]] + (assoc db :services (js->clj res :keywordize-keys true)))) + +(rf/reg-event-fx + ::get-services + (fn [{:keys [db]} _] + (api/get-request "/api/services" [::load-services] [::bad-response]))) + +(rf/reg-event-db + ::load-comments + (fn [db [_ res]] + (-> db + (assoc-in [:stream :comments-page] (js->clj res :keywordize-keys true)) + (assoc-in [:stream :show-comments-loading] false)))) + +(rf/reg-event-fx + ::get-comments + (fn [{:keys [db]} [_ url]] + (assoc + (api/get-request (str "/api/comments/" (js/encodeURIComponent url)) + [::load-comments] [::bad-response]) + :db (-> db + (assoc-in [:stream :show-comments-loading] true) + (assoc-in [:stream :show-comments] true))))) + +(rf/reg-event-db + ::toggle-comments + (fn [db _] + (assoc-in db [:stream :show-comments] (not (-> db :stream :show-comments))))) + +(rf/reg-event-db + ::toggle-comment-replies + (fn [db [_ comment-id]] + (update-in db [:stream :comments-page :comments] + (fn [comments] + (map #(if (= (:id %) comment-id) + (assoc % :show-replies (not (:show-replies %))) + %) + comments))))) + +(rf/reg-event-db + ::load-paginated-comments + (fn [db [_ res]] + (-> db + (update-in [:stream :comments-page :comments] #(apply conj %1 %2) + (:comments (js->clj res :keywordize-keys true))) + (assoc-in [:stream :comments-page :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + ::comments-pagination + (fn [{:keys [db]} [_ url next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request (str "/api/comments/" (js/encodeURIComponent url)) + [::load-paginated-comments] [::bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) + +(rf/reg-event-db + ::load-kiosks + (fn [db [_ res]] + (assoc db :kiosks (js->clj res :keywordize-keys true)))) + +(rf/reg-event-fx + ::get-kiosks + (fn [{:keys [db]} [_ id]] + (api/get-request (str "/api/services/" id "/kiosks") [::load-kiosks] [::bad-response]))) + +(rf/reg-event-db + ::load-kiosk + (fn [db [_ res]] + (assoc db :kiosk (js->clj res :keywordize-keys true) + :show-page-loading false))) + +(rf/reg-event-fx + ::get-default-kiosk + (fn [{:keys [db]} [_ service-id]] + (assoc + (api/get-request (str "/api/services/" service-id "/default-kiosk") + [::load-kiosk] [::bad-response]) + :db (assoc db :show-page-loading true)))) + +(rf/reg-event-fx + ::get-kiosk + (fn [{:keys [db]} [_ service-id kiosk-id]] + (if kiosk-id + (assoc + (api/get-request (str "/api/services/" service-id "/kiosks/" + (js/encodeURIComponent kiosk-id)) + [::load-kiosk] [::bad-response]) + :db (assoc db :show-page-loading true)) + {:fx [[:dispatch [::get-default-kiosk service-id]]]}))) + +(rf/reg-event-fx + ::change-service + (fn [{:keys [db]} [_ service-id]] + {:fx [[:dispatch + [::navigate {:name :tubo.routes/kiosk + :params {} + :query {:serviceId service-id}}]]]})) + +(rf/reg-event-db + ::load-paginated-kiosk-results + (fn [db [_ res]] + (-> db + (update-in [:kiosk :related-streams] #(apply conj %1 %2) + (:related-streams (js->clj res :keywordize-keys true))) + (assoc-in [:kiosk :next-page] + (:next-page (js->clj res :keywordize-keys true))) + (assoc :show-pagination-loading false)))) + +(rf/reg-event-fx + ::kiosk-pagination + (fn [{:keys [db]} [_ service-id kiosk-id next-page-url]] + (if (empty? next-page-url) + {:db (assoc db :show-pagination-loading false)} + (assoc + (api/get-request + (str "/api/services/" service-id "/kiosks/" (js/encodeURIComponent kiosk-id)) + [::load-paginated-kiosk-results] [::bad-response] + {:nextPage (js/encodeURIComponent next-page-url)}) + :db (assoc db :show-pagination-loading true))))) + +(rf/reg-event-fx + ::load-stream + (fn [{:keys [db]} [_ res]] + (let [stream-res (js->clj res :keywordize-keys true)] + {:db (assoc db :stream stream-res + :show-page-loading false) + :fx [[:dispatch [::change-stream-format nil]] + [:dispatch [::get-comments (:url stream-res)]]]}))) + +(rf/reg-event-fx + ::get-stream + (fn [{:keys [db]} [_ uri]] + (assoc + (api/get-request (str "/api/streams/" (js/encodeURIComponent uri)) + [::load-stream] [::bad-response]) + :db (assoc db :show-page-loading true)))) + +(rf/reg-event-db + ::change-stream-format + (fn [{:keys [stream] :as db} [_ format-id]] + (let [{:keys [audio-streams video-streams]} stream] + (if format-id + (assoc db :stream-format + (first (filter #(= format-id (:id %)) (apply conj audio-streams video-streams)))) + (assoc db :stream-format (-> (if (empty? video-streams) audio-streams video-streams) + last)))))) + +(rf/reg-event-db + ::load-channel + (fn [db [_ res]] + (assoc db :channel (js->clj res :keywordize-keys true) + :show-page-loading false))) + +(rf/reg-event-fx + ::get-channel + (fn [{:keys [db]} [_ uri]] + (assoc + (api/get-request + (str "/api/channels/" (js/encodeURIComponent uri)) + [::load-channel] [::bad-response]) + :db (assoc db :show-page-loading true)))) + +(rf/reg-event-db + ::load-playlist + (fn [db [_ res]] + (assoc db :playlist (js->clj res :keywordize-keys true) + :show-page-loading false))) + +(rf/reg-event-fx + ::get-playlist + (fn [{:keys [db]} [_ uri]] + (assoc + (api/get-request (str "/api/playlists/" (js/encodeURIComponent uri)) + [::load-playlist] [::bad-response]) + :db (assoc db :show-page-loading true)))) + +(rf/reg-event-db + ::load-search-results + (fn [db [_ res]] + (assoc db :search-results (js->clj res :keywordize-keys true) + :show-page-loading false + :global-search ""))) + +(rf/reg-event-fx + ::get-search-results + (fn [{:keys [db]} [_ service-id query]] + (assoc + (api/get-request (str "/api/services/" service-id "/search") + [::load-search-results] [::bad-response] + {:q query}) + :db (assoc db :show-page-loading true)))) diff --git a/src/frontend/tubo/routes.cljs b/src/frontend/tubo/routes.cljs new file mode 100644 index 0000000..eb40ac7 --- /dev/null +++ b/src/frontend/tubo/routes.cljs @@ -0,0 +1,58 @@ +(ns tubo.routes + (:require + [reitit.frontend :as ref] + [reitit.frontend.easy :as rfe] + [re-frame.core :as rf] + [tubo.events :as events] + [tubo.views.channel :as channel] + [tubo.views.kiosk :as kiosk] + [tubo.views.playlist :as playlist] + [tubo.views.search :as search] + [tubo.views.stream :as stream])) + +(def routes + (ref/router + [["/" {:view kiosk/kiosk + :name ::home + :controllers [{:start (fn [_] + (rf/dispatch [::events/change-service-id 0]) + (rf/dispatch [::events/get-default-kiosk 0]) + (rf/dispatch [::events/get-kiosks 0]))}]}] + ["/search" {:view search/search + :name ::search + :controllers [{:parameters {:query [:q :serviceId]} + :start (fn [{{:keys [serviceId q]} :query}] + (rf/dispatch [::events/change-service-id (js/parseInt serviceId)]) + (rf/dispatch [::events/get-search-results serviceId q]))}]}] + ["/stream" {:view stream/stream + :name ::stream + :controllers [{:parameters {:query [:url]} + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [::events/get-stream url]))}]}] + ["/channel" {:view channel/channel + :name ::channel + :controllers [{:parameters {:query [:url]} + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [::events/get-channel url]))}]}] + ["/playlist" {:view playlist/playlist + :name ::playlist + :controllers [{:parameters {:query [:url]} + :start (fn [{{:keys [url]} :query}] + (rf/dispatch [::events/get-playlist url]))}]}] + ["/kiosk" {:view kiosk/kiosk + :name ::kiosk + :controllers [{:parameters {:query [:kioskId :serviceId]} + :start (fn [{{:keys [serviceId kioskId]} :query}] + (rf/dispatch [::events/change-service-id (js/parseInt serviceId)]) + (rf/dispatch [::events/get-kiosk serviceId kioskId]) + (rf/dispatch [::events/get-kiosks serviceId]))}]}]])) + +(defn on-navigate + [new-match] + (rf/dispatch [::events/reset-page-scroll]) + (when new-match + (rf/dispatch [::events/navigated new-match]))) + +(defn start-routes! + [] + (rfe/start! routes on-navigate {:use-fragment false})) diff --git a/src/frontend/tubo/subs.cljs b/src/frontend/tubo/subs.cljs new file mode 100644 index 0000000..88291ca --- /dev/null +++ b/src/frontend/tubo/subs.cljs @@ -0,0 +1,98 @@ +(ns tubo.subs + (:require + [re-frame.core :as rf])) + +(rf/reg-sub + :http-response + (fn [db _] + (:http-response db))) + +(rf/reg-sub + :search-results + (fn [db _] + (:search-results db))) + +(rf/reg-sub + :stream + (fn [db _] + (:stream db))) + +(rf/reg-sub + :stream-format + (fn [db _] + (:stream-format db))) + +(rf/reg-sub + :playlist + (fn [db _] + (:playlist db))) + +(rf/reg-sub + :channel + (fn [db _] + (:channel db))) + +(rf/reg-sub + :global-search + (fn [db _] + (:global-search db))) + +(rf/reg-sub + :service-id + (fn [db _] + (:service-id db))) + +(rf/reg-sub + :service-color + (fn [db _] + (:service-color db))) + +(rf/reg-sub + :services + (fn [db _] + (:services db))) + +(rf/reg-sub + :kiosks + (fn [db _] + (:kiosks db))) + +(rf/reg-sub + :kiosk + (fn [db _] + (:kiosk db))) + +(rf/reg-sub + :current-match + (fn [db _] + (:current-match db))) + +(rf/reg-sub + :page-scroll + (fn [db _] + (:page-scroll db))) + +(rf/reg-sub + :global-stream + (fn [db _] + (:global-stream db))) + +(rf/reg-sub + :show-global-player + (fn [db _] + (:show-global-player db))) + +(rf/reg-sub + :show-page-loading + (fn [db _] + (:show-page-loading db))) + +(rf/reg-sub + :show-pagination-loading + (fn [db _] + (:show-pagination-loading db))) + +(rf/reg-sub + :show-mobile-nav + (fn [db _] + (:show-mobile-nav db))) diff --git a/src/frontend/tubo/util.cljs b/src/frontend/tubo/util.cljs new file mode 100644 index 0000000..8e03f85 --- /dev/null +++ b/src/frontend/tubo/util.cljs @@ -0,0 +1,24 @@ +(ns tubo.util + (:require + ["timeago.js" :as timeago])) + +(defn format-date + [date] + (if (-> date js/Date.parse js/isNaN) + date + (timeago/format date))) + +(defn format-quantity + [num] + (.format + (js/Intl.NumberFormat + "en-US" #js {"notation" "compact" "maximumFractionDigits" 1}) + num)) + +(defn format-duration + [num] + (let [duration (js/Date. (* num 1000)) + slice (if (> (.getHours duration) 1) + #(.slice % 11 19) + #(.slice % 14 19))] + (-> duration (.toISOString) slice))) diff --git a/src/frontend/tubo/views.cljs b/src/frontend/tubo/views.cljs new file mode 100644 index 0000000..4fe2202 --- /dev/null +++ b/src/frontend/tubo/views.cljs @@ -0,0 +1,99 @@ +(ns tubo.views + (:require + [reitit.frontend.easy :as rfe] + [re-frame.core :as rf] + [reagent.ratom :as ratom] + [tubo.components.navigation :as navigation] + [tubo.components.player :as player] + [tubo.events :as events] + [tubo.routes :as routes])) + +(defonce scroll-hook (.addEventListener js/window "scroll" #(rf/dispatch [::events/page-scroll]))) +(defonce mobile-touch-hook (.addEventListener js/document.body "touchmove" #(rf/dispatch [::events/page-scroll]))) +(defonce services (rf/dispatch [::events/get-services])) +(defonce kiosks (rf/dispatch [::events/get-kiosks 0])) + +(defn navbar + [{{:keys [serviceId]} :query-params}] + (let [service-id @(rf/subscribe [:service-id]) + service-color @(rf/subscribe [:service-color]) + global-search @(rf/subscribe [:global-search]) + services @(rf/subscribe [:services]) + id (js/parseInt (or serviceId service-id)) + mobile-nav? @(rf/subscribe [:show-mobile-nav]) + {:keys [available-kiosks default-kiosk]} @(rf/subscribe [:kiosks])] + [:nav.flex.px-2.py-2.5.items-center.sticky.top-0.z-50.font-nunito + {:style {:background service-color}} + [:div.flex.items-center.justify-between.flex-auto + [:div.py-2 + [:a.px-5.text-white.font-bold + {:href (rfe/href ::routes/home)} + "tubo"]] + [:form.flex.items-center.relative + {:on-submit (fn [e] + (.preventDefault e) + (rf/dispatch [::events/navigate + {:name ::routes/search + :params {} + :query {:q global-search :serviceId service-id}}]))} + [:div + [:input.bg-transparent.border-none.rounded.py-2.px-1.focus:ring-transparent.placeholder-white.box-border.w-40.xs:w-auto + {:type "text" + :value global-search + :on-change #(rf/dispatch [::events/change-global-search (.. % -target -value)]) + :placeholder "Search for something"}]] + [:div.flex.items-center.px-2 + [:button.text-white + {:type "submit"} + [:i.fas.fa-search]]]] + [:div.cursor-pointer.px-2.ml:hidden + {:on-click #(rf/dispatch [::events/toggle-mobile-nav])} + [:i.fa-solid.fa-bars]] + [:div.bg-neutral-900.items-center.fixed.overflow-x-hidden.min-h-screen.w-60.top-0.shadow-xl.shadow-black.pt-8 + {:class (str "ease-in-out delay-75 transition-[right] " + "ml:w-full ml:flex ml:min-h-0 ml:relative ml:text-white ml:bg-transparent ml:shadow-none ml:p-0 ml:right-0 " + (if mobile-nav? "right-0" "right-[-245px]"))} + [:div.cursor-pointer.px-2.ml:hidden.absolute.top-1.right-2 + {:on-click #(rf/dispatch [::events/toggle-mobile-nav])} + [:i.fa-solid.fa-close.text-xl]] + [:div.relative.flex.flex-col.items-center.justify-center.ml:flex-row + [:div.w-full.box-border.z-10 + [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.font-bold.font-nunito.px-5.w-full + {:on-change #(rf/dispatch [::events/change-service (js/parseInt (.. % -target -value))]) + :value service-id + :style {:background "transparent"}} + (when services + (for [service services] + [:option.bg-neutral-900.border-none {:value (:id service) :key (:id service)} + (-> service :info :name)]))]] + [:div.flex.absolute.min-h-full.top-0.right-4.ml:right-0.items-center.justify-end.z-0 + [:i.fa-solid.fa-caret-down]]] + [:div.relative.flex-auto.ml:pl-4 + [:ul.flex.font-roboto.flex-col.ml:flex-row + (for [kiosk available-kiosks] + [:li.px-5.py-2 {:key kiosk} + [:a {:href (rfe/href ::routes/kiosk nil {:serviceId service-id + :kioskId kiosk})} + kiosk]])]]]]])) + +(defn footer + [] + [:footer + [:div.bg-black.text-gray-300.p-5.text-center.w-full + [:div.flex.flex-col.justify-center.items-center + [:div.flex.items-center.justify-center + [:div.items-center + [:a.font-bold {:href "https://sr.ht/~conses/tubo"} "tubo"]] + [:div + [:p.px-2 (str "2022-" (.getFullYear (js/Date.)))]]]]]]) + +(defn app + [] + (let [current-match @(rf/subscribe [:current-match])] + [:div.min-h-screen.flex.flex-col.h-full.text-white.bg-neutral-900.relative + [navbar current-match] + [:div.flex.flex-col.justify-between.relative.font-nunito {:class "min-h-[calc(100vh-58px)]"} + (when-let [view (-> current-match :data :view)] + [view current-match]) + [footer] + [player/global-player]]])) diff --git a/src/frontend/tubo/views/channel.cljs b/src/frontend/tubo/views/channel.cljs new file mode 100644 index 0000000..ee5459f --- /dev/null +++ b/src/frontend/tubo/views/channel.cljs @@ -0,0 +1,41 @@ +(ns tubo.views.channel + (:require + [re-frame.core :as rf] + [tubo.components.items :as items] + [tubo.components.loading :as loading] + [tubo.components.navigation :as navigation] + [tubo.events :as events])) + +(defn channel + [{{:keys [url]} :query-params}] + (let [{:keys [banner avatar name description subscriber-count + related-streams next-page]} @(rf/subscribe [:channel]) + next-page-url (:url next-page) + service-color @(rf/subscribe [:service-color]) + page-loading? @(rf/subscribe [:show-page-loading]) + pagination-loading? @(rf/subscribe [:show-pagination-loading]) + page-scroll @(rf/subscribe [:page-scroll]) + scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] + (when scrolled-to-bottom? + (rf/dispatch [::events/channel-pagination url next-page-url])) + [:div.flex.flex-col.items-center.px-5.py-2.flex-auto + (if page-loading? + [loading/loading-icon service-color "text-5xl"] + [:div.flex.flex-col.flex-auto {:class "ml:w-4/5 xl:w-3/5"} + [navigation/back-button service-color] + (when banner + [:div.flex.justify-center + [:img {:src banner}]]) + [:div.flex.items-center.my-4.mx-2 + (when avatar + [:div.relative.w-16.h-16 + [:img.rounded-full.object-cover.max-w-full.min-h-full {:src avatar :alt name}]]) + [:div.m-4 + [:h1.text-xl name] + (when subscriber-count + [:div.flex.my-2.items-center + [:i.fa-solid.fa-users.text-xs] + [:span.mx-2 (.toLocaleString subscriber-count)]])]] + [:div.my-2 + [:p description]] + [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tubo/views/kiosk.cljs b/src/frontend/tubo/views/kiosk.cljs new file mode 100644 index 0000000..1c072af --- /dev/null +++ b/src/frontend/tubo/views/kiosk.cljs @@ -0,0 +1,26 @@ +(ns tubo.views.kiosk + (:require + [re-frame.core :as rf] + [tubo.components.items :as items] + [tubo.components.loading :as loading] + [tubo.components.navigation :as navigation] + [tubo.events :as events])) + +(defn kiosk + [{{:keys [serviceId kioskId]} :query-params}] + (let [{:keys [id url related-streams next-page]} @(rf/subscribe [:kiosk]) + next-page-url (:url next-page) + service-color @(rf/subscribe [:service-color]) + page-loading? @(rf/subscribe [:show-page-loading]) + page-scroll @(rf/subscribe [:page-scroll]) + scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] + (when scrolled-to-bottom? + (rf/dispatch [::events/kiosk-pagination serviceId id next-page-url])) + [:div.flex.flex-col.items-center.px-5.py-2.flex-auto + (if page-loading? + [loading/loading-icon service-color "text-5xl"] + [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"} + [:div.flex.justify-center.items-center.my-4.mx-2 + [:div.m-4 + [:h1.text-2xl id]]] + [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tubo/views/playlist.cljs b/src/frontend/tubo/views/playlist.cljs new file mode 100644 index 0000000..ed4f90f --- /dev/null +++ b/src/frontend/tubo/views/playlist.cljs @@ -0,0 +1,40 @@ +(ns tubo.views.playlist + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.items :as items] + [tubo.components.loading :as loading] + [tubo.components.navigation :as navigation] + [tubo.events :as events])) + +(defn playlist + [{{:keys [url]} :query-params}] + (let [{:keys [id name playlist-type thumbnail-url banner-url + uploader-name uploader-url uploader-avatar stream-count + next-page related-streams]} @(rf/subscribe [:playlist]) + next-page-url (:url next-page) + service-color @(rf/subscribe [:service-color]) + page-loading? @(rf/subscribe [:show-page-loading]) + page-scroll @(rf/subscribe [:page-scroll]) + scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] + (when scrolled-to-bottom? + (rf/dispatch [::events/playlist-pagination url next-page-url])) + [:div.flex.flex-col.items-center.px-5.pt-4.flex-auto + (if page-loading? + [loading/loading-icon service-color "text-5xl"] + [:div.flex.flex-col.flex-auto.w-full {:class "ml:w-4/5"} + [navigation/back-button service-color] + (when banner-url + [:div + [:img {:src banner-url}]]) + [:div.flex.items-center.justify-center.my-4.mx-2 + [:div.flex.flex-col.justify-center.items-center + [:h1.text-2xl.font-bold name] + [:div.flex.items-center.pt-4 + [:span.mr-2 "By"] + [:div.flex.items-center.py-3.box-border.h-12 + [:div.w-12 + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} + [:img.rounded-full.object-cover.min-h-full.min-w-full {:src uploader-avatar :alt uploader-name}]]]]] + [:p.pt-4 (str stream-count " streams")]]] + [items/related-streams related-streams next-page-url]])])) diff --git a/src/frontend/tubo/views/search.cljs b/src/frontend/tubo/views/search.cljs new file mode 100644 index 0000000..9938f53 --- /dev/null +++ b/src/frontend/tubo/views/search.cljs @@ -0,0 +1,28 @@ +(ns tubo.views.search + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.components.items :as items] + [tubo.components.loading :as loading] + [tubo.events :as events])) + +(defn search + [{{:keys [q serviceId]} :query-params}] + (let [{:keys [items next-page] :as search-results} @(rf/subscribe [:search-results]) + next-page-url (:url next-page) + services @(rf/subscribe [:services]) + service-id @(rf/subscribe [:service-id]) + service-color @(rf/subscribe [:service-color]) + page-scroll @(rf/subscribe [:page-scroll]) + page-loading? @(rf/subscribe [:show-page-loading]) + scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))] + (when scrolled-to-bottom? + (rf/dispatch [::events/search-pagination q serviceId next-page-url])) + [:div.flex.flex-col.items-center.flex-auto + [:div.flex.flex-col.items-center.w-full.pt-4.flex-initial + [:h2 (str "Showing search results for: \"" q "\"")] + [:h1 (str "Number of search results: " (count items))]] + (if page-loading? + [loading/loading-icon service-color "text-5xl"] + [:div.flex.flex-col.flex-auto.w-full {:class "lg:w-4/5"} + [items/related-streams items next-page-url]])])) diff --git a/src/frontend/tubo/views/stream.cljs b/src/frontend/tubo/views/stream.cljs new file mode 100644 index 0000000..a380b5e --- /dev/null +++ b/src/frontend/tubo/views/stream.cljs @@ -0,0 +1,122 @@ +(ns tubo.views.stream + (:require + [re-frame.core :as rf] + [reitit.frontend.easy :as rfe] + [tubo.events :as events] + [tubo.components.items :as items] + [tubo.components.loading :as loading] + [tubo.components.navigation :as navigation] + [tubo.components.comments :as comments] + [tubo.components.player :as player] + [tubo.util :as util])) + +(defn stream + [match] + (let [{:keys [name url video-streams audio-streams view-count + subscriber-count like-count dislike-count + description uploader-avatar uploader-name + uploader-url upload-date related-streams + thumbnail-url show-comments-loading comments-page + show-comments service-id] :as stream} @(rf/subscribe [:stream]) + available-streams (apply conj audio-streams video-streams) + {:keys [content id] :as stream-format} @(rf/subscribe [:stream-format]) + page-loading? @(rf/subscribe [:show-page-loading]) + service-color @(rf/subscribe [:service-color])] + [:div.flex.flex-col.items-center.justify-center.text-white.flex-auto + (if page-loading? + [loading/loading-icon service-color "text-5xl"] + [:div.w-full.pb-4.relative {:class "ml:w-4/5 xl:w-3/5"} + [navigation/back-button service-color] + [:div.flex.justify-center.relative + {:class "ml:h-[450px] lg:h-[600px]"} + (when stream-format + [player/stream-player {"sources" [{"src" content "type" "video/mp4"} + {"src" content "type" "video/webm"}] + "poster" thumbnail-url + "controls" true} + content])] + [:div.px-4.ml:p-0 + [:div.flex.flex.w-full.mt-3 + (when stream-format + [:div.relative.flex.bg-stone-800.flex-col.items-center.justify-center.z-10.mr-2.border.rounded.border-black + [:select.border-none.focus:ring-transparent.bg-blend-color-dodge.pl-4.pr-8.w-full + {:on-change #(rf/dispatch [::events/change-stream-format (.. % -target -value)]) + :value id + :style {:background "transparent"}} + (when available-streams + (for [[i {:keys [id format resolution averageBitrate]}] (map-indexed vector available-streams)] + [:option.bg-neutral-900.border-none {:value id :key i} + (str (or resolution "audio-only") " " format (when-not resolution (str " " averageBitrate "kbit/s")))]))] + [:div.flex.absolute.min-h-full.top-0.right-4.items-center.justify-end + {:style {:zIndex "-1"}} + [:i.fa-solid.fa-caret-down]]]) + [:button.border.rounded.border-black.px-3.py-1.bg-stone-800 + {:on-click #(rf/dispatch [::events/switch-to-global-player + {:uploader-name uploader-name :uploader-url uploader-url + :name name :url url :stream content :service-color service-color}])} + [:i.fa-solid.fa-headphones]] + [:button.border.rounded.border-black.px-3.py-1.bg-stone-800.mx-2 + [:a {:href url} + [:i.fa-solid.fa-external-link-alt]]]] + [:div.flex.flex-col.py-1.comments + [:div.min-w-full.py-3 + [:h1.text-2xl.font-extrabold.line-clamp-1 name]] + [:div.flex.justify-between.py-2 + [:div.flex.items-center.flex-auto + (when uploader-avatar + [:div.relative.w-16.h-16 + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url}) :title uploader-name} + [:img.rounded-full.object-cover.max-w-full.min-h-full {:src uploader-avatar :alt uploader-name}]]]) + [:div.mx-2 + [:a {:href (rfe/href :tubo.routes/channel nil {:url uploader-url})} uploader-name] + (when subscriber-count + [:div.flex.my-2.items-center + [:i.fa-solid.fa-users.text-xs] + [:p.mx-2 (util/format-quantity subscriber-count)]])]] + [:div.flex.flex-col.items-end + (when view-count + [:div + [:i.fa-solid.fa-eye.text-xs] + [:span.ml-2 (.toLocaleString view-count)]]) + [:div.flex + (when like-count + [:div.items-center + [:i.fa-solid.fa-thumbs-up.text-xs] + [:span.ml-2 (.toLocaleString like-count)]]) + (when dislike-count + [:div.ml-2.items-center + [:i.fa-solid.fa-thumbs-down.text-xs] + [:span.ml-2 dislike-count]])] + (when upload-date + [:div + [:i.fa-solid.fa-calendar.mx-2.text-xs] + [:span + (-> upload-date + js/Date.parse + js/Date. + .toDateString)]])]] + [:div.min-w-full.py-3 + [:h1 name] + [:div {:dangerouslySetInnerHTML {:__html description}}]] + [:div.py-6 + [:div.flex.items-center + [:i.fa-solid.fa-comments] + [:p.px-2.py-4 "Comments"] + (if show-comments + [:i.fa-solid.fa-chevron-up {:on-click #(rf/dispatch [::events/toggle-comments]) + :style {:cursor "pointer"}}] + [:i.fa-solid.fa-chevron-down {:on-click #(if (or show-comments comments-page) + (rf/dispatch [::events/toggle-comments]) + (rf/dispatch [::events/get-comments url])) + :style {:cursor "pointer"}}])] + [:div + (if show-comments-loading + [loading/loading-icon service-color "text-2xl"] + (when (and show-comments comments-page) + [comments/comments comments-page uploader-name uploader-avatar url]))]] + (when-not (empty? related-streams) + [:div.py-3 + [:div.flex.items-center + [:i.fa-solid.fa-list] + [:h1.px-2.text-lg.bold "Related Results"]] + [items/related-streams related-streams nil]])]]])])) diff --git a/webpack.config.js b/webpack.config.js index 55590c4..b3b8620 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,7 +5,7 @@ const RemoveEmptyScriptsPlugin = require("webpack-remove-empty-scripts") module.exports = { mode: process.env.NODE_ENV, entry: { - tau: "./resources/src/css/tau.scss" + tubo: "./resources/src/css/tubo.scss" }, output: { path: path.resolve(__dirname, "resources/public") -- cgit v1.2.3