Browse Source

feat(frontend): Introduce video.js for stream playback

Miguel Ángel Moreno 2 years ago
parent
commit
67007e8bdd

+ 241 - 0
package-lock.json

@@ -28,6 +28,15 @@
         "js-tokens": "^4.0.0"
       }
     },
+    "@babel/runtime": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
+      "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
+      "dev": true,
+      "requires": {
+        "regenerator-runtime": "^0.13.11"
+      }
+    },
     "@discoveryjs/json-ext": {
       "version": "0.5.7",
       "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -179,6 +188,44 @@
       "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
       "dev": true
     },
+    "@videojs/http-streaming": {
+      "version": "2.14.3",
+      "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.14.3.tgz",
+      "integrity": "sha512-2tFwxCaNbcEZzQugWf8EERwNMyNtspfHnvxRGRABQs09W/5SqmkWFuGWfUAm4wQKlXGfdPyAJ1338ASl459xAA==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@videojs/vhs-utils": "3.0.5",
+        "aes-decrypter": "3.1.3",
+        "global": "^4.4.0",
+        "m3u8-parser": "4.7.1",
+        "mpd-parser": "0.21.1",
+        "mux.js": "6.0.1",
+        "video.js": "^6 || ^7"
+      }
+    },
+    "@videojs/vhs-utils": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz",
+      "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "global": "^4.4.0",
+        "url-toolkit": "^2.2.1"
+      }
+    },
+    "@videojs/xhr": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz",
+      "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "global": "~4.4.0",
+        "is-function": "^1.0.1"
+      }
+    },
     "@webassemblyjs/ast": {
       "version": "1.11.1",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -343,6 +390,12 @@
       "integrity": "sha512-0G7tNyS+yW8TdgHwZKlDWYXFA6OJQnoLCQvYKkQP0Q2X205PSQ6RNUj0M+1OB/9gRQaUZ/ccYfaxd0nhaWKfjw==",
       "dev": true
     },
+    "@xmldom/xmldom": {
+      "version": "0.7.9",
+      "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.9.tgz",
+      "integrity": "sha512-yceMpm/xd4W2a85iqZyO09gTnHvXF6pyiWjD2jcOJs7hRoZtNNOO1eJlhHj1ixA+xip2hOyGn+LgcvLCMo5zXA==",
+      "dev": true
+    },
     "@xtuc/ieee754": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -384,6 +437,18 @@
       "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
       "dev": true
     },
+    "aes-decrypter": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.3.tgz",
+      "integrity": "sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@videojs/vhs-utils": "^3.0.5",
+        "global": "^4.4.0",
+        "pkcs7": "^1.0.4"
+      }
+    },
     "ajv": {
       "version": "8.11.2",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz",
@@ -459,6 +524,11 @@
         "postcss-value-parser": "^4.2.0"
       }
     },
+    "base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -492,6 +562,15 @@
         "update-browserslist-db": "^1.0.9"
       }
     },
+    "buffer": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+      "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+      "requires": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.2.1"
+      }
+    },
     "buffer-from": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -817,6 +896,12 @@
         "entities": "^2.0.0"
       }
     },
+    "dom-walk": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+      "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
+      "dev": true
+    },
     "domelementtype": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@@ -1041,6 +1126,16 @@
       "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
       "dev": true
     },
+    "global": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+      "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+      "dev": true,
+      "requires": {
+        "min-document": "^2.19.0",
+        "process": "^0.11.10"
+      }
+    },
     "graceful-fs": {
       "version": "4.2.10",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@@ -1068,6 +1163,11 @@
       "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
       "dev": true
     },
+    "ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
+    },
     "immutable": {
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.1.tgz",
@@ -1094,6 +1194,12 @@
         "resolve-cwd": "^3.0.0"
       }
     },
+    "individual": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
+      "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==",
+      "dev": true
+    },
     "interpret": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
@@ -1130,6 +1236,12 @@
       "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "dev": true
     },
+    "is-function": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+      "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+      "dev": true
+    },
     "is-glob": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1211,6 +1323,12 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
     },
+    "keycode": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
+      "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==",
+      "dev": true
+    },
     "kind-of": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -1279,6 +1397,17 @@
         "yallist": "^4.0.0"
       }
     },
+    "m3u8-parser": {
+      "version": "4.7.1",
+      "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.7.1.tgz",
+      "integrity": "sha512-pbrQwiMiq+MmI9bl7UjtPT3AK603PV9bogNlr83uC+X9IoxqL5E4k7kU7fMQ0dpRgxgeSMygqUa0IMLQNXLBNA==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@videojs/vhs-utils": "^3.0.5",
+        "global": "^4.4.0"
+      }
+    },
     "mdn-data": {
       "version": "2.0.14",
       "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -1322,6 +1451,15 @@
         "mime-db": "1.52.0"
       }
     },
+    "min-document": {
+      "version": "2.19.0",
+      "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+      "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+      "dev": true,
+      "requires": {
+        "dom-walk": "^0.1.0"
+      }
+    },
     "mini-css-extract-plugin": {
       "version": "2.7.2",
       "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@@ -1343,6 +1481,28 @@
       "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
       "dev": true
     },
