From 860671be0088ae4714fa8f36cd28ea70a61e5f41 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 1 Oct 2023 21:08:26 +0200 Subject: [PATCH] progress bar, skips and more Co-authored-by: Jip Frijlink --- .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(null); + + const { dragging, dragPercentage, dragMouseDown } = useProgressBar( + ref, + commitTime + ); + useEffect(() => { + setSeeking(dragging); + }, [setSeeking, dragging]); + + useEffect(() => { + setDraggingTime((dragPercentage / 100) * duration); + }, [setDraggingTime, duration, dragPercentage]); + + return ( +
+
+
+ {/* Pre-loaded content bar */} +
+ + {/* Actual progress bar */} +
+
+
+
+
+
+ ); +} 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 ; +} + +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 ; +} 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)} / {formatSeconds(duration)} + + ) : ( + <> + {t("videoPlayer.timeLeft", { timeLeft: formatSeconds(secondsRemaining) })}{" "} + • {formattedTimeFinished} + + ); + + return ( + toggleMode()}>{child} + ); +} 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 ( -
+
+ {props.children} 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 { @@ -14,5 +18,8 @@ export interface DisplayInterface extends Listener { 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(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) => { + 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