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