diff --git a/front/bun.lock b/front/bun.lock
index 0732b929..10da79ae 100644
--- a/front/bun.lock
+++ b/front/bun.lock
@@ -39,6 +39,8 @@
"react-native-web": "^0.21.1",
"react-tooltip": "^5.29.1",
"sweetalert2": "^11.23.0",
+ "uuid": "^13.0.0",
+ "video.js": "^8.23.4",
"yoshiki": "1.2.14",
"zod": "^4.1.11",
},
@@ -545,6 +547,12 @@
"@urql/exchange-retry": ["@urql/exchange-retry@1.3.2", "", { "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg=="],
+ "@videojs/http-streaming": ["@videojs/http-streaming@3.17.2", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "aes-decrypter": "^4.0.2", "global": "^4.4.0", "m3u8-parser": "^7.2.0", "mpd-parser": "^1.3.1", "mux.js": "7.1.0", "video.js": "^7 || ^8" } }, "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g=="],
+
+ "@videojs/vhs-utils": ["@videojs/vhs-utils@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "global": "^4.4.0" } }, "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA=="],
+
+ "@videojs/xhr": ["@videojs/xhr@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "global": "~4.4.0", "is-function": "^1.0.1" } }, "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ=="],
+
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.10", "", {}, "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@@ -553,6 +561,8 @@
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+ "aes-decrypter": ["aes-decrypter@4.0.2", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "global": "^4.4.0", "pkcs7": "^1.0.4" } }, "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw=="],
+
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -729,6 +739,8 @@
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+ "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="],
+
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
@@ -875,6 +887,8 @@
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="],
+
"global-dirs": ["global-dirs@0.1.1", "", { "dependencies": { "ini": "^1.3.4" } }, "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -931,6 +945,8 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+ "is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="],
+
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
@@ -1025,6 +1041,8 @@
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+ "m3u8-parser": ["m3u8-parser@7.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "global": "^4.4.0" } }, "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ=="],
+
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -1073,6 +1091,8 @@
"mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
+ "min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="],
+
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1083,8 +1103,12 @@
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
+ "mpd-parser": ["mpd-parser@1.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.0.0", "@xmldom/xmldom": "^0.8.3", "global": "^4.4.0" }, "bin": { "mpd-to-m3u8-json": "bin/parse.js" } }, "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "mux.js": ["mux.js@7.1.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "global": "^4.4.0" }, "bin": { "muxjs-transmux": "bin/transmux.js" } }, "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA=="],
+
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -1163,6 +1187,8 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
+ "pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="],
+
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
@@ -1177,6 +1203,8 @@
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
+ "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
+
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
@@ -1227,7 +1255,7 @@
"react-native-svg-transformer": ["react-native-svg-transformer@1.5.1", "", { "dependencies": { "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "path-dirname": "^1.0.2" }, "peerDependencies": { "react-native": ">=0.59.0", "react-native-svg": ">=12.0.0" } }, "sha512-dFvBNR8A9VPum9KCfh+LE49YiJEF8zUSnEFciKQroR/bEOhlPoZA0SuQ0qNk7m2iZl2w59FYjdRe0pMHWMDl0Q=="],
- "react-native-video": ["react-native-video@github:zoriya/react-native-video#0bb0dd5", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.27.2" } }, "zoriya-react-native-video-0bb0dd5"],
+ "react-native-video": ["react-native-video@github:zoriya/react-native-video#6613ecf", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.27.2" } }, "zoriya-react-native-video-6613ecf"],
"react-native-web": ["react-native-web@0.21.1", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BeNsgwwe4AXUFPAoFU+DKjJ+CVQa3h54zYX77p7GVZrXiiNo3vl03WYDYVEy5R2J2HOPInXtQZB5gmj3vuzrKg=="],
@@ -1435,7 +1463,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
- "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
+ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
@@ -1443,6 +1471,14 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
+ "video.js": ["video.js@8.23.4", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/http-streaming": "^3.17.2", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", "global": "4.4.0", "m3u8-parser": "^7.2.0", "mpd-parser": "^1.3.1", "mux.js": "^7.0.1", "videojs-contrib-quality-levels": "4.1.0", "videojs-font": "4.2.0", "videojs-vtt.js": "0.15.5" } }, "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ=="],
+
+ "videojs-contrib-quality-levels": ["videojs-contrib-quality-levels@4.1.0", "", { "dependencies": { "global": "^4.4.0" }, "peerDependencies": { "video.js": "^8" } }, "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA=="],
+
+ "videojs-font": ["videojs-font@4.2.0", "", {}, "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ=="],
+
+ "videojs-vtt.js": ["videojs-vtt.js@0.15.5", "", { "dependencies": { "global": "^4.3.1" } }, "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ=="],
+
"vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@@ -1713,6 +1749,8 @@
"write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+ "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
+
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"yoshiki/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
diff --git a/front/package.json b/front/package.json
index 99f6629b..cd691682 100644
--- a/front/package.json
+++ b/front/package.json
@@ -48,6 +48,8 @@
"react-native-web": "^0.21.1",
"react-tooltip": "^5.29.1",
"sweetalert2": "^11.23.0",
+ "uuid": "^13.0.0",
+ "video.js": "^8.23.4",
"yoshiki": "1.2.14",
"zod": "^4.1.11"
},
diff --git a/front/src/ui/player/controls/index.tsx b/front/src/ui/player/controls/index.tsx
index dea8d039..366acfa7 100644
--- a/front/src/ui/player/controls/index.tsx
+++ b/front/src/ui/player/controls/index.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { ViewProps } from "react-native";
-import { StyleSheet } from "react-native";
+import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
@@ -45,16 +45,16 @@ export const Controls = ({
} satisfies ViewProps;
return (
-
+
+
-
+
);
};
diff --git a/front/src/ui/player/controls/misc.tsx b/front/src/ui/player/controls/misc.tsx
index f3b18cc6..642a44b3 100644
--- a/front/src/ui/player/controls/misc.tsx
+++ b/front/src/ui/player/controls/misc.tsx
@@ -6,9 +6,9 @@ import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
-import { type ComponentProps, useState } from "react";
+import { type ComponentProps, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
-import { type PressableProps, View } from "react-native";
+import { type PressableProps, View, Platform } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { px, useYoshiki } from "yoshiki/native";
import {
@@ -46,18 +46,39 @@ export const PlayButton = ({
);
};
+export const toggleFullscreen = async (set?: boolean) => {
+ set ??= document.fullscreenElement === null;
+ try {
+ if (set) {
+ await document.body.requestFullscreen({ navigationUI: "hide" });
+ // @ts-expect-error Firefox does not support this so ts complains
+ await screen.orientation.lock("landscape");
+ } else {
+ if (document.fullscreenElement) await document.exitFullscreen();
+ screen.orientation.unlock();
+ }
+ } catch (e) {
+ console.log(e);
+ }
+};
+
export const FullscreenButton = (
props: Partial>>,
) => {
+ // this is a web only component
const { t } = useTranslation();
- // TODO: actually implement that
- const [fullscreen, setFullscreen] = useState(true);
+ const [fullscreen, setFullscreen] = useState(false);
+ useEffect(() => {
+ const update = () => setFullscreen(document.fullscreenElement !== null);
+ document.addEventListener("fullscreenchange", update);
+ return () => document.removeEventListener("fullscreenchange", update);
+ }, []);
return (
console.log("lol")}
+ onPress={() => toggleFullscreen()}
{...tooltip(t("player.fullscreen"), true)}
{...props}
/>
@@ -91,9 +112,9 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
icon={
muted || volume === 0
? VolumeOff
- : volume < 25
+ : volume < 0.25
? VolumeMute
- : volume < 65
+ : volume < 0.65
? VolumeDown
: VolumeUp
}
@@ -103,9 +124,9 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
{...tooltip(t("player.mute"), true)}
/>
{
- player.volume = vol;
+ player.volume = vol / 100;
}}
size={4}
{...css({ width: px(100) })}
diff --git a/front/src/ui/player/controls/progress.tsx b/front/src/ui/player/controls/progress.tsx
index c63ad53c..e28d2ca6 100644
--- a/front/src/ui/player/controls/progress.tsx
+++ b/front/src/ui/player/controls/progress.tsx
@@ -80,11 +80,11 @@ export const ProgressText = ({
}: { player: VideoPlayer } & TextProps) => {
const { css } = useYoshiki();
- const [progress, setProgress] = useState(player.currentTime || 0);
+ const [progress, setProgress] = useState(player.currentTime);
useEvent(player, "onProgress", (progress) => {
setProgress(progress.currentTime);
});
- const [duration, setDuration] = useState(player.duration || 100);
+ const [duration, setDuration] = useState(player.duration);
useEvent(player, "onLoad", (info) => {
if (info.duration) setDuration(info.duration);
});
diff --git a/front/src/ui/player/controls/touch.tsx b/front/src/ui/player/controls/touch.tsx
index 579a1def..4bd4c0fa 100644
--- a/front/src/ui/player/controls/touch.tsx
+++ b/front/src/ui/player/controls/touch.tsx
@@ -8,6 +8,7 @@ import {
import { useEvent, type VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import { useIsTouch } from "~/primitives";
+import { toggleFullscreen } from "./misc";
export const TouchControls = ({
player,
@@ -62,7 +63,7 @@ export const TouchControls = ({
}}
onDoublePress={(e) => {
if (!isTouch) {
- // player.toggleFullscreen();
+ toggleFullscreen();
return;
}
diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx
index 00429b1d..f1054520 100644
--- a/front/src/ui/player/index.tsx
+++ b/front/src/ui/player/index.tsx
@@ -1,5 +1,5 @@
import { Stack, useRouter } from "expo-router";
-import { StyleSheet, View } from "react-native";
+import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, VideoInfo } from "~/models";
@@ -9,6 +9,11 @@ import { useLocalSetting } from "~/providers/settings";
import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
import { Controls, LoadingIndicator } from "./controls";
+import { useEffect } from "react";
+import { v4 as uuidv4 } from "uuid";
+import { toggleFullscreen } from "./controls/misc";
+
+const clientId = uuidv4();
export const Player = () => {
const [slug, setSlug] = useQueryState("slug", undefined!);
@@ -19,28 +24,40 @@ export const Player = () => {
// TODO: map current entry using entries' duration & the current playtime
const currentEntry = 0;
const entry = data?.entries[currentEntry] ?? data?.entries[0];
+ const title = entry ? `${entry.name} (${entryDisplayNumber(entry)})` : null;
const { apiUrl, authToken } = useToken();
const [playMode] = useLocalSetting<"direct" | "hls">("playMode", "direct");
const player = useVideoPlayer(
{
- uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}`,
- headers: {
- Authorization: `Bearer ${authToken}`,
+ uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
+ // chrome based browsers support matroska but they tell they don't
+ mimeType: info?.mimeCodec?.replace("x-matroska", "mp4"),
+ headers: authToken
+ ? {
+ Authorization: `Bearer ${authToken}`,
+ }
+ : {},
+ metadata: {
+ title: title ?? undefined,
+ description: entry?.description ?? undefined,
+ artist: data?.show?.name ?? undefined,
+ imageUri: data?.show?.thumbnail?.high ?? undefined,
},
- // externalSubtitles: info?.subtitles
- // .filter((x) => x.link)
- // .map((x) => ({
- // uri: x.link!,
- // // TODO: translate this `Unknown`
- // label: x.title ?? "Unknown",
- // language: x.language ?? "und",
- // type: x.codec,
- // })),
+ externalSubtitles: info?.subtitles
+ .filter((x) => x.link)
+ .map((x) => ({
+ uri: x.link!,
+ // TODO: translate this `Unknown`
+ label: x.title ?? "Unknown",
+ language: x.language ?? "und",
+ type: x.codec,
+ })),
},
(p) => {
p.playWhenInactive = true;
p.playInBackground = true;
+ p.showNotificationControls = true;
const seek = start ?? data?.progress.time;
// TODO: fix console.error bellow
if (seek) p.seekTo(seek);
@@ -60,6 +77,31 @@ export const Player = () => {
}
});
+ // TODO: add the equivalent of this for android
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const prev = data?.previous?.video;
+ window.navigator.mediaSession.setActionHandler(
+ "previoustrack",
+ prev
+ ? () => {
+ setStart(0);
+ setSlug(prev);
+ }
+ : null,
+ );
+ const next = data?.next?.video;
+ window.navigator.mediaSession.setActionHandler(
+ "nexttrack",
+ next
+ ? () => {
+ setStart(0);
+ setSlug(next);
+ }
+ : null,
+ );
+ }, [data?.next?.video, data?.previous?.video, setSlug, setStart]);
+
// const [playbackError, setPlaybackError] = useState(
// undefined,
// );
@@ -67,14 +109,13 @@ export const Player = () => {
// const startTime = startTimeP ?? data?.watchStatus?.watchedTime;
- // const setFullscreen = useSetAtom(fullscreenAtom);
- // useEffect(() => {
- // if (Platform.OS !== "web") return;
- // if (/Mobi/i.test(window.navigator.userAgent)) setFullscreen(true);
- // return () => {
- // if (!document.location.href.includes("/watch")) setFullscreen(false);
- // };
- // }, [setFullscreen]);
+ useEffect(() => {
+ if (Platform.OS !== "web") return;
+ if (/Mobi/i.test(window.navigator.userAgent)) toggleFullscreen(true);
+ return () => {
+ if (!document.location.href.includes("/watch")) toggleFullscreen(false);
+ };
+ }, []);
// if (error || infoError || playbackError)
// return (
@@ -95,7 +136,7 @@ export const Player = () => {
}}
>
diff --git a/front/src/ui/player/old/media-session.tsx b/front/src/ui/player/old/media-session.tsx
deleted file mode 100644
index 64b74dfd..00000000
--- a/front/src/ui/player/old/media-session.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Kyoo - A portable and vast media library solution.
- * Copyright (c) Kyoo.
- *
- * See AUTHORS.md and LICENSE file in the project root for full license information.
- *
- * Kyoo is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * any later version.
- *
- * Kyoo is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Kyoo. If not, see .
- */
-
-import { useAtom, useAtomValue, useSetAtom } from "jotai";
-import { useEffect } from "react";
-import { useRouter } from "solito/router";
-import { reducerAtom } from "./old/keyboardd";
-import { durationAtom, playAtom, progressAtom } from "./old/statee";
-
-export const MediaSessionManager = ({
- title,
- subtitle,
- artist,
- imageUri,
- previous,
- next,
-}: {
- title?: string;
- subtitle?: string;
- artist?: string;
- imageUri?: string | null;
- previous?: string;
- next?: string;
-}) => {
- const [isPlaying, setPlay] = useAtom(playAtom);
- const progress = useAtomValue(progressAtom);
- const duration = useAtomValue(durationAtom);
- const reducer = useSetAtom(reducerAtom);
- const router = useRouter();
-
- useEffect(() => {
- if (!("mediaSession" in navigator)) return;
- navigator.mediaSession.metadata = new MediaMetadata({
- title: title,
- album: subtitle,
- artist: artist,
- artwork: imageUri ? [{ src: imageUri }] : undefined,
- });
- }, [title, subtitle, artist, imageUri]);
-
- useEffect(() => {
- if (!("mediaSession" in navigator)) return;
- const actions: [MediaSessionAction, MediaSessionActionHandler | null][] = [
- ["play", () => setPlay(true)],
- ["pause", () => setPlay(false)],
- ["previoustrack", previous ? () => router.push(previous) : null],
- ["nexttrack", next ? () => router.push(next) : null],
- [
- "seekbackward",
- (evt: MediaSessionActionDetails) =>
- reducer({ type: "seek", value: evt.seekOffset ? -evt.seekOffset : -10 }),
- ],
- [
- "seekforward",
- (evt: MediaSessionActionDetails) =>
- reducer({ type: "seek", value: evt.seekOffset ? evt.seekOffset : 10 }),
- ],
- [
- "seekto",
- (evt: MediaSessionActionDetails) => reducer({ type: "seekTo", value: evt.seekTime! }),
- ],
- ];
-
- for (const [action, handler] of actions) {
- try {
- navigator.mediaSession.setActionHandler(action, handler);
- } catch {}
- }
- }, [setPlay, reducer, router, previous, next]);
-
- useEffect(() => {
- if (!("mediaSession" in navigator)) return;
- navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
- }, [isPlaying]);
- useEffect(() => {
- if (!("mediaSession" in navigator) || !duration) return;
- navigator.mediaSession.setPositionState({
- position: Math.min(progress, duration),
- duration,
- playbackRate: 1,
- });
- }, [progress, duration]);
-
- return null;
-};
diff --git a/front/src/utils.ts b/front/src/utils.ts
index 7315e0ad..2d67999c 100644
--- a/front/src/utils.ts
+++ b/front/src/utils.ts
@@ -1,5 +1,5 @@
import { NavigationContext, useRoute } from "@react-navigation/native";
-import { useContext } from "react";
+import { useCallback, useContext } from "react";
import type { Movie, Show } from "~/models";
export function setServerData(_key: string, _val: any) {}
@@ -12,9 +12,12 @@ export const useQueryState = (key: string, initial: S) => {
const nav = useContext(NavigationContext);
const state = ((route.params as any)?.[key] as S) ?? initial;
- const update = (val: S | ((old: S) => S)) => {
- nav!.setParams({ [key]: val });
- };
+ const update = useCallback(
+ (val: S | ((old: S) => S)) => {
+ nav!.setParams({ [key]: val });
+ },
+ [nav, key],
+ );
return [state, update] as const;
};