From 860671be0088ae4714fa8f36cd28ea70a61e5f41 Mon Sep 17 00:00:00 2001 From: mrjvs <jellevs@gmail.com> Date: Sun, 1 Oct 2023 21:08:26 +0200 Subject: [PATCH] progress bar, skips and more Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com> --- .vscode/settings.json | 3 + package.json | 1 + pnpm-lock.yaml | 144 +++++++++++++++- src/components/player/atoms/ProgressBar.tsx | 78 +++++++++ src/components/player/atoms/Skips.tsx | 27 +++ src/components/player/atoms/Time.tsx | 47 +++++ src/components/player/atoms/index.ts | 3 + src/components/player/base/BottomControls.tsx | 19 ++- src/components/player/display/base.ts | 49 ++++++ .../player/display/displayInterface.ts | 7 + .../player/internals/VideoContainer.tsx | 26 ++- src/components/player/utils/handleBuffered.ts | 8 + src/pages/PlayerView.tsx | 46 +++-- src/pages/parts/player/ScrapingPart.tsx | 160 ++++++++++++++++++ src/stores/player/slices/display.ts | 20 +++ src/stores/player/slices/interface.ts | 13 +- src/stores/player/slices/progress.ts | 8 +- src/utils/providers.ts | 28 +++ tailwind.config.js | 8 +- vite.config.ts | 4 + 20 files changed, 663 insertions(+), 36 deletions(-) create mode 100644 src/components/player/atoms/ProgressBar.tsx create mode 100644 src/components/player/atoms/Skips.tsx create mode 100644 src/components/player/atoms/Time.tsx create mode 100644 src/components/player/utils/handleBuffered.ts create mode 100644 src/pages/parts/player/ScrapingPart.tsx create mode 100644 src/utils/providers.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 279011fe..ef6a5b8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "eslint.format.enable": true, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "ms-vsliveshare.vsliveshare" } } diff --git a/package.json b/package.json index 9c4b3caf..0c5fb382 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@formkit/auto-animate": "^0.7.0", "@headlessui/react": "^1.5.0", + "@movie-web/providers": "^1.0.1", "@react-spring/web": "^9.7.1", "@sentry/integrations": "^7.49.0", "@sentry/react": "^7.49.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1c5672f..ba43a93d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@headlessui/react': specifier: ^1.5.0 version: 1.7.17(react-dom@17.0.2)(react@17.0.2) + '@movie-web/providers': + specifier: ^1.0.1 + version: 1.0.1 '@react-spring/web': specifier: ^9.7.1 version: 9.7.3(react-dom@17.0.2)(react@17.0.2) @@ -1826,6 +1829,19 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@movie-web/providers@1.0.1: + resolution: {integrity: sha512-7f3uQKhym+4F5rC5r+6qHjL8Rx3b8P9r1UJcENlkgULUEjX7I/w4B6FzdRlHnTig+DVwuUabNWHE+hzS/tQQPw==} + dependencies: + cheerio: 1.0.0-rc.12 + crypto-js: 4.1.1 + form-data: 4.0.0 + nanoid: 3.3.6 + node-fetch: 2.7.0 + unpacker: 1.0.1 + transitivePeerDependencies: + - encoding + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2634,7 +2650,6 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: true /at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} @@ -2718,6 +2733,10 @@ packages: engines: {node: '>=8'} dev: true + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -2818,6 +2837,30 @@ packages: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: false + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: false + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -2890,7 +2933,6 @@ packages: engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 - dev: true /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2964,6 +3006,16 @@ packages: hyphenate-style-name: 1.0.4 dev: false + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: false + /css-tree@1.1.3: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} @@ -2972,6 +3024,11 @@ packages: source-map: 0.6.1 dev: false + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3055,7 +3112,6 @@ packages: /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dev: true /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} @@ -3107,6 +3163,18 @@ packages: csstype: 3.1.2 dev: false + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + /domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} @@ -3114,10 +3182,25 @@ packages: webidl-conversions: 7.0.0 dev: true + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + /dompurify@3.0.5: resolution: {integrity: sha512-F9e6wPGtY+8KNMRAVfxeCOHU0/NPWMSENNq4pQctuXRqqdEPW7q3CrLbR5Nse044WwacyjHGOMlvNsBe1y6z9A==} dev: false + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -3145,7 +3228,6 @@ packages: /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - dev: true /error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -3719,7 +3801,6 @@ packages: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.35 - dev: true /fraction.js@4.3.5: resolution: {integrity: sha512-58DncB2bO/8ZvTHapG7U2KEbeFFyUbbrFFkHakecpdUSqJrQnEuBeTUPEggIVkx5cnugZJ4IVzk2Nbb32MOxBg==} @@ -3997,6 +4078,15 @@ packages: void-elements: 3.1.0 dev: false + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -4588,14 +4678,12 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - dev: true /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 - dev: true /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4673,7 +4761,6 @@ packages: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /nanoid@4.0.2: resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} @@ -4697,6 +4784,18 @@ packages: resolution: {integrity: sha512-F5kfEj95kX8tkDhUCYdV8dg3/8Olx/94zB8+ZNthFs6Bz31UpUi8Xh40TN3thLwXgrwXry1pEg9lJ++tLWTcqA==} dev: false + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: false + /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: true @@ -4718,6 +4817,12 @@ packages: path-key: 3.1.1 dev: true + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /nwsapi@2.2.7: resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} dev: true @@ -4851,11 +4956,17 @@ packages: callsites: 3.1.0 dev: true + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: false + /parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} dependencies: entities: 4.5.0 - dev: true /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -5910,6 +6021,10 @@ packages: url-parse: 1.5.10 dev: true + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: false + /tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} dependencies: @@ -6399,6 +6514,10 @@ packages: xml-name-validator: 4.0.0 dev: true + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: false + /webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} dev: true @@ -6428,6 +6547,13 @@ packages: webidl-conversions: 7.0.0 dev: true + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: false + /whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} dependencies: diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx new file mode 100644 index 00000000..f84843ab --- /dev/null +++ b/src/components/player/atoms/ProgressBar.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { useProgressBar } from "@/hooks/useProgressBar"; +import { usePlayerStore } from "@/stores/player/store"; + +export function ProgressBar() { + const { duration, time, buffered } = usePlayerStore((s) => s.progress); + const display = usePlayerStore((s) => s.display); + const setDraggingTime = usePlayerStore((s) => s.setDraggingTime); + const setSeeking = usePlayerStore((s) => s.setSeeking); + const { isSeeking } = usePlayerStore((s) => s.interface); + + const commitTime = useCallback( + (percentage) => { + display?.setTime(percentage * duration); + }, + [duration, display] + ); + + const ref = useRef<HTMLDivElement>(null); + + const { dragging, dragPercentage, dragMouseDown } = useProgressBar( + ref, + commitTime + ); + useEffect(() => { + setSeeking(dragging); + }, [setSeeking, dragging]); + + useEffect(() => { + setDraggingTime((dragPercentage / 100) * duration); + }, [setDraggingTime, duration, dragPercentage]); + + return ( + <div ref={ref}> + <div + className="group w-full h-8 flex items-center" + onMouseDown={dragMouseDown} + onTouchStart={dragMouseDown} + > + <div + className={[ + "relative w-full h-1 bg-video-progress-background bg-opacity-25 rounded-full transition-[height] duration-100 group-hover:h-1.5", + dragging ? "!h-1.5" : "", + ].join(" ")} + > + {/* Pre-loaded content bar */} + <div + className="absolute top-0 left-0 h-full rounded-full bg-video-progress-preloaded bg-opacity-25 flex justify-end items-center" + style={{ + width: `${(buffered / duration) * 100}%`, + }} + /> + + {/* Actual progress bar */} + <div + className="absolute top-0 left-0 h-full rounded-full bg-video-progress-watched flex justify-end items-center" + style={{ + width: `${ + Math.max( + 0, + Math.min(1, dragging ? dragPercentage / 100 : time / duration) + ) * 100 + }%`, + }} + > + <div + className={[ + "w-[1rem] min-w-[1rem] h-[1rem] rounded-full transform translate-x-1/2 scale-0 group-hover:scale-100 bg-white transition-[transform] duration-100", + isSeeking ? "scale-100" : "", + ].join(" ")} + /> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/player/atoms/Skips.tsx b/src/components/player/atoms/Skips.tsx new file mode 100644 index 00000000..82261a5b --- /dev/null +++ b/src/components/player/atoms/Skips.tsx @@ -0,0 +1,27 @@ +import { useCallback } from "react"; + +import { Icons } from "@/components/Icon"; +import { VideoPlayerButton } from "@/components/player/internals/Button"; +import { usePlayerStore } from "@/stores/player/store"; + +export function SkipForward() { + const display = usePlayerStore((s) => s.display); + const time = usePlayerStore((s) => s.progress.time); + + const commit = useCallback(() => { + display?.setTime(time + 10); + }, [display, time]); + + return <VideoPlayerButton onClick={commit} icon={Icons.SKIP_FORWARD} />; +} + +export function SkipBackward() { + const display = usePlayerStore((s) => s.display); + const time = usePlayerStore((s) => s.progress.time); + + const commit = useCallback(() => { + display?.setTime(time - 10); + }, [display, time]); + + return <VideoPlayerButton onClick={commit} icon={Icons.SKIP_BACKWARD} />; +} diff --git a/src/components/player/atoms/Time.tsx b/src/components/player/atoms/Time.tsx new file mode 100644 index 00000000..ac266bb1 --- /dev/null +++ b/src/components/player/atoms/Time.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { VideoPlayerButton } from "@/components/player/internals/Button"; +import { usePlayerStore } from "@/stores/player/store"; +import { formatSeconds } from "@/utils/formatSeconds"; + +export function Time() { + const [timeMode, setTimeMode] = useState(true); + + const { duration, time, draggingTime } = usePlayerStore((s) => s.progress); + const { isSeeking } = usePlayerStore((s) => s.interface); + const { t } = useTranslation(); + + function toggleMode() { + setTimeMode(!timeMode); + } + + const currentTime = Math.min( + Math.max(isSeeking ? draggingTime : time, 0), + duration + ); + const secondsRemaining = Math.abs(currentTime - duration); + const timeFinished = new Date(Date.now() + secondsRemaining * 1e3); + + const formattedTimeFinished = t("videoPlayer.finishAt", { + timeFinished, + formatParams: { + timeFinished: { hour: "numeric", minute: "numeric" }, + }, + }); + + const child = timeMode ? ( + <> + {formatSeconds(currentTime)} <span>/ {formatSeconds(duration)}</span> + </> + ) : ( + <> + {t("videoPlayer.timeLeft", { timeLeft: formatSeconds(secondsRemaining) })}{" "} + • {formattedTimeFinished} + </> + ); + + return ( + <VideoPlayerButton onClick={() => toggleMode()}>{child}</VideoPlayerButton> + ); +} diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index 5fedb0ef..f59c0183 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -1,2 +1,5 @@ export * from "./Pause"; export * from "./Fullscreen"; +export * from "./ProgressBar"; +export * from "./Skips"; +export * from "./Time"; diff --git a/src/components/player/base/BottomControls.tsx b/src/components/player/base/BottomControls.tsx index 21af9c5d..2a0430f7 100644 --- a/src/components/player/base/BottomControls.tsx +++ b/src/components/player/base/BottomControls.tsx @@ -1,15 +1,26 @@ import { Transition } from "@/components/Transition"; +import { PlayerHoverState } from "@/stores/player/slices/interface"; +import { usePlayerStore } from "@/stores/player/store"; export function BottomControls(props: { - show: boolean; + show?: boolean; children: React.ReactNode; }) { + const { hovering } = usePlayerStore((s) => s.interface); + const visible = + (hovering !== PlayerHoverState.NOT_HOVERING || props.show) ?? false; + return ( - <div className="w-full absolute bottom-0 flex flex-col pt-32 bg-gradient-to-t from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)]"> + <div className="w-full text-white"> + <Transition + animation="fade" + show={visible} + className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute bottom-0 w-full" + /> <Transition animation="slide-up" - show={props.show} - className="pointer-events-auto px-4 pb-2 flex justify-end" + show={visible} + className="pointer-events-auto px-4 pb-3 absolute bottom-0 w-full" > {props.children} </Transition> diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 8acdf1d1..90371493 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -5,7 +5,9 @@ import { DisplayInterfaceEvents, } from "@/components/player/display/displayInterface"; import { Source } from "@/components/player/hooks/usePlayer"; +import { handleBuffered } from "@/components/player/utils/handleBuffered"; import { + canChangeVolume, canFullscreen, canFullscreenAnyElement, canWebkitFullscreen, @@ -18,12 +20,29 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { let videoElement: HTMLVideoElement | null = null; let containerElement: HTMLElement | null = null; let isFullscreen = false; + let isPausedBeforeSeeking = false; function setSource() { if (!videoElement || !source) return; videoElement.src = source.url; videoElement.addEventListener("play", () => emit("play", undefined)); videoElement.addEventListener("pause", () => emit("pause", undefined)); + videoElement.addEventListener("volumechange", () => + emit("volumechange", videoElement?.volume ?? 0) + ); + videoElement.addEventListener("timeupdate", () => + emit("time", videoElement?.currentTime ?? 0) + ); + videoElement.addEventListener("loadedmetadata", () => { + emit("duration", videoElement?.duration ?? 0); + }); + videoElement.addEventListener("progress", () => { + if (videoElement) + emit( + "buffered", + handleBuffered(videoElement.currentTime, videoElement.buffered) + ); + }); } function fullscreenChange() { @@ -58,6 +77,36 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { play() { videoElement?.play(); }, + setSeeking(active) { + // if it was playing when starting to seek, play again + if (!active) { + if (!isPausedBeforeSeeking) this.play(); + return; + } + + isPausedBeforeSeeking = videoElement?.paused ?? true; + this.pause(); + }, + setTime(t) { + if (!videoElement) return; + // clamp time between 0 and max duration + let time = Math.min(t, videoElement.duration); + time = Math.max(0, time); + + if (Number.isNaN(time)) return; + emit("time", time); + videoElement.currentTime = time; + }, + async setVolume(v) { + if (!videoElement) return; + + // clamp time between 0 and 1 + let volume = Math.min(v, 1); + volume = Math.max(0, volume); + + // update state + if (await canChangeVolume()) videoElement.volume = volume; + }, toggleFullscreen() { if (isFullscreen) { isFullscreen = false; diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 60e75a4d..6d5e1417 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -5,6 +5,10 @@ export type DisplayInterfaceEvents = { play: void; pause: void; fullscreen: boolean; + volumechange: number; + time: number; + duration: number; + buffered: number; }; export interface DisplayInterface extends Listener<DisplayInterfaceEvents> { @@ -14,5 +18,8 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> { processVideoElement(video: HTMLVideoElement): void; processContainerElement(container: HTMLElement): void; toggleFullscreen(): void; + setSeeking(active: boolean): void; + setVolume(vol: number): void; + setTime(t: number): void; destroy(): void; } diff --git a/src/components/player/internals/VideoContainer.tsx b/src/components/player/internals/VideoContainer.tsx index 04c0331e..358a1c1d 100644 --- a/src/components/player/internals/VideoContainer.tsx +++ b/src/components/player/internals/VideoContainer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { PointerEvent, useCallback, useEffect, useRef } from "react"; import { makeVideoElementDisplayInterface } from "@/components/player/display/base"; import { playerStatus } from "@/stores/player/slices/source"; @@ -26,6 +26,20 @@ function useShouldShowVideoElement() { function VideoElement() { const videoEl = useRef<HTMLVideoElement>(null); const display = usePlayerStore((s) => s.display); + const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); + + const toggleFullscreen = useCallback(() => { + display?.toggleFullscreen(); + }, [display]); + + const togglePause = useCallback( + (e: PointerEvent<HTMLVideoElement>) => { + if (e.pointerType !== "mouse") return; + if (isPaused) display?.play(); + else display?.pause(); + }, + [display, isPaused] + ); // report video element to display interface useEffect(() => { @@ -34,7 +48,15 @@ function VideoElement() { } }, [display, videoEl]); - return <video className="w-full h-screen" autoPlay ref={videoEl} />; + return ( + <video + className="w-full h-screen bg-black" + autoPlay + ref={videoEl} + onDoubleClick={toggleFullscreen} + onPointerUp={togglePause} + /> + ); } export function VideoContainer() { diff --git a/src/components/player/utils/handleBuffered.ts b/src/components/player/utils/handleBuffered.ts new file mode 100644 index 00000000..ee19ae6a --- /dev/null +++ b/src/components/player/utils/handleBuffered.ts @@ -0,0 +1,8 @@ +export function handleBuffered(time: number, buffered: TimeRanges): number { + for (let i = 0; i < buffered.length; i += 1) { + if (buffered.start(buffered.length - 1 - i) < time) { + return buffered.end(buffered.length - 1 - i); + } + } + return 0; +} diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 6622dce3..3b5f8b34 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -1,38 +1,50 @@ +import { useCallback } from "react"; + import { MWStreamType } from "@/backend/helpers/streams"; import { Player } from "@/components/player"; import { usePlayer } from "@/components/player/hooks/usePlayer"; -import { PlayerHoverState } from "@/stores/player/slices/interface"; +import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { playerStatus } from "@/stores/player/slices/source"; -import { usePlayerStore } from "@/stores/player/store"; export function PlayerView() { const { status, playMedia, setScrapeStatus } = usePlayer(); - const hovering = usePlayerStore((s) => s.interface.hovering); - function scrape() { + const startStream = useCallback(() => { playMedia({ type: MWStreamType.MP4, // url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - url: "http://95.111.247.180/darude.mp4", + // url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4", + url: "http://95.111.247.180/frog.mp4", }); - } - - const showControlElements = hovering !== PlayerHoverState.NOT_HOVERING; + }, [playMedia]); return ( <Player.Container onLoad={setScrapeStatus}> - <Player.BottomControls show={showControlElements}> - <Player.Pause /> - <Player.Fullscreen /> + <Player.BottomControls> + <Player.ProgressBar /> + <div className="flex justify-between"> + <div className="flex space-x-3 items-center"> + <Player.Pause /> + <Player.SkipBackward /> + <Player.SkipForward /> + <Player.Time /> + </div> + <div> + <Player.Fullscreen /> + </div> + </div> </Player.BottomControls> {status === playerStatus.SCRAPING ? ( - <div className="w-full h-screen"> - <p>Its now scraping</p> - <button type="button" onClick={scrape}> - Finish scraping - </button> - </div> + <ScrapingPart + onGetStream={startStream} + media={{ + type: "movie", + title: "Hamilton", + tmdbId: "556574", + releaseYear: 2020, + }} + /> ) : null} </Player.Container> ); diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx new file mode 100644 index 00000000..273dd9d4 --- /dev/null +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -0,0 +1,160 @@ +import { ScrapeMedia } from "@movie-web/providers"; +import { useCallback, useState } from "react"; + +import { providers } from "@/utils/providers"; + +export interface ScrapingProps { + media: ScrapeMedia; + onGetStream?: () => void; +} + +export interface ScrapingSegment { + name: string; + id: string; + status: "failure" | "pending" | "notfound" | "success" | "waiting"; + reason?: string; + percentage: number; +} + +export interface ScrapingItems { + id: string; + children: string[]; +} + +function useScrape() { + const [sources, setSources] = useState<Record<string, ScrapingSegment>>({}); + const [sourceOrder, setSourceOrder] = useState<ScrapingItems[]>([]); + + const startScraping = useCallback( + async (media: ScrapeMedia) => { + if (!providers) return; + const output = await providers.runAll({ + media, + events: { + init(evt) { + console.log("init", evt); + setSources( + evt.sourceIds + .map((v) => { + const source = providers.getMetadata(v); + if (!source) throw new Error("invalid source id"); + const out: ScrapingSegment = { + name: source.name, + id: source.id, + status: "waiting", + percentage: 0, + }; + return out; + }) + .reduce<Record<string, ScrapingSegment>>((a, v) => { + a[v.id] = v; + return a; + }, {}) + ); + setSourceOrder(evt.sourceIds.map((v) => ({ id: v, children: [] }))); + }, + start(id) { + console.log("start", id); + setSources((s) => { + if (s[id]) s[id].status = "pending"; + return { ...s }; + }); + }, + update(evt) { + console.log("update", evt); + setSources((s) => { + if (s[evt.id]) { + s[evt.id].status = evt.status; + s[evt.id].reason = evt.reason; + s[evt.id].percentage = evt.percentage; + } + return { ...s }; + }); + }, + discoverEmbeds(evt) { + console.log("discoverEmbeds", evt); + setSources((s) => { + evt.embeds.forEach((v) => { + const source = providers.getMetadata(v.embedScraperId); + if (!source) throw new Error("invalid source id"); + const out: ScrapingSegment = { + name: source.name, + id: v.id, + status: "waiting", + percentage: 0, + }; + s[v.id] = out; + }); + return { ...s }; + }); + setSourceOrder((s) => { + const source = s.find((v) => v.id === evt.sourceId); + if (!source) throw new Error("invalid source id"); + source.children = evt.embeds.map((v) => v.id); + return [...s]; + }); + }, + }, + }); + + console.log(output); + return output; + }, + [setSourceOrder, setSources] + ); + + return { + startScraping, + sourceOrder, + sources, + }; +} + +export function ScrapingPart(props: ScrapingProps) { + const { startScraping, sourceOrder, sources } = useScrape(); + + return ( + <div> + {sourceOrder.map((order) => { + const source = sources[order.id]; + if (!source) return null; + return ( + <div key={order.id}> + <p className="font-bold text-white">{source.name}</p> + <p> + status: {source.status} ({source.percentage}%) + </p> + <p>reason: {source.reason}</p> + {order.children.map((embedId) => { + const embed = sources[embedId]; + if (!embed) return null; + return ( + <div key={embedId} className="border border-blue-300 rounded"> + <p className="font-bold text-white">{embed.name}</p> + <p> + status: {embed.status} ({embed.percentage}%) + </p> + <p>reason: {embed.reason}</p> + </div> + ); + })} + </div> + ); + })} + <button + type="button" + onClick={() => startScraping(props.media)} + className="block" + > + Start scraping + </button> + <button + type="button" + onClick={() => props.onGetStream?.()} + className="block" + > + Finish scraping + </button> + </div> + ); +} diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts index 7be20b74..c1bbefb9 100644 --- a/src/stores/player/slices/display.ts +++ b/src/stores/player/slices/display.ts @@ -30,6 +30,26 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({ s.interface.isFullscreen = isFullscreen; }) ); + newDisplay.on("time", (time) => + set((s) => { + s.progress.time = time; + }) + ); + newDisplay.on("volumechange", (vol) => + set((s) => { + s.mediaPlaying.volume = vol; + }) + ); + newDisplay.on("duration", (duration) => + set((s) => { + s.progress.duration = duration; + }) + ); + newDisplay.on("buffered", (buffered) => + set((s) => { + s.progress.buffered = buffered; + }) + ); set((s) => { s.display = newDisplay; diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts index 38e41a2c..533a2e69 100644 --- a/src/stores/player/slices/interface.ts +++ b/src/stores/player/slices/interface.ts @@ -14,6 +14,7 @@ export enum PlayerHoverState { export interface InterfaceSlice { interface: { isFullscreen: boolean; + isSeeking: boolean; hovering: PlayerHoverState; volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently? @@ -23,11 +24,13 @@ export interface InterfaceSlice { timeFormat: VideoPlayerTimeFormat; // Time format of the video player }; updateInterfaceHovering(newState: PlayerHoverState): void; + setSeeking(seeking: boolean): void; } -export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set) => ({ +export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({ interface: { isFullscreen: false, + isSeeking: false, leftControlHovering: false, hovering: PlayerHoverState.NOT_HOVERING, volumeChangedWithKeybind: false, @@ -37,8 +40,14 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set) => ({ updateInterfaceHovering(newState: PlayerHoverState) { set((s) => { - console.log("setting", newState); s.interface.hovering = newState; }); }, + setSeeking(seeking) { + const display = get().display; + display?.setSeeking(seeking); + set((s) => { + s.interface.isSeeking = seeking; + }); + }, }); diff --git a/src/stores/player/slices/progress.ts b/src/stores/player/slices/progress.ts index 4be4fc1d..06ae6a87 100644 --- a/src/stores/player/slices/progress.ts +++ b/src/stores/player/slices/progress.ts @@ -7,13 +7,19 @@ export interface ProgressSlice { buffered: number; // how much is buffered draggingTime: number; // when dragging, time thats at the cursor }; + setDraggingTime(draggingTime: number): void; } -export const createProgressSlice: MakeSlice<ProgressSlice> = () => ({ +export const createProgressSlice: MakeSlice<ProgressSlice> = (set) => ({ progress: { time: 0, duration: 0, buffered: 0, draggingTime: 0, }, + setDraggingTime(draggingTime: number) { + set((s) => { + s.progress.draggingTime = draggingTime; + }); + }, }); diff --git a/src/utils/providers.ts b/src/utils/providers.ts new file mode 100644 index 00000000..d276326b --- /dev/null +++ b/src/utils/providers.ts @@ -0,0 +1,28 @@ +import { + ProviderBuilderOptions, + ProviderControls, + makeProviders, + makeSimpleProxyFetcher, + makeStandardFetcher, + targets, +} from "@movie-web/providers"; + +import { conf } from "@/setup/config"; + +const urls = conf().PROXY_URLS; +const fetchers = urls.map((v) => makeSimpleProxyFetcher(v, fetch)); +let fetchersIndex = Math.floor(Math.random() * fetchers.length); + +function makeLoadBalancedSimpleProxyFetcher() { + const fetcher: ProviderBuilderOptions["fetcher"] = (a, b) => { + fetchersIndex += 1 % fetchers.length; + return fetchers[fetchersIndex](a, b); + }; + return fetcher; +} + +export const providers = makeProviders({ + fetcher: makeStandardFetcher(fetch), + proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(), + target: targets.BROWSER, +}) as any as ProviderControls; diff --git a/tailwind.config.js b/tailwind.config.js index d12c6083..6fd3461d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -104,7 +104,13 @@ module.exports = { // video player video: { - buttonBackground: "#444B5C" + buttonBackground: "#444B5C", + + progress: { + background: "#8787A8", + preloaded: "#8787A8", + watched: "#A75FC9" + } } } } diff --git a/vite.config.ts b/vite.config.ts index ce5746ad..878239ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -84,6 +84,9 @@ export default defineConfig(({ mode }) => { }), loadVersion(), checker({ + overlay: { + position: "tr", + }, typescript: true, // check typescript build errors in dev server eslint: { // check lint errors in dev server @@ -94,6 +97,7 @@ export default defineConfig(({ mode }) => { }, }), ], + resolve: { alias: { "@": path.resolve(__dirname, "./src"),