+    "mpd-parser": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.21.1.tgz",
+      "integrity": "sha512-BxlSXWbKE1n7eyEPBnTEkrzhS3PdmkkKdM1pgKbPnPOH0WFZIc0sPOWi7m0Uo3Wd2a4Or8Qf4ZbS7+ASqQ49fw==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@videojs/vhs-utils": "^3.0.5",
+        "@xmldom/xmldom": "^0.7.2",
+        "global": "^4.4.0"
+      }
+    },
+    "mux.js": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-6.0.1.tgz",
+      "integrity": "sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.11.2",
+        "global": "^4.4.0"
+      }
+    },
     "nanoid": {
       "version": "3.3.4",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
@@ -1481,6 +1641,15 @@
       "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
       "dev": true
     },
+    "pkcs7": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
+      "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.5.5"
+      }
+    },
     "pkg-dir": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -1848,6 +2017,12 @@
       "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
       "dev": true
     },
+    "process": {
+      "version": "0.11.10",
+      "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+      "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+      "dev": true
+    },
     "punycode": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -1919,6 +2094,12 @@
         "resolve": "^1.20.0"
       }
     },
+    "regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "dev": true
+    },
     "require-from-string": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1974,12 +2155,30 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "rust-result": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
+      "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==",
+      "dev": true,
+      "requires": {
+        "individual": "^2.0.0"
+      }
+    },
     "safe-buffer": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
       "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
       "dev": true
     },
+    "safe-json-parse": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
+      "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==",
+      "dev": true,
+      "requires": {
+        "rust-result": "^1.0.0"
+      }
+    },
     "sass": {
       "version": "1.57.1",
       "resolved": "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz",
@@ -2290,12 +2489,54 @@
         "punycode": "^2.1.0"
       }
     },
+    "url-toolkit": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz",
+      "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==",
+      "dev": true
+    },
     "util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
       "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
       "dev": true
     },
+    "video.js": {
+      "version": "7.20.3",
+      "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.20.3.tgz",
+      "integrity": "sha512-JMspxaK74LdfWcv69XWhX4rILywz/eInOVPdKefpQiZJSMD5O8xXYueqACP2Q5yqKstycgmmEKlJzZ+kVmDciw==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@videojs/http-streaming": "2.14.3",
+        "@videojs/vhs-utils": "^3.0.4",
+        "@videojs/xhr": "2.6.0",
+        "aes-decrypter": "3.1.3",
+        "global": "^4.4.0",
+        "keycode": "^2.2.0",
+        "m3u8-parser": "4.7.1",
+        "mpd-parser": "0.21.1",
+        "mux.js": "6.0.1",
+        "safe-json-parse": "4.0.0",
+        "videojs-font": "3.2.0",
+        "videojs-vtt.js": "^0.15.4"
+      }
+    },
+    "videojs-font": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
+      "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==",
+      "dev": true
+    },
+    "videojs-vtt.js": {
+      "version": "0.15.4",
+      "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz",
+      "integrity": "sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA==",
+      "dev": true,
+      "requires": {
+        "global": "^4.3.1"
+      }
+    },
     "watchpack": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

+ 2 - 0
package.json

@@ -6,6 +6,7 @@
   },
   "dependencies": {
     "@fortawesome/fontawesome-free": "^6.2.1",
+    "buffer": "^6.0.3",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "timeago.js": "^4.0.2"
@@ -24,6 +25,7 @@
     "sass": "^1.57.1",
     "sass-loader": "^13.2.0",
     "tailwindcss": "^3.2.4",
+    "video.js": "^7.20.3",
     "webpack": "^5.75.0",
     "webpack-cli": "^5.0.1",
     "webpack-remove-empty-scripts": "^1.0.1"

+ 1 - 0
resources/src/css/tau.scss

@@ -7,3 +7,4 @@
 @import "tailwindcss/base";
 @import "tailwindcss/components";
 @import "tailwindcss/utilities";
+@import "video.js/dist/video-js.css";

+ 63 - 17
src/frontend/tau/components/player.cljs

@@ -1,24 +1,70 @@
 (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]))
+   [tau.events :as events]
+   ["video.js" :as videojs]))
 
 (defn global-player
   []
-  (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
-       {: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 :controls true}]]]
-        [:div.px-2
-         [:i.fa-solid.fa-close.cursor-pointer {:on-click #(rf/dispatch [::events/toggle-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])})))

+ 8 - 5
src/frontend/tau/views/stream.cljs

@@ -7,6 +7,7 @@
    [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
@@ -27,11 +28,13 @@
        [: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
-         {:style {:background (str "center / cover no-repeat url('" thumbnail-url"')")}
-          :class "ml:h-[450px] lg:h-[600px]"}
-         [:video.bottom-0.object-cover.min-h-full.max-h-full.min-w-full
-          {:src content :controls true}
-          "This browser can't play the stream format."]]
+         {: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