progress bar, skips and more

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-01 21:08:26 +02:00
parent 7e182a4b7a
commit 860671be00
20 changed files with 663 additions and 36 deletions

View File

@ -4,5 +4,8 @@
"eslint.format.enable": true,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "ms-vsliveshare.vsliveshare"
}
}

View File

@ -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",

View File

@ -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:

View File

@ -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>
);
}

View File

@ -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} />;
}

View File

@ -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>
);
}

View File

@ -1,2 +1,5 @@
export * from "./Pause";
export * from "./Fullscreen";
export * from "./ProgressBar";
export * from "./Skips";
export * from "./Time";

View File

@ -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>

View File

@ -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;

View File

@ -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;
}

View File

@ -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() {

View File

@ -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;
}

View File

@ -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.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>
);

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;
});
},
});

View File

@ -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;
});
},
});

28
src/utils/providers.ts Normal file
View File

@ -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;

View File

@ -104,7 +104,13 @@ module.exports = {
// video player
video: {
buttonBackground: "#444B5C"
buttonBackground: "#444B5C",
progress: {
background: "#8787A8",
preloaded: "#8787A8",
watched: "#A75FC9"
}
}
}
}

View File

@ -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"),