From 8593d76984ffa0b07bd25a9c7d0f0dde0c82b132 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 4 Feb 2024 20:41:56 +0100 Subject: [PATCH 001/442] chore: init providers package --- README.md | 36 +----- packages/provider-utils/package.json | 34 ++++++ packages/provider-utils/src/index.ts | 1 + packages/provider-utils/tsconfig.json | 8 ++ pnpm-lock.yaml | 164 ++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 packages/provider-utils/package.json create mode 100644 packages/provider-utils/src/index.ts create mode 100644 packages/provider-utils/tsconfig.json diff --git a/README.md b/README.md index d512e09..feb5514 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## About -It uses [Turborepo](https://turborepo.org) and contains: +This repository uses [Turborepo](https://turborepo.org) and contains: ```text .github @@ -19,7 +19,9 @@ apps └─ Typesafe API calls using tRPC packages ├─ tmdb - └─ Typesafe API calls to The Movie Database + | └─ Typesafe API calls to The Movie Database + └─ providers + └─ Typesafe API calls to the video providers tooling ├─ eslint | └─ shared, fine-grained, eslint presets @@ -31,35 +33,9 @@ tooling └─ shared tsconfig you can extend from ``` -### Configure Expo `dev`-script +## Getting started -#### Use iOS Simulator - -1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator). - - > **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once. Run `npx expo start` in the root dir, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory. - - ```diff - + "dev": "expo start --ios", - ``` - -2. Run `pnpm dev` at the project root folder. - -#### Use Android Emulator - -1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator). - -2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator. - - ```diff - + "dev": "expo start --android", - ``` - -3. Run `pnpm dev` at the project root folder. - -> **TIP:** It might be easier to run each app in separate terminal windows so you get the logs from each app separately. This is also required if you want your terminals to be interactive, e.g. to access the Expo QR code. You can run `pnpm --filter expo dev` and `pnpm --filter nextjs dev` to run each app in a separate terminal window. - -### 3. When it's time to add a new package +### When it's time to add a new package To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later). diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json new file mode 100644 index 0000000..533f185 --- /dev/null +++ b/packages/provider-utils/package.json @@ -0,0 +1,34 @@ +{ + "name": "@movie-web/provider-utils", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@movie-web/eslint-config": "workspace:^0.2.0", + "@movie-web/prettier-config": "workspace:^0.1.0", + "@movie-web/tsconfig": "workspace:^0.1.0", + "eslint": "^8.56.0", + "prettier": "^3.1.1", + "typescript": "^5.3.3" + }, + "eslintConfig": { + "extends": [ + "@movie-web/eslint-config/base" + ] + }, + "prettier": "@movie-web/prettier-config", + "dependencies": { + "@movie-web/providers": "^2.1.1" + } +} diff --git a/packages/provider-utils/src/index.ts b/packages/provider-utils/src/index.ts new file mode 100644 index 0000000..bb356a3 --- /dev/null +++ b/packages/provider-utils/src/index.ts @@ -0,0 +1 @@ +export const name = "provider-utils"; diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json new file mode 100644 index 0000000..12305a4 --- /dev/null +++ b/packages/provider-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@movie-web/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0b255e..2c15a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,31 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/provider-utils: + dependencies: + '@movie-web/providers': + specifier: ^2.1.1 + version: 2.1.1 + devDependencies: + '@movie-web/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@movie-web/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@movie-web/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^8.56.0 + version: 8.56.0 + prettier: + specifier: ^3.1.1 + version: 3.2.4 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages/tmdb: dependencies: tmdb-ts: @@ -2283,6 +2308,20 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@movie-web/providers@2.1.1: + resolution: {integrity: sha512-g2CA/w3YlGw3b3v6yDSgUIUdym4rFs4CzZOo/OlyL4HtsFH9mk182ukt7HYSxgddCEJRjl81LZZc3/pLRIGcMA==} + dependencies: + cheerio: 1.0.0-rc.12 + crypto-js: 4.2.0 + form-data: 4.0.0 + iso-639-1: 3.1.0 + nanoid: 3.3.7 + 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'} @@ -3828,6 +3867,10 @@ packages: resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} dev: false + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: false + /bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} dependencies: @@ -4056,6 +4099,30 @@ packages: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} dev: false + /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'} @@ -4393,6 +4460,10 @@ packages: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} dev: false + /crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + dev: false + /crypto-random-string@1.0.0: resolution: {integrity: sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg==} engines: {node: '>=4'} @@ -4409,6 +4480,21 @@ 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-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'} @@ -4637,6 +4723,33 @@ packages: dependencies: esutils: 2.0.3 + /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 + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + 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 + /dot-case@2.1.1: resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} dependencies: @@ -4680,6 +4793,11 @@ packages: once: 1.4.0 dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /env-editor@0.4.2: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} @@ -5515,6 +5633,15 @@ packages: mime-types: 2.1.35 dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: false @@ -5896,6 +6023,15 @@ packages: lru-cache: 6.0.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-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -6387,6 +6523,11 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /iso-639-1@3.1.0: + resolution: {integrity: sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==} + engines: {node: '>=6.0'} + dev: false + /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} @@ -7586,6 +7727,12 @@ packages: dependencies: path-key: 3.1.1 + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: false + /nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} dev: false @@ -7902,6 +8049,19 @@ packages: pngjs: 3.4.0 dev: false + /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: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -9885,6 +10045,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + /unpacker@1.0.1: + resolution: {integrity: sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==} + dev: false + /unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} From d42b5fbb457973375be6a7c5963077760bc894df Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 4 Feb 2024 21:05:12 +0100 Subject: [PATCH 002/442] chore: add react-native-video dependency --- apps/expo/package.json | 1 + pnpm-lock.yaml | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/expo/package.json b/apps/expo/package.json index 215186c..1e9ba39 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -38,6 +38,7 @@ "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "~4.8.2", "react-native-screens": "~3.29.0", + "react-native-video": "6.0.0-beta.5", "react-native-web": "^0.19.10", "tailwind-merge": "^2.2.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c15a93..59e37b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: react-native-screens: specifier: ~3.29.0 version: 3.29.0(react-native@0.73.2)(react@18.2.0) + react-native-video: + specifier: 6.0.0-beta.5 + version: 6.0.0-beta.5(react-native@0.73.2)(react@18.2.0) react-native-web: specifier: ^0.19.10 version: 0.19.10(react-dom@18.2.0)(react@18.2.0) @@ -8618,6 +8621,16 @@ packages: warn-once: 0.1.1 dev: false + /react-native-video@6.0.0-beta.5(react-native@0.73.2)(react@18.2.0): + resolution: {integrity: sha512-dAfIXvtxsMI8TE3Q+1MHTP1brq3/V2VsPKVDtU8E+JcF963y5upnBb8JFiG8Yl4s4qAoQum2P02fZE30stQOHg==} + peerDependencies: + react: '*' + react-native: '*' + dependencies: + react: 18.2.0 + react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) + dev: false + /react-native-web@0.19.10(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-IQoHiTQq8egBCVVwmTrYcFLgEFyb4LMZYEktHn4k22JMk9+QTCEz5WTfvr+jdNoeqj/7rtE81xgowKbfGO74qg==} peerDependencies: From 1bf1b8898f4b50869bf213c9e5df097f0aee103d Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 4 Feb 2024 21:07:20 +0100 Subject: [PATCH 003/442] chore: adjust readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index feb5514..a606760 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ apps packages ├─ tmdb | └─ Typesafe API calls to The Movie Database - └─ providers + └─ provider-utils └─ Typesafe API calls to the video providers tooling ├─ eslint From 0728ab6b495c710c94e04b0fd8828981bc6c9dfe Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 4 Feb 2024 22:34:29 +0100 Subject: [PATCH 004/442] chore: update handlebars --- turbo/generators/templates/package.json.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index 2e143b0..706657f 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -3,6 +3,7 @@ "private": true, "version": "0.1.0", "type": "module", + "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }, From 55be0860b980e97fde30b058afc88929f697e09c Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 4 Feb 2024 22:45:30 +0100 Subject: [PATCH 005/442] feat: add video player --- apps/expo/index.js | 1 + apps/expo/package.json | 2 +- apps/expo/src/app/components/item/item.tsx | 15 +++- apps/expo/src/app/video-player.tsx | 82 ++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 apps/expo/index.js create mode 100644 apps/expo/src/app/video-player.tsx diff --git a/apps/expo/index.js b/apps/expo/index.js new file mode 100644 index 0000000..63e531c --- /dev/null +++ b/apps/expo/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; \ No newline at end of file diff --git a/apps/expo/package.json b/apps/expo/package.json index 1e9ba39..61f1a05 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -2,7 +2,7 @@ "name": "@movie-web/mobile", "version": "0.1.0", "private": true, - "main": "expo-router/entry", + "main": "index.js", "scripts": { "clean": "git clean -xdf .expo .turbo node_modules", "dev": "expo start", diff --git a/apps/expo/src/app/components/item/item.tsx b/apps/expo/src/app/components/item/item.tsx index f146621..e7125a3 100644 --- a/apps/expo/src/app/components/item/item.tsx +++ b/apps/expo/src/app/components/item/item.tsx @@ -1,4 +1,5 @@ -import { Image, View } from "react-native"; +import { useRouter } from "expo-router"; +import { Image, View, TouchableOpacity } from "react-native"; import { Text } from "~/components/ui/Text"; @@ -11,10 +12,18 @@ export interface ItemData { } export default function Item({ data }: { data: ItemData }) { + const router = useRouter(); const { title, type, year, posterUrl } = data; + const handlePress = () => { + console.log('Item pressed. Opening VideoPlayer...'); + router.push('/video-player'); + }; + return ( - + + { + {year} + } + ); } diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx new file mode 100644 index 0000000..d92ccd0 --- /dev/null +++ b/apps/expo/src/app/video-player.tsx @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import { StyleSheet, View, ActivityIndicator } from 'react-native'; +import type { VideoRef } from 'react-native-video'; +import Video from 'react-native-video'; + +interface VideoPlayerState { + videoUrl: string; + fullscreen: boolean; + isLoading: boolean; + paused: boolean; +} + +class VideoPlayer extends Component { + private videoPlayer: React.RefObject; + + constructor(props: object) { + super(props); + this.state = { + videoUrl: 'your_video_url', + fullscreen: true, + isLoading: true, + paused: false + }; + this.videoPlayer = React.createRef(); + } + + componentDidMount() { + if (this.videoPlayer.current) { + this.videoPlayer.current.presentFullscreenPlayer(); + } + } + + onVideoLoadStart = () => { + this.setState({ isLoading: true }); + }; + + onReadyForDisplay = () => { + this.setState({ isLoading: false }); + }; + + onVideoError = () => { + console.log("Video playback error"); + }; + + render() { + return ( + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'black', + }, + fullScreen: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + } +}); + +export default VideoPlayer; \ No newline at end of file From 8e03075ebcff81ce41dc5ecd0f2298cfa60f941c Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:32:53 +0100 Subject: [PATCH 006/442] feat: video player orientation --- apps/expo/app.config.ts | 10 +++++-- apps/expo/package.json | 1 + apps/expo/src/app/video-player.tsx | 44 ++++++++++++++++++++++-------- pnpm-lock.yaml | 11 ++++++++ 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 223d040..9fd8780 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -5,7 +5,6 @@ const defineConfig = (): ExpoConfig => ({ slug: "mw-mobile", scheme: "dev.movieweb.app", version: "0.1.0", - orientation: "portrait", icon: "./assets/images/icon.png", userInterfaceStyle: "automatic", splash: { @@ -20,6 +19,7 @@ const defineConfig = (): ExpoConfig => ({ ios: { bundleIdentifier: "dev.movieweb.app", supportsTablet: true, + requireFullScreen: true, }, android: { package: "dev.movieweb.app", @@ -41,7 +41,13 @@ const defineConfig = (): ExpoConfig => ({ tsconfigPaths: true, typedRoutes: true, }, - plugins: ["expo-router"], + plugins: ["expo-router", [ + "expo-screen-orientation", + { + initialOrientation: "DEFAULT" + } + ] +], }); export default defineConfig; diff --git a/apps/expo/package.json b/apps/expo/package.json index 61f1a05..b5f3093 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -26,6 +26,7 @@ "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", "expo-router": "~3.4.6", + "expo-screen-orientation": "~6.4.1", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "expo-web-browser": "^12.8.2", diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index d92ccd0..d6343d6 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -1,7 +1,9 @@ import React, { Component } from 'react'; -import { StyleSheet, View, ActivityIndicator } from 'react-native'; +import { StyleSheet, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import type { VideoRef } from 'react-native-video'; import Video from 'react-native-video'; +import * as ScreenOrientation from 'expo-screen-orientation'; interface VideoPlayerState { videoUrl: string; @@ -16,7 +18,7 @@ class VideoPlayer extends Component { constructor(props: object) { super(props); this.state = { - videoUrl: 'your_video_url', + videoUrl: '', fullscreen: true, isLoading: true, paused: false @@ -25,9 +27,25 @@ class VideoPlayer extends Component { } componentDidMount() { - if (this.videoPlayer.current) { - this.videoPlayer.current.presentFullscreenPlayer(); - } + const lockOrientation = async () => { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + }; + + if (this.videoPlayer.current) { + this.videoPlayer.current.presentFullscreenPlayer(); + void lockOrientation(); + } + } + + componentWillUnmount() { + const unlockOrientation = async () => { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + } + + if (this.videoPlayer.current) { + this.videoPlayer.current.dismissFullscreenPlayer(); + } + void unlockOrientation(); } onVideoLoadStart = () => { @@ -38,27 +56,28 @@ class VideoPlayer extends Component { this.setState({ isLoading: false }); }; - onVideoError = () => { - console.log("Video playback error"); - }; +// onVideoError = () => { // probably useful later +// console.log("Video playback error"); +// }; render() { return ( - + + ); } } @@ -76,7 +95,8 @@ const styles = StyleSheet.create({ left: 0, bottom: 0, right: 0, - } + }, + }); export default VideoPlayer; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59e37b0..9775945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: expo-router: specifier: ~3.4.6 version: 3.4.6(expo-constants@15.4.5)(expo-linking@6.2.2)(expo-modules-autolinking@1.10.2)(expo-status-bar@1.11.1)(expo@50.0.5)(react-dom@18.2.0)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-screens@3.29.0)(react-native@0.73.2)(react@18.2.0) + expo-screen-orientation: + specifier: ~6.4.1 + version: 6.4.1(expo@50.0.5) expo-splash-screen: specifier: ~0.26.4 version: 0.26.4(expo-modules-autolinking@1.10.2)(expo@50.0.5) @@ -5378,6 +5381,14 @@ packages: - supports-color dev: false + /expo-screen-orientation@6.4.1(expo@50.0.5): + resolution: {integrity: sha512-VM0C9ORNL1aT6Dr2OUeryzV519n0FjtXI2m+HlijOMi1QT2bPg4tBkCd7HLgywU4dZ1Esa46ewUudmk+fOqmMQ==} + peerDependencies: + expo: '*' + dependencies: + expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + dev: false + /expo-splash-screen@0.26.4(expo-modules-autolinking@1.10.2)(expo@50.0.5): resolution: {integrity: sha512-2DwofTQ0FFQCsvDysm/msENsbyNsJiAJwK3qK/oXeizECAPqD7bK19J4z9kuEbr7ORPX9MLnTQYKl6kmX3keUg==} peerDependencies: From 667bf4ab13e1d714404b8ced995bae9ea00349fc Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:33:44 +0100 Subject: [PATCH 007/442] feat: pass video url to player --- apps/expo/src/app/components/item/item.tsx | 5 +++- apps/expo/src/app/video-player.tsx | 33 +++++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/expo/src/app/components/item/item.tsx b/apps/expo/src/app/components/item/item.tsx index e7125a3..a13ea8f 100644 --- a/apps/expo/src/app/components/item/item.tsx +++ b/apps/expo/src/app/components/item/item.tsx @@ -16,8 +16,11 @@ export default function Item({ data }: { data: ItemData }) { const { title, type, year, posterUrl } = data; const handlePress = () => { - console.log('Item pressed. Opening VideoPlayer...'); router.push('/video-player'); + // router.push({ + // pathname: '/video-player', + // params: { videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4' } + // }); }; return ( diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index d6343d6..afca0a4 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -4,6 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import type { VideoRef } from 'react-native-video'; import Video from 'react-native-video'; import * as ScreenOrientation from 'expo-screen-orientation'; +import { useLocalSearchParams } from "expo-router"; interface VideoPlayerState { videoUrl: string; @@ -12,13 +13,23 @@ interface VideoPlayerState { paused: boolean; } -class VideoPlayer extends Component { - private videoPlayer: React.RefObject; +interface VideoPlayerProps { + videoUrl: string; +} - constructor(props: object) { +export default function VideoPlayerWrapper() { + const params = useLocalSearchParams(); + const videoUrl = typeof params.videoUrl === 'string' ? params.videoUrl : ''; + return ; + } + + class VideoPlayer extends Component { + private videoPlayer: React.RefObject; + + constructor(props: VideoPlayerProps) { super(props); this.state = { - videoUrl: '', + videoUrl: props.videoUrl || '', fullscreen: true, isLoading: true, paused: false @@ -31,10 +42,14 @@ class VideoPlayer extends Component { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); }; - if (this.videoPlayer.current) { - this.videoPlayer.current.presentFullscreenPlayer(); - void lockOrientation(); - } + const { videoUrl } = this.props; + + this.setState({ videoUrl }, () => { + if (this.videoPlayer.current) { + this.videoPlayer.current.presentFullscreenPlayer(); + void lockOrientation(); + } + }); } componentWillUnmount() { @@ -98,5 +113,3 @@ const styles = StyleSheet.create({ }, }); - -export default VideoPlayer; \ No newline at end of file From 8976b939b674e71960bc484ca59ed73aea17d4f0 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 11:04:38 +0100 Subject: [PATCH 008/442] feat: add getVideoUrl function --- packages/provider-utils/src/video.ts | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/provider-utils/src/video.ts diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts new file mode 100644 index 0000000..162a7a9 --- /dev/null +++ b/packages/provider-utils/src/video.ts @@ -0,0 +1,47 @@ +import type { + FileBasedStream, + Qualities, + RunnerOptions, + ScrapeMedia} from '@movie-web/providers'; +import { + makeProviders, + makeStandardFetcher, + targets, + } from '@movie-web/providers'; + +export async function getVideoUrl(media: ScrapeMedia): Promise { + const providers = makeProviders({ + fetcher: makeStandardFetcher(fetch), + target: targets.NATIVE, + consistentIpForRequests: true, + }); + + const options: RunnerOptions = { + media + }; + + const results = await providers.runAll(options); + if (!results) return null; + + let highestQuality; + let url; + + switch (results.stream.type) { + case 'file': + highestQuality = findHighestQuality(results.stream); + url = highestQuality ? results.stream.qualities[highestQuality]?.url : null; + return url ?? null; + case 'hls': + return results.stream.playlist; + } +} + +function findHighestQuality(stream: FileBasedStream): Qualities | undefined { + const qualityOrder: Qualities[] = ['4k', '1080', '720', '480', '360', 'unknown']; + for (const quality of qualityOrder) { + if (stream.qualities[quality]) { + return quality; + } + } + return undefined; +} \ No newline at end of file From 552b9b52bc21bea70fd0af3159e4ff4e50d42d0b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 12:27:46 +0100 Subject: [PATCH 009/442] feat: load video from providers --- apps/expo/app.config.ts | 18 +++-- apps/expo/index.js | 3 +- apps/expo/package.json | 3 + apps/expo/src/app/components/item/item.tsx | 92 +++++++++++++++------- apps/expo/src/app/video-player.tsx | 92 +++++++++++----------- packages/provider-utils/package.json | 3 +- packages/provider-utils/src/index.ts | 2 + packages/provider-utils/src/util.ts | 43 ++++++++++ packages/provider-utils/src/video.ts | 86 +++++++++++--------- pnpm-lock.yaml | 22 ++++++ 10 files changed, 244 insertions(+), 120 deletions(-) create mode 100644 packages/provider-utils/src/util.ts diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 9fd8780..9c17ebf 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -19,7 +19,7 @@ const defineConfig = (): ExpoConfig => ({ ios: { bundleIdentifier: "dev.movieweb.app", supportsTablet: true, - requireFullScreen: true, + requireFullScreen: true, }, android: { package: "dev.movieweb.app", @@ -41,13 +41,15 @@ const defineConfig = (): ExpoConfig => ({ tsconfigPaths: true, typedRoutes: true, }, - plugins: ["expo-router", [ - "expo-screen-orientation", - { - initialOrientation: "DEFAULT" - } - ] -], + plugins: [ + "expo-router", + [ + "expo-screen-orientation", + { + initialOrientation: "DEFAULT", + }, + ], + ], }); export default defineConfig; diff --git a/apps/expo/index.js b/apps/expo/index.js index 63e531c..ab16fb5 100644 --- a/apps/expo/index.js +++ b/apps/expo/index.js @@ -1 +1,2 @@ -import "expo-router/entry"; \ No newline at end of file +import "expo-router/entry"; +import "@react-native-anywhere/polyfill-base64"; diff --git a/apps/expo/package.json b/apps/expo/package.json index b5f3093..aee39b1 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -19,7 +19,10 @@ }, "dependencies": { "@expo/metro-config": "^0.17.3", + "@movie-web/provider-utils": "*", "@movie-web/tmdb": "*", + "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", + "@react-navigation/native": "^6.1.9", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "expo": "~50.0.5", diff --git a/apps/expo/src/app/components/item/item.tsx b/apps/expo/src/app/components/item/item.tsx index a13ea8f..e0649be 100644 --- a/apps/expo/src/app/components/item/item.tsx +++ b/apps/expo/src/app/components/item/item.tsx @@ -1,5 +1,12 @@ +import { Image, TouchableOpacity, View } from "react-native"; import { useRouter } from "expo-router"; -import { Image, View, TouchableOpacity } from "react-native"; +import * as ScreenOrientation from "expo-screen-orientation"; + +import { + getVideoUrl, + transformSearchResultToScrapeMedia, +} from "@movie-web/provider-utils"; +import { fetchMediaDetails } from "@movie-web/tmdb"; import { Text } from "~/components/ui/Text"; @@ -13,38 +20,67 @@ export interface ItemData { export default function Item({ data }: { data: ItemData }) { const router = useRouter(); - const { title, type, year, posterUrl } = data; + const { id, title, type, year, posterUrl } = data; - const handlePress = () => { - router.push('/video-player'); - // router.push({ - // pathname: '/video-player', - // params: { videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4' } - // }); + const handlePress = async () => { + router.push("/video-player"); + + const media = await fetchMediaDetails(id, type); + if (!media) return; + + const { result } = media; + let season: number | undefined; + let episode: number | undefined; + + if (type === "tv") { + // season = ?? undefined; + // episode = ?? undefined; + } + + const scrapeMedia = transformSearchResultToScrapeMedia( + type, + result, + season, + episode, + ); + + const videoUrl = await getVideoUrl(scrapeMedia); + if (!videoUrl) { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + return router.push("/(tabs)"); + } + console.log(videoUrl); + + router.push({ + pathname: "/video-player", + params: { videoUrl }, + }); }; return ( - + { - - - - - {title} - - - {type === "tv" ? "Show" : "Movie"} - - - {year} - - - } + + + + + {title} + + + {type === "tv" ? "Show" : "Movie"} + + + {year} + + + } ); } diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index afca0a4..7652df3 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -1,10 +1,10 @@ -import React, { Component } from 'react'; -import { StyleSheet, ActivityIndicator } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import type { VideoRef } from 'react-native-video'; -import Video from 'react-native-video'; -import * as ScreenOrientation from 'expo-screen-orientation'; +import type { VideoRef } from "react-native-video"; +import React, { Component } from "react"; +import { ActivityIndicator, StyleSheet } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Video from "react-native-video"; import { useLocalSearchParams } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; interface VideoPlayerState { videoUrl: string; @@ -14,53 +14,57 @@ interface VideoPlayerState { } interface VideoPlayerProps { - videoUrl: string; + videoUrl: string; } export default function VideoPlayerWrapper() { - const params = useLocalSearchParams(); - const videoUrl = typeof params.videoUrl === 'string' ? params.videoUrl : ''; - return ; - } + const params = useLocalSearchParams(); + const videoUrl = typeof params.videoUrl === "string" ? params.videoUrl : ""; + return ; +} - class VideoPlayer extends Component { - private videoPlayer: React.RefObject; +class VideoPlayer extends Component { + private videoPlayer: React.RefObject; - constructor(props: VideoPlayerProps) { + constructor(props: VideoPlayerProps) { super(props); this.state = { - videoUrl: props.videoUrl || '', + videoUrl: props.videoUrl || "", fullscreen: true, isLoading: true, - paused: false + paused: false, }; this.videoPlayer = React.createRef(); } componentDidMount() { - const lockOrientation = async () => { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); - }; - - const { videoUrl } = this.props; - - this.setState({ videoUrl }, () => { - if (this.videoPlayer.current) { - this.videoPlayer.current.presentFullscreenPlayer(); - void lockOrientation(); - } - }); + const lockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE, + ); + }; + + const { videoUrl } = this.props; + + this.setState({ videoUrl }, () => { + if (this.videoPlayer.current) { + this.videoPlayer.current.presentFullscreenPlayer(); + void lockOrientation(); + } + }); } componentWillUnmount() { - const unlockOrientation = async () => { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); - } + const unlockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }; - if (this.videoPlayer.current) { - this.videoPlayer.current.dismissFullscreenPlayer(); - } - void unlockOrientation(); + if (this.videoPlayer.current) { + this.videoPlayer.current.dismissFullscreenPlayer(); + } + void unlockOrientation(); } onVideoLoadStart = () => { @@ -71,9 +75,9 @@ export default function VideoPlayerWrapper() { this.setState({ isLoading: false }); }; -// onVideoError = () => { // probably useful later -// console.log("Video playback error"); -// }; + // onVideoError = () => { // probably useful later + // console.log("Video playback error"); + // }; render() { return ( @@ -84,7 +88,7 @@ export default function VideoPlayerWrapper() { style={styles.fullScreen} fullscreen={this.state.fullscreen} paused={this.state.paused} - controls={true} + controls={true} onLoadStart={this.onVideoLoadStart} onReadyForDisplay={this.onReadyForDisplay} // onError={this.onVideoError} @@ -98,18 +102,18 @@ export default function VideoPlayerWrapper() { } const styles = StyleSheet.create({ + // taken from example, probably needs to be nativewind stuff instead container: { flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'black', + justifyContent: "center", + alignItems: "center", + backgroundColor: "black", }, fullScreen: { - position: 'absolute', + position: "absolute", top: 0, left: 0, bottom: 0, right: 0, }, - }); diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index 533f185..01abe92 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -29,6 +29,7 @@ }, "prettier": "@movie-web/prettier-config", "dependencies": { - "@movie-web/providers": "^2.1.1" + "@movie-web/providers": "^2.1.1", + "tmdb-ts": "^1.6.1" } } diff --git a/packages/provider-utils/src/index.ts b/packages/provider-utils/src/index.ts index bb356a3..c59ca8d 100644 --- a/packages/provider-utils/src/index.ts +++ b/packages/provider-utils/src/index.ts @@ -1 +1,3 @@ export const name = "provider-utils"; +export * from "./video"; +export * from "./util"; diff --git a/packages/provider-utils/src/util.ts b/packages/provider-utils/src/util.ts new file mode 100644 index 0000000..2b68eaf --- /dev/null +++ b/packages/provider-utils/src/util.ts @@ -0,0 +1,43 @@ +import type { MovieDetails, TvShowDetails } from "tmdb-ts"; + +import type { ScrapeMedia } from "@movie-web/providers"; + +export function transformSearchResultToScrapeMedia( + type: "tv" | "movie", + result: TvShowDetails | MovieDetails, + season?: number, + episode?: number, +): ScrapeMedia { + if (type === "tv") { + const tvResult = result as TvShowDetails; + return { + type: "show", + tmdbId: tvResult.id.toString(), + title: tvResult.name, + releaseYear: new Date(tvResult.first_air_date).getFullYear(), + season: { + number: season ?? tvResult.seasons[0]?.season_number ?? 1, + tmdbId: season + ? tvResult.seasons + .find((s) => s.season_number === season) + ?.id.toString() ?? "" + : tvResult.seasons[0]?.id.toString() ?? "", + }, + episode: { + number: episode ?? 1, + tmdbId: "", + }, + }; + } + if (type === "movie") { + const movieResult = result as MovieDetails; + return { + type: "movie", + tmdbId: movieResult.id.toString(), + title: movieResult.title, + releaseYear: new Date(movieResult.release_date).getFullYear(), + }; + } + + throw new Error("Invalid type parameter"); +} diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 162a7a9..d483924 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,47 +1,57 @@ import type { - FileBasedStream, - Qualities, - RunnerOptions, - ScrapeMedia} from '@movie-web/providers'; + FileBasedStream, + Qualities, + RunnerOptions, + ScrapeMedia, +} from "@movie-web/providers"; import { - makeProviders, - makeStandardFetcher, - targets, - } from '@movie-web/providers'; + makeProviders, + makeStandardFetcher, + targets, +} from "@movie-web/providers"; -export async function getVideoUrl(media: ScrapeMedia): Promise { - const providers = makeProviders({ - fetcher: makeStandardFetcher(fetch), - target: targets.NATIVE, - consistentIpForRequests: true, - }); +export async function getVideoUrl(media: ScrapeMedia): Promise { + const providers = makeProviders({ + fetcher: makeStandardFetcher(fetch), + target: targets.NATIVE, + consistentIpForRequests: true, + }); - const options: RunnerOptions = { - media - }; + const options: RunnerOptions = { + media, + }; - const results = await providers.runAll(options); - if (!results) return null; + const results = await providers.runAll(options); + if (!results) return null; - let highestQuality; - let url; - - switch (results.stream.type) { - case 'file': - highestQuality = findHighestQuality(results.stream); - url = highestQuality ? results.stream.qualities[highestQuality]?.url : null; - return url ?? null; - case 'hls': - return results.stream.playlist; - } + let highestQuality; + let url; + + switch (results.stream.type) { + case "file": + highestQuality = findHighestQuality(results.stream); + url = highestQuality + ? results.stream.qualities[highestQuality]?.url + : null; + return url ?? null; + case "hls": + return results.stream.playlist; + } } function findHighestQuality(stream: FileBasedStream): Qualities | undefined { - const qualityOrder: Qualities[] = ['4k', '1080', '720', '480', '360', 'unknown']; - for (const quality of qualityOrder) { - if (stream.qualities[quality]) { - return quality; - } - } - return undefined; -} \ No newline at end of file + const qualityOrder: Qualities[] = [ + "4k", + "1080", + "720", + "480", + "360", + "unknown", + ]; + for (const quality of qualityOrder) { + if (stream.qualities[quality]) { + return quality; + } + } + return undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9775945..7ae733e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,9 +34,18 @@ importers: '@expo/metro-config': specifier: ^0.17.3 version: 0.17.3(@react-native/babel-preset@0.73.20) + '@movie-web/provider-utils': + specifier: '*' + version: link:../../packages/provider-utils '@movie-web/tmdb': specifier: '*' version: link:../../packages/tmdb + '@react-native-anywhere/polyfill-base64': + specifier: 0.0.1-alpha.0 + version: 0.0.1-alpha.0 + '@react-navigation/native': + specifier: ^6.1.9 + version: 6.1.9(react-native@0.73.2)(react@18.2.0) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -149,6 +158,9 @@ importers: '@movie-web/providers': specifier: ^2.1.1 version: 2.1.1 + tmdb-ts: + specifier: ^1.6.1 + version: 1.6.1 devDependencies: '@movie-web/eslint-config': specifier: workspace:^0.2.0 @@ -2387,6 +2399,12 @@ packages: react: 18.2.0 dev: false + /@react-native-anywhere/polyfill-base64@0.0.1-alpha.0: + resolution: {integrity: sha512-OF3idcETV622AyFvvK54ot2EG0G43tZTZJyWtFHtrEKUmoUvSuC5DOMeLino0TwBQJn2s26MBnIPVgokBJb/xw==} + dependencies: + base-64: 0.1.0 + dev: false + /@react-native-community/cli-clean@12.3.0: resolution: {integrity: sha512-iAgLCOWYRGh9ukr+eVQnhkV/OqN3V2EGd/in33Ggn/Mj4uO6+oUncXFwB+yjlyaUNz6FfjudhIz09yYGSF+9sg==} dependencies: @@ -3838,6 +3856,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base-64@0.1.0: + resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} + dev: false + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} From 28126f612ae41dad55af0adb5c846b899bedb752 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:24:20 +0100 Subject: [PATCH 010/442] chore: rogue log goes home --- apps/expo/src/app/components/item/item.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/expo/src/app/components/item/item.tsx b/apps/expo/src/app/components/item/item.tsx index e0649be..37cbd16 100644 --- a/apps/expo/src/app/components/item/item.tsx +++ b/apps/expo/src/app/components/item/item.tsx @@ -51,7 +51,6 @@ export default function Item({ data }: { data: ItemData }) { ); return router.push("/(tabs)"); } - console.log(videoUrl); router.push({ pathname: "/video-player", From 61be1c37ac2356fcc3fa110188d55276ff2447ca Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:32:38 +0100 Subject: [PATCH 011/442] refactor: make video player function component --- apps/expo/src/app/video-player.tsx | 153 +++++++++++++---------------- 1 file changed, 67 insertions(+), 86 deletions(-) diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index 7652df3..0ddefc2 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -1,18 +1,11 @@ import type { VideoRef } from "react-native-video"; -import React, { Component } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { ActivityIndicator, StyleSheet } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Video from "react-native-video"; import { useLocalSearchParams } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; -interface VideoPlayerState { - videoUrl: string; - fullscreen: boolean; - isLoading: boolean; - paused: boolean; -} - interface VideoPlayerProps { videoUrl: string; } @@ -22,87 +15,75 @@ export default function VideoPlayerWrapper() { const videoUrl = typeof params.videoUrl === "string" ? params.videoUrl : ""; return ; } - -class VideoPlayer extends Component { - private videoPlayer: React.RefObject; - - constructor(props: VideoPlayerProps) { - super(props); - this.state = { - videoUrl: props.videoUrl || "", - fullscreen: true, - isLoading: true, - paused: false, - }; - this.videoPlayer = React.createRef(); - } - - componentDidMount() { - const lockOrientation = async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.LANDSCAPE, - ); - }; - - const { videoUrl } = this.props; - - this.setState({ videoUrl }, () => { - if (this.videoPlayer.current) { - this.videoPlayer.current.presentFullscreenPlayer(); - void lockOrientation(); - } - }); - } - - componentWillUnmount() { - const unlockOrientation = async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - }; - - if (this.videoPlayer.current) { - this.videoPlayer.current.dismissFullscreenPlayer(); - } - void unlockOrientation(); - } - - onVideoLoadStart = () => { - this.setState({ isLoading: true }); - }; - - onReadyForDisplay = () => { - this.setState({ isLoading: false }); - }; - - // onVideoError = () => { // probably useful later - // console.log("Video playback error"); - // }; - - render() { - return ( - - - ); - } +interface VideoPlayerProps { + videoUrl: string; } +const VideoPlayer: React.FC = ({ videoUrl }) => { + const [isLoading, setIsLoading] = useState(true); + const videoPlayer = useRef(null); + + useEffect(() => { + const lockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE + ); + }; + + const unlockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + }; + + const presentFullscreenPlayer = async () => { + if (videoPlayer.current) { + videoPlayer.current.presentFullscreenPlayer(); + await lockOrientation(); + } + }; + + const dismissFullscreenPlayer = async () => { + if (videoPlayer.current) { + videoPlayer.current.dismissFullscreenPlayer(); + await unlockOrientation(); + } + }; + + setIsLoading(true); + void presentFullscreenPlayer(); + + return () => { + void dismissFullscreenPlayer(); + }; + }, [videoUrl]); + + const onVideoLoadStart = () => { + setIsLoading(true); + }; + + const onReadyForDisplay = () => { + setIsLoading(false); + }; + + return ( + + + ); +}; + const styles = StyleSheet.create({ - // taken from example, probably needs to be nativewind stuff instead container: { flex: 1, justifyContent: "center", From 6fbea58edc361ad53dd1792114a47b3a54025a84 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:34:51 +0100 Subject: [PATCH 012/442] chore: use nativewind classes --- apps/expo/src/app/video-player.tsx | 124 +++++++++++++---------------- 1 file changed, 54 insertions(+), 70 deletions(-) diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index 0ddefc2..32fc2d7 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -1,6 +1,6 @@ import type { VideoRef } from "react-native-video"; import React, { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, StyleSheet } from "react-native"; +import { ActivityIndicator } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Video from "react-native-video"; import { useLocalSearchParams } from "expo-router"; @@ -16,85 +16,69 @@ export default function VideoPlayerWrapper() { return ; } interface VideoPlayerProps { - videoUrl: string; + videoUrl: string; } const VideoPlayer: React.FC = ({ videoUrl }) => { - const [isLoading, setIsLoading] = useState(true); - const videoPlayer = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const videoPlayer = useRef(null); - useEffect(() => { - const lockOrientation = async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.LANDSCAPE - ); - }; + useEffect(() => { + const lockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE, + ); + }; - const unlockOrientation = async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP - ); - }; + const unlockOrientation = async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }; - const presentFullscreenPlayer = async () => { - if (videoPlayer.current) { - videoPlayer.current.presentFullscreenPlayer(); - await lockOrientation(); - } - }; + const presentFullscreenPlayer = async () => { + if (videoPlayer.current) { + videoPlayer.current.presentFullscreenPlayer(); + await lockOrientation(); + } + }; - const dismissFullscreenPlayer = async () => { - if (videoPlayer.current) { - videoPlayer.current.dismissFullscreenPlayer(); - await unlockOrientation(); - } - }; + const dismissFullscreenPlayer = async () => { + if (videoPlayer.current) { + videoPlayer.current.dismissFullscreenPlayer(); + await unlockOrientation(); + } + }; - setIsLoading(true); - void presentFullscreenPlayer(); + setIsLoading(true); + void presentFullscreenPlayer(); - return () => { - void dismissFullscreenPlayer(); - }; - }, [videoUrl]); + return () => { + void dismissFullscreenPlayer(); + }; + }, [videoUrl]); - const onVideoLoadStart = () => { - setIsLoading(true); - }; + const onVideoLoadStart = () => { + setIsLoading(true); + }; - const onReadyForDisplay = () => { - setIsLoading(false); - }; + const onReadyForDisplay = () => { + setIsLoading(false); + }; - return ( - - - ); + return ( + + + ); }; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: "black", - }, - fullScreen: { - position: "absolute", - top: 0, - left: 0, - bottom: 0, - right: 0, - }, -}); From e39ee1373bbb443cd6f188d6eaa421e22c459d8f Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:15:41 +0100 Subject: [PATCH 013/442] feat: move source fetching logic into player and remove double player nonsense --- apps/expo/src/app/components/item/item.tsx | 42 +--------- apps/expo/src/app/video-player.tsx | 97 ++++++++++++++++++---- 2 files changed, 86 insertions(+), 53 deletions(-) diff --git a/apps/expo/src/app/components/item/item.tsx b/apps/expo/src/app/components/item/item.tsx index 37cbd16..9529601 100644 --- a/apps/expo/src/app/components/item/item.tsx +++ b/apps/expo/src/app/components/item/item.tsx @@ -1,12 +1,5 @@ import { Image, TouchableOpacity, View } from "react-native"; import { useRouter } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; - -import { - getVideoUrl, - transformSearchResultToScrapeMedia, -} from "@movie-web/provider-utils"; -import { fetchMediaDetails } from "@movie-web/tmdb"; import { Text } from "~/components/ui/Text"; @@ -20,41 +13,12 @@ export interface ItemData { export default function Item({ data }: { data: ItemData }) { const router = useRouter(); - const { id, title, type, year, posterUrl } = data; - - const handlePress = async () => { - router.push("/video-player"); - - const media = await fetchMediaDetails(id, type); - if (!media) return; - - const { result } = media; - let season: number | undefined; - let episode: number | undefined; - - if (type === "tv") { - // season = ?? undefined; - // episode = ?? undefined; - } - - const scrapeMedia = transformSearchResultToScrapeMedia( - type, - result, - season, - episode, - ); - - const videoUrl = await getVideoUrl(scrapeMedia); - if (!videoUrl) { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - return router.push("/(tabs)"); - } + const { title, type, year, posterUrl } = data; + const handlePress = () => { router.push({ pathname: "/video-player", - params: { videoUrl }, + params: { data: JSON.stringify(data) }, }); }; diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index 32fc2d7..3a9bf9f 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -1,29 +1,81 @@ import type { VideoRef } from "react-native-video"; import React, { useEffect, useRef, useState } from "react"; -import { ActivityIndicator } from "react-native"; +import { ActivityIndicator, StyleSheet } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Video from "react-native-video"; -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; -interface VideoPlayerProps { - videoUrl: string; -} +import { + getVideoUrl, + transformSearchResultToScrapeMedia, +} from "@movie-web/provider-utils"; +import { fetchMediaDetails } from "@movie-web/tmdb"; + +import type { ItemData } from "./components/item/item"; export default function VideoPlayerWrapper() { const params = useLocalSearchParams(); - const videoUrl = typeof params.videoUrl === "string" ? params.videoUrl : ""; - return ; -} -interface VideoPlayerProps { - videoUrl: string; + const data = params.data + ? (JSON.parse(params.data as string) as ItemData) + : null; + return ; } -const VideoPlayer: React.FC = ({ videoUrl }) => { +interface VideoPlayerProps { + data: ItemData | null; +} + +const VideoPlayer: React.FC = ({ data }) => { + const [videoUrl, setVideoUrl] = useState(""); const [isLoading, setIsLoading] = useState(true); const videoPlayer = useRef(null); + const router = useRouter(); useEffect(() => { + const initializePlayer = async () => { + const fetchVideo = async () => { + if (!data) return null; + const { id, type } = data; + const media = await fetchMediaDetails(id, type); + if (!media) return null; + + const { result } = media; + let season: number | undefined; + let episode: number | undefined; + + if (type === "tv") { + // season = ?? undefined; + // episode = ?? undefined; + } + + const scrapeMedia = transformSearchResultToScrapeMedia( + type, + result, + season, + episode, + ); + + const videoUrl = await getVideoUrl(scrapeMedia); + if (!videoUrl) { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + return router.push("/(tabs)"); + } + return videoUrl; + }; + + setIsLoading(true); + const url = await fetchVideo(); + if (url) { + setVideoUrl(url); + setIsLoading(false); + } else { + router.push("/(tabs)"); + } + }; + const lockOrientation = async () => { await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.LANDSCAPE, @@ -52,11 +104,12 @@ const VideoPlayer: React.FC = ({ videoUrl }) => { setIsLoading(true); void presentFullscreenPlayer(); + void initializePlayer(); return () => { void dismissFullscreenPlayer(); }; - }, [videoUrl]); + }, [data, router]); const onVideoLoadStart = () => { setIsLoading(true); @@ -67,11 +120,11 @@ const VideoPlayer: React.FC = ({ videoUrl }) => { }; return ( - + ); }; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "black", + }, + fullScreen: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + }, +}); From 8db85c545b7bb11812d4778dc1aa227730b8aa1a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:49:00 +0100 Subject: [PATCH 014/442] feat: set headers in video player --- apps/expo/src/app/video-player.tsx | 33 +++++++++++++++++++++++----- packages/provider-utils/src/video.ts | 22 +++++-------------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/video-player.tsx index 3a9bf9f..60d05a3 100644 --- a/apps/expo/src/app/video-player.tsx +++ b/apps/expo/src/app/video-player.tsx @@ -7,6 +7,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { + findHighestQuality, getVideoUrl, transformSearchResultToScrapeMedia, } from "@movie-web/provider-utils"; @@ -28,6 +29,7 @@ interface VideoPlayerProps { const VideoPlayer: React.FC = ({ data }) => { const [videoUrl, setVideoUrl] = useState(""); + const [headers, setHeaders] = useState({} as Record); const [isLoading, setIsLoading] = useState(true); const videoPlayer = useRef(null); const router = useRouter(); @@ -56,19 +58,38 @@ const VideoPlayer: React.FC = ({ data }) => { episode, ); - const videoUrl = await getVideoUrl(scrapeMedia); - if (!videoUrl) { + const stream = await getVideoUrl(scrapeMedia); + if (!stream) { await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.PORTRAIT_UP, ); return router.push("/(tabs)"); } - return videoUrl; + return stream; }; setIsLoading(true); - const url = await fetchVideo(); - if (url) { + const stream = await fetchVideo(); + + if (stream) { + let highestQuality; + let url; + + switch (stream.type) { + case "file": + highestQuality = findHighestQuality(stream); + url = highestQuality ? stream.qualities[highestQuality]?.url : null; + return url ?? null; + case "hls": + url = stream.playlist; + } + + const combinedHeaders = { + ...stream.headers, + ...stream.preferredHeaders, + }; + + setHeaders(combinedHeaders); setVideoUrl(url); setIsLoading(false); } else { @@ -123,7 +144,7 @@ const VideoPlayer: React.FC = ({ data }) => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: "black", - }, - fullScreen: { - position: "absolute", - top: 0, - left: 0, - bottom: 0, - right: 0, - }, -}); - interface Caption { type: "srt" | "vtt"; id: string; From fda8f34dab6382084041b3eb8e9b383bfe2adde1 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:12:57 +0100 Subject: [PATCH 021/442] feat: create ios altstore repo.json on release --- .github/workflows/release-mobile.yml | 42 ++++++++++++++++++++++++++++ apps/expo/repo.config.json | 16 +++++++++++ 2 files changed, 58 insertions(+) create mode 100644 apps/expo/repo.config.json diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index d3fd8d5..5d296d9 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + pages: write + id-token: write jobs: bump-version: @@ -141,3 +143,43 @@ jobs: token: ${{ env.GITHUB_TOKEN }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + altstore-repo: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: [build-ios, release-app] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 21 + + - name: Create app repo + run: | + npm install -g altstore-github + mkdir -p pages + cd apps/expo + npx altstore-github --config repo.config.json > app.json + mv app.json ../../pages/app.json + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./pages + destination: ./_site + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 + diff --git a/apps/expo/repo.config.json b/apps/expo/repo.config.json new file mode 100644 index 0000000..084c5e5 --- /dev/null +++ b/apps/expo/repo.config.json @@ -0,0 +1,16 @@ +{ + "apps": [ + { + "name": "movie-web", + "bundleIdentifier": "dev.movieweb.app", + "filename": "movie-web.ipa", + "githubOwner": "movie-web", + "githubRepository": "native-app", + "developerName": "movie-web", + "subtitle": "A small app for watching movies and shows easily", + "localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.", + "iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true", + "tintColor": "a87fd1" + } + ] +} \ No newline at end of file From d9a03907e07270af1dede192c512efeab9b9eb58 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:14:07 +0100 Subject: [PATCH 022/442] chore: rename job --- .github/workflows/release-mobile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index 5d296d9..5cea704 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -144,7 +144,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - altstore-repo: + app-repo: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From df53ee610eb3866197ea325c66808311d225cef1 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:21:25 +0100 Subject: [PATCH 023/442] feat: comment built binaries on pull request --- .github/workflows/build-mobile.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 862a1e6..0b6bd61 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -44,6 +44,12 @@ jobs: - name: Rename apk run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk + + - name: Comment on pull request + if: github.event_name == 'pull_request' + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: ./apps/expo/android/app/build/outputs/apk/release/movie-web.apk - name: Upload movie-web.apk as artifact uses: actions/upload-artifact@v4 @@ -88,6 +94,12 @@ jobs: cd ios/build/Build/Products/Release-iphoneos zip -r ../../../movie-web.ipa Payload + - name: Comment on pull request + if: github.event_name == 'pull_request' + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: ./apps/expo/ios/build/movie-web.ipa + - name: Upload movie-web.ipa as artifact uses: actions/upload-artifact@v4 with: From e1ae9136e10acab133a6094035ce0f8d6f319ea7 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:23:41 +0100 Subject: [PATCH 024/442] chore: prettier --- apps/expo/repo.config.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/expo/repo.config.json b/apps/expo/repo.config.json index 084c5e5..63ab1f8 100644 --- a/apps/expo/repo.config.json +++ b/apps/expo/repo.config.json @@ -1,16 +1,16 @@ { - "apps": [ - { - "name": "movie-web", - "bundleIdentifier": "dev.movieweb.app", - "filename": "movie-web.ipa", - "githubOwner": "movie-web", - "githubRepository": "native-app", - "developerName": "movie-web", - "subtitle": "A small app for watching movies and shows easily", - "localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.", - "iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true", - "tintColor": "a87fd1" - } - ] -} \ No newline at end of file + "apps": [ + { + "name": "movie-web", + "bundleIdentifier": "dev.movieweb.app", + "filename": "movie-web.ipa", + "githubOwner": "movie-web", + "githubRepository": "native-app", + "developerName": "movie-web", + "subtitle": "A small app for watching movies and shows easily", + "localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.", + "iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true", + "tintColor": "a87fd1" + } + ] +} From 3e6c5147cded4979cb30d38fb612dd83a224546d Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:34:28 +0100 Subject: [PATCH 025/442] chore: set prettier as default for workflow files --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c96c9e..87b90af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,8 @@ "typescript.preferences.autoImportFileExcludePatterns": [ // Should import Text from UI components instead "react-native/Libraries/Text/Text.d.ts" - ] +], +"[github-actions-workflow]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" +} } From 1a44c10c0df708646596832726170e37ea0a1e3b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 09:44:21 +0100 Subject: [PATCH 026/442] feat: use actual mw icons --- apps/expo/assets/images/adaptive-icon.png | Bin 17547 -> 23885 bytes apps/expo/assets/images/favicon.png | Bin 1466 -> 974 bytes apps/expo/assets/images/icon.png | Bin 22380 -> 98560 bytes apps/expo/assets/images/splash.png | Bin 47346 -> 269551 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/expo/assets/images/adaptive-icon.png b/apps/expo/assets/images/adaptive-icon.png index 03d6f6b6c6727954aec1d8206222769afd178d8d..7f727877157c37076461eee0b3cd2d8cf4ae0611 100644 GIT binary patch literal 23885 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

5J^K*Yo7Jko-lT<< z%xIbR@!;*vH^11Y-~*9i{&-U0vfkxBpx8%JUBRcH)ZGac`-fs>vf@B zVmJMObtJK3e^bXdMy3Q19I(ehYZUvh06UP*M!f-fj-PR3wNG!JV9(*~FQ+U;1k1h$ zT$;aTf4hzdSO)lZD)Z-%`1_VIos)n8Czn~6VPDYW?%pK#s;p^_s^PD3{mc&fU4OFL z0uK;eF>5p{|A-m=!Re)I4KDrxR?!a#C;-Iqpn;-v)A6DOxUvWPK%bBPFRcHZSmlDR zesoE;GSuj(l0xeqmJltU|sH?&oCkMIHfjMY2+X# zV%EpXBI*JKK~@(vy+R-XLqz%yU~K*|%Mpo3`d44~Y5j5&5Z;31{TqMVft?pA;(yBp=^IZOJfBjWPI06Ob4B7 z(G~TZnCz8pfC1!vR4yxJG^TA@pbR z)2e`{d`xbm>-AnLple!R~oXJRBH9D)c<+bpw_T~KYQJ3VR)EaWCV0Nc)4Op@65byEJ z8R3%s{tk&8{{N+$$JOrc`g{ed1uAyUx7gwO#USe^-XBZC?TKu4)>N_*pxZFto0pyC zEF)}im0(`zPG~#SuUm%|G&lDdCgJ`Cu_H6n(|U1fKz0YJHjhwI?#})($Vccf4~!q~ z5irk=L5;k>`iJf{v=80HNIpdG$U;>DmWc0yyB9!44)zPB7)u27ZP3*45NO7kqo=n) zqbVm`Itw-M{VB#3`x691zCFPc+haUUx?oacIqf>` zVr5pCl>5IxAS`S6|1ZGfoGg*-1ffBU>LL|y#^6EfW#%dNx1~QY_nxce{t){mLxJ~v zN~hXDi!$hz^13w8cMC`Co3NAgv&+X`*^QkilfA>22#{;f$H+>JNcP1y$d>ha9jx54 z`sY}%PDQw?$EVnnxZqCEF#)I|-xW2!IGY4%cZtoyQtQRqaC87>0;egj;=NCV@9+P5 zXYjVH5H)Iv6oz0P#H$(07uVH+t~WnL%%%F_5k$;dl4N@FStTurZ=q{5=(Pa)Y8RYk z0I1<222S#qM5cKhc)EwLKR~wrc)2rx0WuhV?;KYOyZg;`urvk0Q`2a;`Fioo#!}g?tUTsK!M-r~3NgfBU=si)y`sI9o@?9*R zR4nAqT@9M61S>ME#7tY8=t;qa(V&Z&%AJjo;$3!kTzG>KwNw3&Q7r9k&L!FYP!5WC zg6@cq)M~m+q>l@}=d59$k^a5c8~>ufp~YNzk6A%J`Qi3ZWlI3er*X$$bUb#iR_KQ5 z@QezaO#HwGvH0Wih3o=lvDvHnSk7dIq)x9I*r?m+_ z(6cbp=#yyW4o8Zzt}7I?-Ut|z+%LIpFCy_dqndWwYw=U58D+0m_LVqy|EEu}yIN*j zv{p6B2BEn+l2CP4$iMK%|e8Xtq@4JkA}3?bd1eOUPo2hZ;(EKQe|EQFAcaQtwTo1v?!A8po?_p36P zyB8xC6kSdArGG=^JlFZR6>4;5x%H3QQ?94vxAjn27^j$(&3rqX>R~11(thC_lcNPvgB!C(FQ6z?(+f_{8)a|28GTQimwhFstLCt1hl}&ahO5f?u4`Tvl)uVu*WdLco&P97S6`Y@jXJ+LyR4Uz6LIV# zfNy>)Dbw2>eYyGKME0r7)-lQLClC$J^4SgU=~;g@IDb>KXXyT=35SG@0?S`)neqfI z2d8EV#=FqM4Y;dZeBGa9Yci|5-#JC>wuyxCf)82JHu(mp!|RBxwDVN4LJvfYEwS?M zveDzNG9TOa-iJI!Zt#Q^>p$c+N_k3}1c|R`7AC3nhpsl*}9n?#Zubq zAgO~!-3{89+ue14q+SlWnW%pa5Pq#cLf|#nnu())y4^N8;;n`ALW{?$FU{*KFR8tm z+c+_#+zxu1s|rzZ@GvDtgdYVTaBkbnetyern@!Puc(!tquj!`nHFp)w*S?m@x)v4C z5&5mDMIE{M-RpZpLUd4wxe7#O!^8B(`$vbCeU?r0ff|?6{gaHZjjLbXRQ#;`!{0FZ z?79syhY7aY7^84`v7dVQyItqrz-A_T?P;XkbJMNeF{E9w6~l!q*qDb~1R+d!^a;Nk z#r38XlqA+%D?F#ZpNlAlWtj76{zZC?Io$BRC#ISReLSwvRPLE*W`y~*dMb{3g^QD+ zt|AHeRZ{vS_r&avTG96ddm&|?MZ`3s$k-0x~V|fEsa>{V&-&5JEJ(N}rJ4uy+e~Bh) zI)~XQ{k3aaqgx$dujRi2VYKIv`24+^`~1^-2#Bz5H(i`(2pBNqjQ(6X&Sm^>9c53z zJU>B7l=#|4O6*?Z>RPDwDX-^O*K*5 z6Cy@MMJ%^US3x@B4N47&pfsgO2PqbiE}femtP}}|CQ<|hL@+>rNJ5cjqoWidp@VcH zBE1-rXX1K)!TaI)^nA%WIcwIQ*?VSR`?}_wGi)e6!JdGgBC*5#LaF4MpLyLylNiy> zb^HC^qV26sJ?=QZGV;xuC5}b+N`9Y{TU9I)Gt9YMo$mR4$NTN5(2D?5Ik<4|2z@OhSR8yB>4s1@gMy!<5afArdRA9mZ=sVI+-Rs|0^`W`6tC zJKH+l_Zu~cGDOt7Kww>@@n~8SDHes6n*1ZqheX@QyPt94TW|&Q0q5N27tFrL41X9Y zGkcj*OqEicqZ(wGd-030eouI@=2y*2PivH|bN(`Li)=*%i7TCd&7I2qwraj&tD#LC z^*XeBb>-gV);PhkOr*{%!$BNXK{R^~4M{4x`E9C9ZobP4kRSWDTa|~r8ZcW_HMCVY zt@cbqZr?6|5ov~*ulxQhtDIn)Af^%9{jhV>gGB zX)tWFD10kPy2jM92=Ca){p(7J*-Km=bQD&k>F4xs*Z~?uD4B49e)~Ghyb#_yg)uyi zCoKTRmUgYs+IU&hyj;+9Bwkb5F!l_mIOX8)2}bVL_?%_g>sLRg1b0z!vGU7g^3D73 z(^fv)wFJ`ymaf0sQgP?(@G@Kz3a0?jaMYXQJGCzx;j6sTbmJwWH?5#-_l~Efqe^jwK z_}cyR`6C6-(4}$V*WT|!itI#yig;QEhK(1-L{U!(hrCLkstZjx1;PEZ1$Wb&D)GI~ z&6*YrG8d=y;k;199B+8V_;$a;(Z9vE`sLEnBA3bgw{x>BGFLM!Kkt^`M9Mz^fU(zo z>7NhlXZRC!DCb`kn`=6MeM0@9HI}G8Po!eEZ1jlC?amiv5o5fYHLmhogAx&Us{~up zgo6_;my_8=A~U{%|abqx(KQm4D#?h}eeasY3X#}`?cRKD7H zg`hzb=deZXl?hKu*!d}-C(6TIhw|J|PROT}_E^&lSLE6Jhomkz^bheWj zQ7MfQVAQaZxYe-p*3n&L0ZZ1#ioGa*?3J1BUpwbLXnAW>yr%c4aGJKMa%1hdh`r;yh{+B zr;=De#d{fNX#B}&{c9}nWsk$x@WMDG+SkiZB0;n{AZIJ2#8Q=Sy%|@q>f}DLF6sbD z=UB0+D(~9xONR9t>IZ!Dd60|RLoU?}{~lr`m>?FYBAn<)onF;?x?5m@(CzTGhfq^z zi(MX9WC{5W=J%tjR;dj}BGxtzYgU8UU51ZK4w=}N7!1r6tZv?ZtZ_P0m?@3`>uyWY zqeQMhLI~$8fdK|bfeN-7XFNT!TZDFA@r#UiqT%}5>Bd0E7ATKBV&N;!|L-R9- zP6mE2Heizx{{`iTIfHX3kv&qNe%qT}>dK%k$d&0hJ{(_D(l(a>@>6FtHZTLy=1hmR zIst=H942lX-QQPXl(fS5*f`!RHfpc^GT-KYx}sR**G**1B>;ZCpwIQwFoEU#Pd)qk zuh$xK9{2O&F)6Up?tQJ`vQll6B=K9a-h3iUmI-F zX9$@Ad4Fx(XWPi_u9>c+p^`be>(aAwKeT&>VNLq_t8>~?R{W>&GRm2Y1IVSHPUD>S zLPPdR&J9anLi#E2#LwI|x*vExtZI`Fkjffjr%I`J-Zq; z8T08#gK0K4hr;x#32-j2t?<EB^PJbHHw0h0BI1<|2FcNhzFAG}$jx=yhg$%0KZu15wAW(fG4 zWak*Hnkiw+clv~q+joHrvEDMSUcx%XxVztbW2bRPqemtdl%RW6e>2Z#Q*D|NC2|3P zYxi=qpQw4-lMHPoGyaMtBdpR>tI&}x-t^kAjQx=pl7s=!`x*e@{ko?$$*|bM%UJWW znivZ%Lc5UnM4L=FNWF&uh?LSMGV`dCFzTDf{eG7H6a6Nkh=nhq`+N_ZPa00YzQ(P}^QHL90nF@W0FoX^xkmI} zud%4KFP640xE^T%gBx?XhrjxD5TAN4BwY|f55u4(Vchi|9^VWp^1V+nQ!9SC(pG_1 zJs3|oM|&GYcMnL~7kQxtM(y28cfBviIk&c#j;?n7xbs70QG&=d5QJw9YR87Uk@yXYBKF@cK+}$YXnH{EiMMCHaH~=u#(xZCR)&#Wzhcm&3 zDXvi!XhrDhKR>%v14RuYT>$3bEUFV)7-0GpI~Vm_Hsqw9NGk z=-FEGJ@ib&QCuHfAo<$u!zScIHW{cW)Dxt*bD~Nk^0M6d&7isYDzr-Yrdlf#^MUPI zvZ$-ekE+0O!KbHm?#hO1K1>NF6$%B-C#aO#ZZpFI6KbmlUzZ$;XJHU+yf9Ikt&<*I z%prKGo-n}M7YzQ+!A9K>%kFhgXQQ>eWvZAM>-e1b4q*w1*w^kAFc)t?-KPEC7CSd5 zGl?lVq&m&&h00x9LHcwgfRifm5zm&&+-clWb1GZ&T~|}nQVQ@e^(-8U&d4fuPN3?q z!fqczewGzOC%CH0RuTneYzhgKTU(4JPH2P$XZ`gtgH&l0f6oU4hncx}1iY(p5u!+< zsH=z?;c1M?_U9-74j?>Az9o1T=HdBp&tnCC}- z=ekF%srC6I0ch&I_w%CDMqB7v1zGSqxZ1{Kqr?N!8}J5h+O$sLny1H(Ikqg@2%5La zn5VX@!%*8*QS(GObC+zU()Y{D0xL>xcAFY*@`&jU06yU+4;P5b8{s{NpE%k>TueNx z zn_Kk6lf6K2AV+76^|Bvx0Nq^%xqh)L-psJzz4QCsZvz-UxWsMj7||U5oIAky zj%rsM^J=Sy$16#D(MOzbgG7D>{;J;mbox1a_`_aP$iS}E+V&rZiF`ff9 z$)<&QLRO~}oPIU}&;Xx&qnGRdg?_Z2-w657FVa`9E13(+7YfZ}EIj!$bEnrO4V+GD`wB(1QZI19toNrk6x~ z6wCl?d~R%|lTDpBnba+R5dHFjygK;L(>CF-bVpoVhMA#CI06WpL#&?ta>;1s={?lM z*@`udhej9J%Fn&k!tJV_G9L#*m^0WTLGX+OwV#C3Ur&9%%(7?;jGgBc|0>xr6-y2g zS%}i+Mgh5a>DI0!1_}!5^w@WMo)m8zod8#s`Y9GiWO{pB$f-x0^j5q9=)P@#Im5o_ zdNJ-&oYhtxXXa-aMyaz!r$gBxhW9UxKv-)@c&*G1D1;)=;;xPL^=5ya>}6sNbs zayoU^OW$lPDf)1`H3jG{aSu2~yCji~#JD;WxL?@ScLrf0?ky9|V zhn8Q=jo?}O+|#(|(`Pi|fj6Ihf=(;f(OK>SmbsCPm83@bPLBG*>Ge1Cx10v6b(Y(4 zlG8G`yGOo_KC9dZ#WOVzI=GtGQ`cEV!a*^X{Dm>>58ZsLfo8@+KzglEtk|iQ+Oy#w z@kq3JdNJSEK81N}wYB9pr~gdX@qI>5PKUTa?K`pYu532zT!>%aPV{rt%v6?uFc?H6 zc+#^54{qP+F|bKQ8<9Q5;O?|Jc2JvpA9&(yOVNi*c7ajAr6)_xxFE3uJc zo@D;YQi@8D&NCJw^LJM+)ONc3r`D;31W4!hE z^`fiX8d)usxh=mpt(WR_3c=v10m}3AjI74#7w$LgV={uh9~%0fBQV|G!)+orX(1=J{E-XLf2Aa4w9KI#5*AUZvXeM- zdtLcl#Cp8S<683(n(Pv4ifsMW9I%H z7}p<*w3gM|ZkIYgNoLv-n58%Dc&oFgY7NtCy598G%I-57Ojc8_C#eGX>IB8p6?;Z& z(yw*JXt~w=Z21)~zgXPSCX3WYskArh-LYsO|Jlb*nV7TK^k8QLkv>4zB(DU#9eW=) z$6rM#_14YOwaS`#0Hv=96*qqQNc_4R0>bX+n8vjxirNwlUyZknNHChU&*Z4}$4wn} zcN#m%22dZ1s3l~d{`#9{inQ!v%84BqB_DoE9z{Zoi#AnDab;)*LmWQVQBFlED5z(b z{(jJqb0c2o;vzRf?ieeQM~E_MQlBPUCw6{pdh?z6^OU@gg<(gWC&X3JTIvaw@Ki>V zTBWQV2gE3z5?AeWgR5KgmkX3sH9EDDK2tS7a_l{L*!Rwp5ZxVx*=K$K-{t>D9JW|d X^#UKHd2Qxk1cBZqLv7+k$9w++K_j!) literal 17547 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 delta 1459 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%0A?UR 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≶` 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 diff --git a/apps/expo/assets/images/splash.png b/apps/expo/assets/images/splash.png index 0e89705a9436743e42954d3744a0e7ff0d3d4701..961507efddae0313090952404395b83534926ec4 100644 GIT binary patch literal 269551 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

G>c8Clts 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 literal 47346 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 From a270c94e0312d3725452ca6e33d405e173c8ead3 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 11:21:14 +0100 Subject: [PATCH 027/442] feat: use quick crypto --- apps/expo/babel.config.js | 14 +- apps/expo/package.json | 3 + pnpm-lock.yaml | 386 +++++++++++++++++++++++++++++++++++++- 3 files changed, 395 insertions(+), 8 deletions(-) diff --git a/apps/expo/babel.config.js b/apps/expo/babel.config.js index 582ef1a..42cdf24 100644 --- a/apps/expo/babel.config.js +++ b/apps/expo/babel.config.js @@ -6,6 +6,18 @@ module.exports = function (api) { ["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel", ], - plugins: ["react-native-reanimated/plugin"], + plugins: [ + "react-native-reanimated/plugin", + [ + "module-resolver", + { + alias: { + crypto: "react-native-quick-crypto", + stream: "stream-browserify", + buffer: "@craftzdog/react-native-buffer", + }, + }, + ], + ], }; }; diff --git a/apps/expo/package.json b/apps/expo/package.json index aee39b1..6a00d73 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -39,6 +39,8 @@ "react-native": "0.73.2", "react-native-css-interop": "~0.0.22", "react-native-gesture-handler": "~2.14.1", + "react-native-quick-base64": "^2.0.8", + "react-native-quick-crypto": "^0.6.1", "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "~4.8.2", "react-native-screens": "~3.29.0", @@ -56,6 +58,7 @@ "@movie-web/tsconfig": "workspace:^0.1.0", "@types/babel__core": "^7.20.5", "@types/react": "^18.2.48", + "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.56.0", "prettier": "^3.1.1", "tailwindcss": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ae733e..dd655ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,12 @@ importers: react-native-gesture-handler: specifier: ~2.14.1 version: 2.14.1(react-native@0.73.2)(react@18.2.0) + react-native-quick-base64: + specifier: ^2.0.8 + version: 2.0.8(react-native@0.73.2)(react@18.2.0) + react-native-quick-crypto: + specifier: ^0.6.1 + version: 0.6.1(react-native@0.73.2)(react@18.2.0) react-native-reanimated: specifier: ~3.6.2 version: 3.6.2(@babel/core@7.23.9)(@babel/plugin-proposal-nullish-coalescing-operator@7.18.6)(@babel/plugin-proposal-optional-chaining@7.21.0)(@babel/plugin-transform-arrow-functions@7.23.3)(@babel/plugin-transform-shorthand-properties@7.23.3)(@babel/plugin-transform-template-literals@7.23.3)(react-native@0.73.2)(react@18.2.0) @@ -140,6 +146,9 @@ importers: '@types/react': specifier: ^18.2.48 version: 18.2.52 + babel-plugin-module-resolver: + specifier: ^5.0.0 + version: 5.0.0 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -1732,6 +1741,16 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@craftzdog/react-native-buffer@6.0.5(react-native@0.73.2)(react@18.2.0): + resolution: {integrity: sha512-Av+YqfwA9e7jhgI9GFE/gTpwl/H+dRRLmZyJPOpKTy107j9Oj7oXlm3/YiMNz+C/CEGqcKAOqnXDLs4OL6AAFw==} + dependencies: + ieee754: 1.2.1 + react-native-quick-base64: 2.0.8(react-native@0.73.2)(react@18.2.0) + transitivePeerDependencies: + - react + - react-native + dev: false + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -3158,6 +3177,10 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: true + /@types/node@17.0.45: + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + dev: false + /@types/node@20.11.16: resolution: {integrity: sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==} dependencies: @@ -3654,6 +3677,15 @@ packages: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} dev: false + /asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + dependencies: + bn.js: 4.12.0 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + dev: false + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: false @@ -3736,6 +3768,17 @@ packages: '@babel/core': 7.23.9 dev: false + /babel-plugin-module-resolver@5.0.0: + resolution: {integrity: sha512-g0u+/ChLSJ5+PzYwLwP8Rp8Rcfowz58TJNCe+L/ui4rpzE/mg//JVX0EWBUYoxaextqnwuGHzfGp2hh0PPV25Q==} + engines: {node: '>= 16'} + dependencies: + find-babel-config: 2.0.0 + glob: 8.1.0 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.8 + dev: true + /babel-plugin-polyfill-corejs2@0.4.8(@babel/core@7.23.9): resolution: {integrity: sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==} peerDependencies: @@ -3895,6 +3938,14 @@ packages: resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} dev: false + /bn.js@4.12.0: + resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} + dev: false + + /bn.js@5.2.1: + resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} + dev: false + /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: false @@ -3936,6 +3987,60 @@ packages: dependencies: fill-range: 7.0.1 + /brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + dev: false + + /browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.4 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /browserify-cipher@1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} + dependencies: + browserify-aes: 1.2.0 + browserify-des: 1.0.2 + evp_bytestokey: 1.0.3 + dev: false + + /browserify-des@1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} + dependencies: + cipher-base: 1.0.4 + des.js: 1.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /browserify-rsa@4.1.0: + resolution: {integrity: sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==} + dependencies: + bn.js: 5.2.1 + randombytes: 2.1.0 + dev: false + + /browserify-sign@4.2.2: + resolution: {integrity: sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==} + engines: {node: '>= 4'} + dependencies: + bn.js: 5.2.1 + browserify-rsa: 4.1.0 + create-hash: 1.2.0 + create-hmac: 1.1.7 + elliptic: 6.5.4 + inherits: 2.0.4 + parse-asn1: 5.1.6 + readable-stream: 3.6.2 + safe-buffer: 5.2.1 + dev: false + /browserslist@4.22.3: resolution: {integrity: sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3971,6 +4076,10 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false + /buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + dev: false + /buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} dependencies: @@ -4205,6 +4314,13 @@ packages: engines: {node: '>=8'} dev: false + /cipher-base@1.0.4: + resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==} + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + /class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} dependencies: @@ -4453,6 +4569,34 @@ packages: parse-json: 4.0.0 dev: false + /create-ecdh@4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} + dependencies: + bn.js: 4.12.0 + elliptic: 6.5.4 + dev: false + + /create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + dependencies: + cipher-base: 1.0.4 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.2 + sha.js: 2.4.11 + dev: false + + /create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + dependencies: + cipher-base: 1.0.4 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.2 + safe-buffer: 5.2.1 + sha.js: 2.4.11 + dev: false + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true @@ -4488,6 +4632,22 @@ packages: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} dev: false + /crypto-browserify@3.12.0: + resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==} + dependencies: + browserify-cipher: 1.0.1 + browserify-sign: 4.2.2 + create-ecdh: 4.0.4 + create-hash: 1.2.0 + create-hmac: 1.1.7 + diffie-hellman: 5.0.3 + inherits: 2.0.4 + pbkdf2: 3.1.2 + public-encrypt: 4.0.3 + randombytes: 2.1.0 + randomfill: 1.0.4 + dev: false + /crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} dev: false @@ -4710,6 +4870,13 @@ packages: engines: {node: '>=6'} dev: false + /des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -4729,6 +4896,14 @@ packages: engines: {node: '>=0.3.1'} dev: true + /diffie-hellman@5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} + dependencies: + bn.js: 4.12.0 + miller-rabin: 4.0.1 + randombytes: 2.1.0 + dev: false + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4804,6 +4979,18 @@ packages: /electron-to-chromium@1.4.656: resolution: {integrity: sha512-9AQB5eFTHyR3Gvt2t/NwR0le2jBSUNwCnMbUCejFWHD+so4tH40/dRLgoE+jxlPeWS43XJewyvCv+I8LPMl49Q==} + /elliptic@6.5.4: + resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5247,6 +5434,18 @@ packages: engines: {node: '>=6'} dev: false + /events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + dev: false + + /evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + dev: false + /exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} dev: false @@ -5586,6 +5785,14 @@ packages: - supports-color dev: false + /find-babel-config@2.0.0: + resolution: {integrity: sha512-dOKT7jvF3hGzlW60Gc3ONox/0rRZ/tz7WCil0bqA1In/3I8f1BctpXahRnEKDySZqci7u+dqq93sZST9fOJpFw==} + engines: {node: '>=16.0.0'} + dependencies: + json5: 2.2.3 + path-exists: 4.0.0 + dev: true + /find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} @@ -5600,7 +5807,6 @@ packages: engines: {node: '>=6'} dependencies: locate-path: 3.0.0 - dev: false /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} @@ -5880,6 +6086,17 @@ packages: once: 1.4.0 path-is-absolute: 1.0.1 + /glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + dev: true + /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -6006,6 +6223,22 @@ packages: has-symbols: 1.0.3 dev: false + /hash-base@3.1.0: + resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==} + engines: {node: '>=4'} + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + safe-buffer: 5.2.1 + dev: false + + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /hasown@2.0.0: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} @@ -6046,6 +6279,14 @@ packages: source-map: 0.7.4 dev: false + /hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + /hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: @@ -7063,7 +7304,6 @@ packages: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 - dev: false /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} @@ -7196,6 +7436,14 @@ packages: buffer-alloc: 1.2.0 dev: false + /md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + /md5@2.2.1: resolution: {integrity: sha512-PlGG4z5mBANDGCKsYQe0CaUYHdZYZt8ZPZLmEt+Urf0W4GlpTX4HescwHU+dc9+Z/G/vZKYZYFrwgm9VxK6QOQ==} dependencies: @@ -7453,6 +7701,14 @@ packages: braces: 3.0.2 picomatch: 2.3.1 + /miller-rabin@4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + dev: false + /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -7491,11 +7747,26 @@ packages: engines: {node: '>=4'} dev: false + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + + /minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -7986,7 +8257,6 @@ packages: engines: {node: '>=6'} dependencies: p-try: 2.2.0 - dev: false /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -7999,7 +8269,6 @@ packages: engines: {node: '>=6'} dependencies: p-limit: 2.3.0 - dev: false /p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} @@ -8031,7 +8300,6 @@ packages: /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - dev: false /pac-proxy-agent@7.0.1: resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==} @@ -8070,6 +8338,16 @@ packages: dependencies: callsites: 3.1.0 + /parse-asn1@5.1.6: + resolution: {integrity: sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==} + dependencies: + asn1.js: 5.4.1 + browserify-aes: 1.2.0 + evp_bytestokey: 1.0.3 + pbkdf2: 3.1.2 + safe-buffer: 5.2.1 + dev: false + /parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -8126,7 +8404,6 @@ packages: /path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} - dev: false /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -8159,6 +8436,17 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + /pbkdf2@3.1.2: + resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==} + engines: {node: '>=0.12'} + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.2 + safe-buffer: 5.2.1 + sha.js: 2.4.11 + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -8191,6 +8479,13 @@ packages: find-up: 3.0.0 dev: false + /pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + dependencies: + find-up: 3.0.0 + dev: true + /plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -8431,6 +8726,17 @@ packages: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: true + /public-encrypt@4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + dependencies: + bn.js: 4.12.0 + browserify-rsa: 4.1.0 + create-hash: 1.2.0 + parse-asn1: 5.1.6 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + dev: false + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -8486,6 +8792,19 @@ packages: inherits: 2.0.4 dev: false + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /randomfill@1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} + dependencies: + randombytes: 2.1.0 + safe-buffer: 5.2.1 + dev: false + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -8606,6 +8925,34 @@ packages: react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) dev: false + /react-native-quick-base64@2.0.8(react-native@0.73.2)(react@18.2.0): + resolution: {integrity: sha512-2kMlnLSy0qz4NA0KXMGugd3qNB5EAizxZ6ghEVNGIxAOlc9CGvC8miv35wgpFbSKeiaBRfcPfkdTM/5Erb/6SQ==} + peerDependencies: + react: '*' + react-native: '*' + dependencies: + base64-js: 1.5.1 + react: 18.2.0 + react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) + dev: false + + /react-native-quick-crypto@0.6.1(react-native@0.73.2)(react@18.2.0): + resolution: {integrity: sha512-s6uFo7tcI3syo8/y5j+t6Rf+KVSuRKDp6tH04A0vjaHptJC6Iu7DVgkNYO7aqtfrYn8ZUgQ/Kqaq+m4i9TxgIQ==} + peerDependencies: + react: '*' + react-native: '>=0.71.0' + dependencies: + '@craftzdog/react-native-buffer': 6.0.5(react-native@0.73.2)(react@18.2.0) + '@types/node': 17.0.45 + crypto-browserify: 3.12.0 + events: 3.3.0 + react: 18.2.0 + react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) + react-native-quick-base64: 2.0.8(react-native@0.73.2)(react@18.2.0) + stream-browserify: 3.0.0 + string_decoder: 1.3.0 + dev: false + /react-native-reanimated@3.6.2(@babel/core@7.23.9)(@babel/plugin-proposal-nullish-coalescing-operator@7.18.6)(@babel/plugin-proposal-optional-chaining@7.21.0)(@babel/plugin-transform-arrow-functions@7.23.3)(@babel/plugin-transform-shorthand-properties@7.23.3)(@babel/plugin-transform-template-literals@7.23.3)(react-native@0.73.2)(react@18.2.0): resolution: {integrity: sha512-IIMREMOrxhtK35drfpzh2UhxNqAOHnuvGgtMofj7yHcMj16tmWZR2zFvMUf6z2MfmXv+aVgFQ6TRZ6yKYf7LNA==} peerDependencies: @@ -8905,6 +9252,10 @@ packages: resolve: 1.7.1 dev: false + /reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + dev: true + /resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -8995,6 +9346,13 @@ packages: dependencies: glob: 7.2.3 + /ripemd160@2.0.2: + resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + dev: false + /run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -9052,7 +9410,6 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - dev: true /sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} @@ -9191,6 +9548,14 @@ packages: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false + /sha.js@2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + /shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} @@ -9391,6 +9756,13 @@ packages: engines: {node: '>= 0.8'} dev: false + /stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: false + /stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} From 367e4ce8fb7512a0d6a92e7af2136b14e5b2f255 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 12:00:00 +0100 Subject: [PATCH 028/442] chore: use camelcase --- apps/expo/src/app/{video-player.tsx => videoPlayer.tsx} | 0 apps/expo/src/components/item/item.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/expo/src/app/{video-player.tsx => videoPlayer.tsx} (100%) diff --git a/apps/expo/src/app/video-player.tsx b/apps/expo/src/app/videoPlayer.tsx similarity index 100% rename from apps/expo/src/app/video-player.tsx rename to apps/expo/src/app/videoPlayer.tsx diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 9529601..d09c5a9 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -17,7 +17,7 @@ export default function Item({ data }: { data: ItemData }) { const handlePress = () => { router.push({ - pathname: "/video-player", + pathname: "/videoPlayer", params: { data: JSON.stringify(data) }, }); }; From 69e6e4ea250fb27d86a66276e5a0368f002387b9 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 10 Feb 2024 12:33:28 +0100 Subject: [PATCH 029/442] feat: have workflow build on demand via /build comment --- .github/workflows/build-mobile-comment.yml | 116 +++++++++++++++++++++ .github/workflows/build-mobile.yml | 1 + 2 files changed, 117 insertions(+) create mode 100644 .github/workflows/build-mobile-comment.yml diff --git a/.github/workflows/build-mobile-comment.yml b/.github/workflows/build-mobile-comment.yml new file mode 100644 index 0000000..189ccaa --- /dev/null +++ b/.github/workflows/build-mobile-comment.yml @@ -0,0 +1,116 @@ +name: "build mobile app via /build" + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + build-android: + runs-on: ubuntu-latest + if: github.event.issue.pull_request && contains(github.event.comment.body, '/build') + steps: + - uses: xt0rted/pull-request-comment-branch@v2 + id: comment-branch + + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 21 + + - uses: pnpm/action-setup@v2 + name: Install pnpm + with: + version: 8 + run_install: false + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install dependencies + run: pnpm install + + - name: Build Android app + run: cd apps/expo && pnpm run apk + + - name: Rename apk + run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk + + - name: Comment on pull request + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: ./apps/expo/android/app/build/outputs/apk/release/movie-web.apk + + - name: Upload movie-web.apk as artifact + uses: actions/upload-artifact@v4 + with: + name: apk + path: ./apps/expo/android/app/build/outputs/apk/release/movie-web.apk + + build-ios: + runs-on: macos-14 + if: github.event.issue.pull_request && contains(github.event.comment.body, '/build') + steps: + - uses: xt0rted/pull-request-comment-branch@v2 + id: comment-branch + + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + + - name: Xcode Select Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.1.0' + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 21 + + - uses: pnpm/action-setup@v2 + name: Install pnpm + with: + version: 8 + run_install: false + + - name: Install dependencies + run: pnpm install + + - name: Build iOS app + run: cd apps/expo && pnpm run ipa + + - name: Export .ipa from .app + run: | + cd apps/expo + mkdir -p ios/build/Build/Products/Release-iphoneos/Payload + mv ios/build/Build/Products/Release-iphoneos/movieweb.app ios/build/Build/Products/Release-iphoneos/Payload/ + cd ios/build/Build/Products/Release-iphoneos + zip -r ../../../movie-web.ipa Payload + + - name: Comment on pull request + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: ./apps/expo/ios/build/movie-web.ipa + + - name: Upload movie-web.ipa as artifact + uses: actions/upload-artifact@v4 + with: + name: ipa + path: ./apps/expo/ios/build/movie-web.ipa + \ No newline at end of file diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 0b6bd61..58041f8 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + pull-requests: write jobs: build-android: From c52c3309feffae399c0bdf23bb283fcf57f30057 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 15:05:37 +0100 Subject: [PATCH 030/442] chore: adjust fuction name --- apps/expo/src/app/videoPlayer.tsx | 4 ++-- packages/provider-utils/src/video.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index fcdde2c..c1baab4 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -11,7 +11,7 @@ import * as ScreenOrientation from "expo-screen-orientation"; import { findHighestQuality, - getVideoUrl, + getVideoStream, transformSearchResultToScrapeMedia, } from "@movie-web/provider-utils"; import { fetchMediaDetails } from "@movie-web/tmdb"; @@ -67,7 +67,7 @@ const VideoPlayer: React.FC = ({ data }) => { episode, ); - const stream = await getVideoUrl(scrapeMedia); + const stream = await getVideoStream(scrapeMedia); if (!stream) { await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.PORTRAIT_UP, diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 8c6bbb0..aeb3ff6 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -11,7 +11,9 @@ import { targets, } from "@movie-web/providers"; -export async function getVideoUrl(media: ScrapeMedia): Promise { +export async function getVideoStream( + media: ScrapeMedia, +): Promise { const providers = makeProviders({ fetcher: makeStandardFetcher(fetch), target: targets.NATIVE, From 2dd7eb49bb35d5a0ea683625522f73fce5341b2f Mon Sep 17 00:00:00 2001 From: Jorrin Date: Sun, 11 Feb 2024 19:40:20 +0100 Subject: [PATCH 031/442] fix: react-native crypto on android --- apps/expo/app.config.ts | 15 +++++++++++++++ apps/expo/package.json | 1 + pnpm-lock.yaml | 13 +++++++++++++ 3 files changed, 29 insertions(+) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 9c17ebf..7245d46 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -49,6 +49,21 @@ const defineConfig = (): ExpoConfig => ({ initialOrientation: "DEFAULT", }, ], + [ + "expo-build-properties", + { + android: { + packagingOptions: { + pickFirst: [ + "lib/x86/libcrypto.so", + "lib/x86_64/libcrypto.so", + "lib/armeabi-v7a/libcrypto.so", + "lib/arm64-v8a/libcrypto.so", + ], + }, + }, + }, + ], ], }); diff --git a/apps/expo/package.json b/apps/expo/package.json index 6a00d73..b6db05b 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "expo": "~50.0.5", + "expo-build-properties": "~0.11.1", "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", "expo-router": "~3.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd655ac..8855a13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: expo: specifier: ~50.0.5 version: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + expo-build-properties: + specifier: ~0.11.1 + version: 0.11.1(expo@50.0.5) expo-constants: specifier: ~15.4.5 version: 15.4.5(expo@50.0.5) @@ -5491,6 +5494,16 @@ packages: - supports-color dev: false + /expo-build-properties@0.11.1(expo@50.0.5): + resolution: {integrity: sha512-m4j4aEjFaDuBE6KWYMxDhWgLzzSmpE7uHKAwtvXyNmRK+6JKF0gjiXi0sXgI5ngNppDQpsyPFMvqG7uQpRuCuw==} + peerDependencies: + expo: '*' + dependencies: + ajv: 8.12.0 + expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + semver: 7.5.4 + dev: false + /expo-constants@15.4.5(expo@50.0.5): resolution: {integrity: sha512-1pVVjwk733hbbIjtQcvUFCme540v4gFemdNlaxM2UXKbfRCOh2hzgKN5joHMOysoXQe736TTUrRj7UaZI5Yyhg==} peerDependencies: From 63512d2596765e2316feb9be3bc0ead7586b2d7c Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:07:30 +0100 Subject: [PATCH 032/442] chore: add default fullscreen orientation value --- apps/expo/src/app/videoPlayer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index c1baab4..9b7f561 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -148,6 +148,7 @@ const VideoPlayer: React.FC = ({ data }) => { // textTracks={textTracks} // breaks playback className="absolute inset-0" fullscreen={true} + fullscreenOrientation="landscape" paused={false} controls={true} onLoadStart={onVideoLoadStart} From 07096f0dec3943bceac222cd17dbd44595d8d1e7 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:18:59 +0100 Subject: [PATCH 033/442] chore: prettier --- apps/expo/src/app/videoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 9b7f561..bbe58bc 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -148,7 +148,7 @@ const VideoPlayer: React.FC = ({ data }) => { // textTracks={textTracks} // breaks playback className="absolute inset-0" fullscreen={true} - fullscreenOrientation="landscape" + fullscreenOrientation="landscape" paused={false} controls={true} onLoadStart={onVideoLoadStart} From 7483d6b973b87a8614ec9498506c20aee6981cb2 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:31:42 +0100 Subject: [PATCH 034/442] fix: iOS only supports vtt captions --- apps/expo/app.config.ts | 2 +- apps/expo/src/app/videoPlayer.tsx | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 7245d46..d03a74d 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -46,7 +46,7 @@ const defineConfig = (): ExpoConfig => ({ [ "expo-screen-orientation", { - initialOrientation: "DEFAULT", + initialOrientation: "PORTRAIT_UP", }, ], [ diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index bbe58bc..5450ad4 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -4,7 +4,7 @@ import type { TextTracks, } from "react-native-video"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, View } from "react-native"; +import { ActivityIndicator, Platform, View } from "react-native"; import Video, { TextTracksType } from "react-native-video"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; @@ -173,10 +173,16 @@ const captionTypeToTextTracksType = { }; function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { - return captions.map((caption) => ({ - title: caption.language, - language: caption.language as ISO639_1, - type: captionTypeToTextTracksType[caption.type] || TextTracksType.VTT, - uri: caption.url, - })); + return captions.map((caption) => { + if (Platform.OS === "ios" && caption.type !== "vtt") { + return null; + } + + return { + title: caption.language, + language: caption.language as ISO639_1, + type: captionTypeToTextTracksType[caption.type], + uri: caption.url, + }; + }).filter(Boolean) as TextTracks; } From 51e24bec27259723ea9cd3f6ebc04299dbffafc5 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:32:26 +0100 Subject: [PATCH 035/442] chore: prettier --- apps/expo/src/app/videoPlayer.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 5450ad4..fc9416e 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -173,16 +173,18 @@ const captionTypeToTextTracksType = { }; function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { - return captions.map((caption) => { - if (Platform.OS === "ios" && caption.type !== "vtt") { - return null; - } + return captions + .map((caption) => { + if (Platform.OS === "ios" && caption.type !== "vtt") { + return null; + } - return { - title: caption.language, - language: caption.language as ISO639_1, - type: captionTypeToTextTracksType[caption.type], - uri: caption.url, - }; - }).filter(Boolean) as TextTracks; + return { + title: caption.language, + language: caption.language as ISO639_1, + type: captionTypeToTextTracksType[caption.type], + uri: caption.url, + }; + }) + .filter(Boolean) as TextTracks; } From b139a4a7ff64af08572b051ba51879c1585f28fd Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:40:31 +0100 Subject: [PATCH 036/442] chore: comment detail --- apps/expo/src/app/videoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index fc9416e..81811bb 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -145,7 +145,7 @@ const VideoPlayer: React.FC = ({ data }) => { %nq@!sVOiM8ZTb$tdTYTQdj6WbQmS-ig0L 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 literal 0 HcmV?d00001 diff --git a/apps/expo/src/app/(tabs)/search/Searchbar.tsx b/apps/expo/src/app/(tabs)/search/Searchbar.tsx index 5de78f4..c26a4e2 100644 --- a/apps/expo/src/app/(tabs)/search/Searchbar.tsx +++ b/apps/expo/src/app/(tabs)/search/Searchbar.tsx @@ -42,7 +42,7 @@ export default function Searchbar({ ref={inputRef} placeholder="What are you looking for?" placeholderTextColor={Colors.secondary[200]} - className="w-full rounded-3xl py-3 pr-5 text-white focus-visible:outline-none" + className="w-full rounded-3xl py-3 pr-5 text-white" /> ); diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index c1baab4..f96e9a6 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -17,14 +17,19 @@ import { import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; -import { usePlayer } from "../hooks/usePlayer"; +import { Header } from "~/components/player/Header"; +import { PlayerProvider, usePlayer } from "~/context/player.context"; export default function VideoPlayerWrapper() { const params = useLocalSearchParams(); const data = params.data ? (JSON.parse(params.data as string) as ItemData) : null; - return ; + return ( + + + + ); } interface VideoPlayerProps { @@ -37,7 +42,7 @@ const VideoPlayer: React.FC = ({ data }) => { const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const { - videoRef, + setVideoRef, unlockOrientation, presentFullscreenPlayer, dismissFullscreenPlayer, @@ -99,6 +104,8 @@ const VideoPlayer: React.FC = ({ data }) => { : [], ); + console.log("stream", url); + setVideoSrc({ uri: url, headers: { @@ -143,17 +150,19 @@ const VideoPlayer: React.FC = ({ data }) => { return (

} ); }; diff --git a/apps/expo/src/components/player/BackButton.tsx b/apps/expo/src/components/player/BackButton.tsx new file mode 100644 index 0000000..f7a14c5 --- /dev/null +++ b/apps/expo/src/components/player/BackButton.tsx @@ -0,0 +1,29 @@ +import { useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; + +import { usePlayer } from "~/context/player.context"; + +export const BackButton = ({ + className, +}: Partial>) => { + const { unlockOrientation } = usePlayer(); + const router = useRouter(); + + return ( + { + unlockOrientation() + .then(() => { + return router.back(); + }) + .catch(() => { + return router.back(); + }); + }} + size={36} + color="white" + className={className} + /> + ); +}; diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx new file mode 100644 index 0000000..4d6daa5 --- /dev/null +++ b/apps/expo/src/components/player/Header.tsx @@ -0,0 +1,22 @@ +import { Image, View } from "react-native"; + +import Icon from "../../../assets/images/icon-transparent.png"; +import { Text } from "../ui/Text"; +import { BackButton } from "./BackButton"; + +interface HeaderProps { + title: string; +} + +export const Header = ({ title }: HeaderProps) => { + return ( + + + {title} + + + movie-web + + + ); +}; diff --git a/apps/expo/src/context/player.context.tsx b/apps/expo/src/context/player.context.tsx new file mode 100644 index 0000000..1f67275 --- /dev/null +++ b/apps/expo/src/context/player.context.tsx @@ -0,0 +1,77 @@ +import type { VideoRef } from "react-native-video"; +import React, { createContext, useCallback, useContext, useState } from "react"; +import * as ScreenOrientation from "expo-screen-orientation"; + +interface PlayerContextProps { + videoRef: VideoRef | null; + setVideoRef: (ref: VideoRef | null) => void; + lockOrientation: () => Promise; + unlockOrientation: () => Promise; + presentFullscreenPlayer: () => Promise; + dismissFullscreenPlayer: () => Promise; +} + +interface PlayerProviderProps { + children: React.ReactNode; +} + +const PlayerContext = createContext(undefined); + +export const PlayerProvider = ({ children }: PlayerProviderProps) => { + const [internalVideoRef, setInternalVideoRef] = useState( + null, + ); + + const setVideoRef = useCallback((ref: VideoRef | null) => { + setInternalVideoRef(ref); + }, []); + + const lockOrientation = useCallback(async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE, + ); + }, []); + + const unlockOrientation = useCallback(async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }, []); + + const presentFullscreenPlayer = useCallback(async () => { + if (internalVideoRef) { + internalVideoRef.presentFullscreenPlayer(); + await lockOrientation(); + } + }, [internalVideoRef, lockOrientation]); + + const dismissFullscreenPlayer = useCallback(async () => { + if (internalVideoRef) { + internalVideoRef.dismissFullscreenPlayer(); + await unlockOrientation(); + } + }, [internalVideoRef, unlockOrientation]); + + const contextValue: PlayerContextProps = { + videoRef: internalVideoRef, + setVideoRef, + lockOrientation, + unlockOrientation, + presentFullscreenPlayer, + dismissFullscreenPlayer, + }; + + return ( + + {children} + + ); +}; + +export const usePlayer = (): PlayerContextProps => { + const context = useContext(PlayerContext); + if (!context) { + throw new Error("usePlayer must be used within a PlayerProvider"); + } + return context; +}; diff --git a/apps/expo/src/hooks/usePlayer.ts b/apps/expo/src/hooks/usePlayer.ts deleted file mode 100644 index 093268c..0000000 --- a/apps/expo/src/hooks/usePlayer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { VideoRef } from "react-native-video"; -import { useCallback, useRef } from "react"; -import * as ScreenOrientation from "expo-screen-orientation"; - -export const usePlayer = () => { - const ref = useRef(null); - - const lockOrientation = useCallback(async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.LANDSCAPE, - ); - }, []); - - const unlockOrientation = useCallback(async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - }, []); - - const presentFullscreenPlayer = useCallback(async () => { - if (ref.current) { - ref.current.presentFullscreenPlayer(); - await lockOrientation(); - } - }, [lockOrientation]); - - const dismissFullscreenPlayer = useCallback(async () => { - if (ref.current) { - ref.current.dismissFullscreenPlayer(); - await unlockOrientation(); - } - }, [unlockOrientation]); - - return { - videoRef: ref, - lockOrientation, - unlockOrientation, - presentFullscreenPlayer, - dismissFullscreenPlayer, - } as const; -}; diff --git a/apps/expo/src/types/globals.d.ts b/apps/expo/src/types/globals.d.ts new file mode 100644 index 0000000..5b5b91a --- /dev/null +++ b/apps/expo/src/types/globals.d.ts @@ -0,0 +1,11 @@ +declare module "*.svg" { + import type { ImageSourcePropType } from "react-native"; + const content: ImageSourcePropType; + export default content; +} + +declare module "*.png" { + import type { ImageSourcePropType } from "react-native"; + const content: ImageSourcePropType; + export default content; +} diff --git a/package.json b/package.json index 412b339..9eaee03 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,6 @@ "build": "turbo build", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "db:push": "pnpm -F db push", - "db:studio": "pnpm -F db studio", "dev": "turbo dev --parallel", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", "format:fix": "turbo format --continue -- --write --cache --cache-location node_modules/.cache/.prettiercache", diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index 01abe92..fda78cb 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -29,7 +29,7 @@ }, "prettier": "@movie-web/prettier-config", "dependencies": { - "@movie-web/providers": "^2.1.1", + "@movie-web/providers": "^2.2.0", "tmdb-ts": "^1.6.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8855a13..0e927ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,8 +168,8 @@ importers: packages/provider-utils: dependencies: '@movie-web/providers': - specifier: ^2.1.1 - version: 2.1.1 + specifier: ^2.2.0 + version: 2.2.0 tmdb-ts: specifier: ^1.6.1 version: 1.6.1 @@ -2348,15 +2348,17 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@movie-web/providers@2.1.1: - resolution: {integrity: sha512-g2CA/w3YlGw3b3v6yDSgUIUdym4rFs4CzZOo/OlyL4HtsFH9mk182ukt7HYSxgddCEJRjl81LZZc3/pLRIGcMA==} + /@movie-web/providers@2.2.0: + resolution: {integrity: sha512-7rKUpLPklwOtS5P2CAeh0P3sPIuYvtkKIgm0kVMp+OsSpKd9IcuYm79bbDrA0MDi3IMGik1W6la9Mzy91+8uYQ==} dependencies: cheerio: 1.0.0-rc.12 + cookie: 0.6.0 crypto-js: 4.2.0 form-data: 4.0.0 iso-639-1: 3.1.0 nanoid: 3.3.7 node-fetch: 2.7.0 + set-cookie-parser: 2.6.0 unpacker: 1.0.1 transitivePeerDependencies: - encoding @@ -4543,6 +4545,11 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: false + /core-js-compat@3.35.1: resolution: {integrity: sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==} dependencies: diff --git a/tooling/tailwind/colors.ts b/tooling/tailwind/colors.ts index 91235f7..fcd7e86 100644 --- a/tooling/tailwind/colors.ts +++ b/tooling/tailwind/colors.ts @@ -10,5 +10,9 @@ export default { 300: "#32324F", 700: "#131322", }, + iconBackground: "#14141c", + icon: { + background: "#14141c", + }, background: "#0a0a12", }; From 08463222e580821674bffd1583b28410e7047cc6 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Sun, 11 Feb 2024 23:06:48 +0100 Subject: [PATCH 038/442] remove non used colors --- tooling/tailwind/colors.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tooling/tailwind/colors.ts b/tooling/tailwind/colors.ts index fcd7e86..91235f7 100644 --- a/tooling/tailwind/colors.ts +++ b/tooling/tailwind/colors.ts @@ -10,9 +10,5 @@ export default { 300: "#32324F", 700: "#131322", }, - iconBackground: "#14141c", - icon: { - background: "#14141c", - }, background: "#0a0a12", }; From c5a5fd8eb6b95088133c5952f8a6f2a8fd6c64e0 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 02:39:19 +0100 Subject: [PATCH 039/442] nativewind not working on video player --- apps/expo/src/app/videoPlayer.tsx | 14 ++++++++++++-- apps/expo/src/components/player/Header.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 8a64892..7ea930d 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -4,7 +4,7 @@ import type { TextTracks, } from "react-native-video"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; import Video, { TextTracksType } from "react-native-video"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; @@ -153,7 +153,6 @@ const VideoPlayer: React.FC = ({ data }) => { ref={setVideoRef} source={videoSrc} // textTracks={textTracks} // breaks playback on iOS, see pr body - className="absolute inset-0" fullscreen fullscreenOrientation="landscape" paused={false} @@ -161,6 +160,7 @@ const VideoPlayer: React.FC = ({ data }) => { useSecureView onLoadStart={onVideoLoadStart} onReadyForDisplay={onReadyForDisplay} + style={styles.backgroundVideo} /> {isLoading && } {!isLoading &&
} @@ -197,3 +197,13 @@ function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { }) .filter(Boolean) as TextTracks; } + +const styles = StyleSheet.create({ + backgroundVideo: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + }, +}); diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 4d6daa5..0efd7ec 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -13,7 +13,7 @@ export const Header = ({ title }: HeaderProps) => { {title} - + movie-web From 3d1a5a88f2f1f45b1ec94cc8b38652a3dec833e8 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:41:42 +0100 Subject: [PATCH 040/442] chore: cleanup & pass title to header --- apps/expo/src/app/videoPlayer.tsx | 4 +--- apps/expo/src/components/player/Header.tsx | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 7ea930d..af4abaf 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -104,8 +104,6 @@ const VideoPlayer: React.FC = ({ data }) => { : [], ); - console.log("stream", url); - setVideoSrc({ uri: url, headers: { @@ -163,7 +161,7 @@ const VideoPlayer: React.FC = ({ data }) => { style={styles.backgroundVideo} /> {isLoading && } - {!isLoading &&
} + {!isLoading &&
} ); }; diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 0efd7ec..8fac95a 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Image, View } from "react-native"; import Icon from "../../../assets/images/icon-transparent.png"; From 33a62752e2ed7b2ee0cc50dac1a183a79efc0bcc Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:26:54 +0100 Subject: [PATCH 041/442] feat: convert srt to vtt if required --- apps/expo/src/app/videoPlayer.tsx | 7 ++++-- packages/provider-utils/package.json | 1 + packages/provider-utils/src/video.ts | 32 ++++++++++++++++++++++------ pnpm-lock.yaml | 7 ++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index af4abaf..83358d3 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -53,7 +53,7 @@ const VideoPlayer: React.FC = ({ data }) => { const fetchVideo = async () => { if (!data) return null; const { id, type } = data; - const media = await fetchMediaDetails(id, type); + const media = await fetchMediaDetails(id, type).catch(() => null); if (!media) return null; const { result } = media; @@ -72,7 +72,10 @@ const VideoPlayer: React.FC = ({ data }) => { episode, ); - const stream = await getVideoStream(scrapeMedia); + const stream = await getVideoStream({ + media: scrapeMedia, + forceVTT: true, + }).catch(() => null); if (!stream) { await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.PORTRAIT_UP, diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index fda78cb..4f0f106 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -30,6 +30,7 @@ "prettier": "@movie-web/prettier-config", "dependencies": { "@movie-web/providers": "^2.2.0", + "srt-webvtt": "^2.0.0", "tmdb-ts": "^1.6.1" } } diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index aeb3ff6..d27c56b 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,3 +1,5 @@ +import { default as toWebVTT } from "srt-webvtt"; + import type { FileBasedStream, Qualities, @@ -11,9 +13,13 @@ import { targets, } from "@movie-web/providers"; -export async function getVideoStream( - media: ScrapeMedia, -): Promise { +export async function getVideoStream({ + media, + forceVTT, +}: { + media: ScrapeMedia; + forceVTT?: boolean; +}): Promise { const providers = makeProviders({ fetcher: makeStandardFetcher(fetch), target: targets.NATIVE, @@ -24,9 +30,23 @@ export async function getVideoStream( media, }; - const results = await providers.runAll(options); - if (!results) return null; - return results.stream; + const result = await providers.runAll(options); + if (!result) return null; + + if (forceVTT) { + if (result.stream.captions && result.stream.captions.length > 0) { + for (const caption of result.stream.captions) { + if (caption.type === "srt") { + const response = await fetch(caption.url); + const srtSubtitle = await response.blob(); + const vttSubtitleUrl = await toWebVTT(srtSubtitle); + caption.url = vttSubtitleUrl; + caption.type = "vtt"; + } + } + } + } + return result.stream; } export function findHighestQuality( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e927ae..b5a8f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@movie-web/providers': specifier: ^2.2.0 version: 2.2.0 + srt-webvtt: + specifier: ^2.0.0 + version: 2.0.0 tmdb-ts: specifier: ^1.6.1 version: 1.6.1 @@ -9741,6 +9744,10 @@ packages: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: false + /srt-webvtt@2.0.0: + resolution: {integrity: sha512-G2Z7/Jf2NRKrmLYNSIhSYZZYE6OFlKXFp9Au2/zJBKgrioUzmrAys1x7GT01dwl6d2sEnqr5uahEIOd0JW/Rbw==} + dev: false + /ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} From 70d074f3861994f5ddff039a4217ac3bd51585f4 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:28:12 +0100 Subject: [PATCH 042/442] chore: cleanup --- packages/provider-utils/src/video.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index d27c56b..f32310f 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -3,7 +3,6 @@ import { default as toWebVTT } from "srt-webvtt"; import type { FileBasedStream, Qualities, - RunnerOptions, ScrapeMedia, Stream, } from "@movie-web/providers"; @@ -26,11 +25,7 @@ export async function getVideoStream({ consistentIpForRequests: true, }); - const options: RunnerOptions = { - media, - }; - - const result = await providers.runAll(options); + const result = await providers.runAll({ media }); if (!result) return null; if (forceVTT) { From 094c0382a64910b3abba44256e9619e6295e2e2d Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 16:11:35 +0100 Subject: [PATCH 043/442] introduce store with idle tracking --- apps/expo/package.json | 6 +- apps/expo/src/app/videoPlayer.tsx | 125 ++++++++---------- .../expo/src/components/player/BackButton.tsx | 4 +- apps/expo/src/components/player/Controls.tsx | 13 ++ apps/expo/src/components/player/Header.tsx | 18 +-- apps/expo/src/context/player.context.tsx | 77 ----------- .../src/stores/player/slices/interface.ts | 48 +++++++ apps/expo/src/stores/player/slices/types.ts | 13 ++ apps/expo/src/stores/player/slices/video.ts | 25 ++++ apps/expo/src/stores/player/store.ts | 13 ++ pnpm-lock.yaml | 67 +++++++--- tooling/eslint/react.js | 1 + 12 files changed, 234 insertions(+), 176 deletions(-) create mode 100644 apps/expo/src/components/player/Controls.tsx delete mode 100644 apps/expo/src/context/player.context.tsx create mode 100644 apps/expo/src/stores/player/slices/interface.ts create mode 100644 apps/expo/src/stores/player/slices/types.ts create mode 100644 apps/expo/src/stores/player/slices/video.ts create mode 100644 apps/expo/src/stores/player/store.ts diff --git a/apps/expo/package.json b/apps/expo/package.json index b6db05b..df8453a 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "expo": "~50.0.5", + "expo-av": "~13.10.5", "expo-build-properties": "~0.11.1", "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", @@ -34,6 +35,7 @@ "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "expo-web-browser": "^12.8.2", + "immer": "^10.0.3", "nativewind": "~4.0.23", "react": "18.2.0", "react-dom": "18.2.0", @@ -45,9 +47,9 @@ "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "~4.8.2", "react-native-screens": "~3.29.0", - "react-native-video": "6.0.0-beta.5", "react-native-web": "^0.19.10", - "tailwind-merge": "^2.2.1" + "tailwind-merge": "^2.2.1", + "zustand": "^4.4.7" }, "devDependencies": { "@babel/core": "^7.23.9", diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index af4abaf..e79eeca 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -1,11 +1,7 @@ -import type { - ISO639_1, - ReactVideoSource, - TextTracks, -} from "react-native-video"; +import type { AVPlaybackSource } from "expo-av"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; -import Video, { TextTracksType } from "react-native-video"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { ResizeMode, Video } from "expo-av"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; @@ -18,18 +14,14 @@ import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; import { Header } from "~/components/player/Header"; -import { PlayerProvider, usePlayer } from "~/context/player.context"; +import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { const params = useLocalSearchParams(); const data = params.data ? (JSON.parse(params.data as string) as ItemData) : null; - return ( - - - - ); + return ; } interface VideoPlayerProps { @@ -37,16 +29,18 @@ interface VideoPlayerProps { } const VideoPlayer: React.FC = ({ data }) => { - const [videoSrc, setVideoSrc] = useState(); - const [_textTracks, setTextTracks] = useState([]); + const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); const router = useRouter(); - const { - setVideoRef, - unlockOrientation, - presentFullscreenPlayer, - dismissFullscreenPlayer, - } = usePlayer(); + const setVideoRef = usePlayerStore((state) => state.setVideoRef); + const setStatus = usePlayerStore((state) => state.setStatus); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); + const presentFullscreenPlayer = usePlayerStore( + (state) => state.presentFullscreenPlayer, + ); + const dismissFullscreenPlayer = usePlayerStore( + (state) => state.dismissFullscreenPlayer, + ); useEffect(() => { const initializePlayer = async () => { @@ -98,11 +92,11 @@ const VideoPlayer: React.FC = ({ data }) => { url = stream.playlist; } - setTextTracks( - stream.captions && stream.captions.length > 0 - ? convertCaptionsToTextTracks(stream.captions) - : [], - ); + // setTextTracks( + // stream.captions && stream.captions.length > 0 + // ? convertCaptionsToTextTracks(stream.captions) + // : [], + // ); setVideoSrc({ uri: url, @@ -127,15 +121,8 @@ const VideoPlayer: React.FC = ({ data }) => { return () => { void dismissFullscreenPlayer(); - void unlockOrientation(); }; - }, [ - data, - dismissFullscreenPlayer, - presentFullscreenPlayer, - router, - unlockOrientation, - ]); + }, [data, dismissFullscreenPlayer, presentFullscreenPlayer, router]); const onVideoLoadStart = () => { setIsLoading(true); @@ -150,58 +137,56 @@ const VideoPlayer: React.FC = ({ data }) => {
} + {!isLoading && data &&
} ); }; -interface Caption { - type: "srt" | "vtt"; - id: string; - url: string; - hasCorsRestrictions: boolean; - language: string; -} +// interface Caption { +// type: "srt" | "vtt"; +// id: string; +// url: string; +// hasCorsRestrictions: boolean; +// language: string; +// } -const captionTypeToTextTracksType = { - srt: TextTracksType.SUBRIP, - vtt: TextTracksType.VTT, -}; +// const captionTypeToTextTracksType = { +// srt: TextTracksType.SUBRIP, +// vtt: TextTracksType.VTT, +// }; -function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { - return captions - .map((caption) => { - if (Platform.OS === "ios" && caption.type !== "vtt") { - return null; - } +// function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { +// return captions +// .map((caption) => { +// if (Platform.OS === "ios" && caption.type !== "vtt") { +// return null; +// } - return { - title: caption.language, - language: caption.language as ISO639_1, - type: captionTypeToTextTracksType[caption.type], - uri: caption.url, - }; - }) - .filter(Boolean) as TextTracks; -} +// return { +// title: caption.language, +// language: caption.language as ISO639_1, +// type: captionTypeToTextTracksType[caption.type], +// uri: caption.url, +// }; +// }) +// .filter(Boolean) as TextTracks; +// } const styles = StyleSheet.create({ - backgroundVideo: { + video: { position: "absolute", top: 0, - left: 0, bottom: 0, + left: 0, right: 0, }, }); diff --git a/apps/expo/src/components/player/BackButton.tsx b/apps/expo/src/components/player/BackButton.tsx index f7a14c5..d027cf7 100644 --- a/apps/expo/src/components/player/BackButton.tsx +++ b/apps/expo/src/components/player/BackButton.tsx @@ -1,12 +1,12 @@ import { useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; -import { usePlayer } from "~/context/player.context"; +import { usePlayerStore } from "~/stores/player/store"; export const BackButton = ({ className, }: Partial>) => { - const { unlockOrientation } = usePlayer(); + const unlockOrientation = usePlayerStore((state) => state.unlockOrientation); const router = useRouter(); return ( diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx new file mode 100644 index 0000000..cd4ef5d --- /dev/null +++ b/apps/expo/src/components/player/Controls.tsx @@ -0,0 +1,13 @@ +import { View } from "react-native"; + +import { usePlayerStore } from "~/stores/player/store"; + +interface ControlsProps { + children: React.ReactNode; +} + +export const Controls = ({ children }: ControlsProps) => { + const idle = usePlayerStore((state) => state.interface.isIdle); + + return {!idle && children}; +}; diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 8fac95a..8c25fb0 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -1,9 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Image, View } from "react-native"; import Icon from "../../../assets/images/icon-transparent.png"; import { Text } from "../ui/Text"; import { BackButton } from "./BackButton"; +import { Controls } from "./Controls"; interface HeaderProps { title: string; @@ -11,13 +11,15 @@ interface HeaderProps { export const Header = ({ title }: HeaderProps) => { return ( - - - {title} - - - movie-web + + + + {title} + + + movie-web + - + ); }; diff --git a/apps/expo/src/context/player.context.tsx b/apps/expo/src/context/player.context.tsx deleted file mode 100644 index 1f67275..0000000 --- a/apps/expo/src/context/player.context.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { VideoRef } from "react-native-video"; -import React, { createContext, useCallback, useContext, useState } from "react"; -import * as ScreenOrientation from "expo-screen-orientation"; - -interface PlayerContextProps { - videoRef: VideoRef | null; - setVideoRef: (ref: VideoRef | null) => void; - lockOrientation: () => Promise; - unlockOrientation: () => Promise; - presentFullscreenPlayer: () => Promise; - dismissFullscreenPlayer: () => Promise; -} - -interface PlayerProviderProps { - children: React.ReactNode; -} - -const PlayerContext = createContext(undefined); - -export const PlayerProvider = ({ children }: PlayerProviderProps) => { - const [internalVideoRef, setInternalVideoRef] = useState( - null, - ); - - const setVideoRef = useCallback((ref: VideoRef | null) => { - setInternalVideoRef(ref); - }, []); - - const lockOrientation = useCallback(async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.LANDSCAPE, - ); - }, []); - - const unlockOrientation = useCallback(async () => { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - }, []); - - const presentFullscreenPlayer = useCallback(async () => { - if (internalVideoRef) { - internalVideoRef.presentFullscreenPlayer(); - await lockOrientation(); - } - }, [internalVideoRef, lockOrientation]); - - const dismissFullscreenPlayer = useCallback(async () => { - if (internalVideoRef) { - internalVideoRef.dismissFullscreenPlayer(); - await unlockOrientation(); - } - }, [internalVideoRef, unlockOrientation]); - - const contextValue: PlayerContextProps = { - videoRef: internalVideoRef, - setVideoRef, - lockOrientation, - unlockOrientation, - presentFullscreenPlayer, - dismissFullscreenPlayer, - }; - - return ( - - {children} - - ); -}; - -export const usePlayer = (): PlayerContextProps => { - const context = useContext(PlayerContext); - if (!context) { - throw new Error("usePlayer must be used within a PlayerProvider"); - } - return context; -}; diff --git a/apps/expo/src/stores/player/slices/interface.ts b/apps/expo/src/stores/player/slices/interface.ts new file mode 100644 index 0000000..287e145 --- /dev/null +++ b/apps/expo/src/stores/player/slices/interface.ts @@ -0,0 +1,48 @@ +import * as ScreenOrientation from "expo-screen-orientation"; + +import type { MakeSlice } from "./types"; + +export interface InterfaceSlice { + interface: { + isIdle: boolean; + }; + setIsIdle(state: boolean): void; + lockOrientation: () => Promise; + unlockOrientation: () => Promise; + presentFullscreenPlayer: () => Promise; + dismissFullscreenPlayer: () => Promise; +} + +export const createInterfaceSlice: MakeSlice = (set, get) => ({ + interface: { + isIdle: true, + }, + setIsIdle: (state) => { + set((s) => { + setTimeout(() => { + if (get().interface.isIdle === false) { + set((s) => { + s.interface.isIdle = true; + }); + } + }, 6000); + s.interface.isIdle = state; + }); + }, + lockOrientation: async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE, + ); + }, + unlockOrientation: async () => { + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }, + presentFullscreenPlayer: async () => { + await get().lockOrientation(); + }, + dismissFullscreenPlayer: async () => { + await get().unlockOrientation(); + }, +}); diff --git a/apps/expo/src/stores/player/slices/types.ts b/apps/expo/src/stores/player/slices/types.ts new file mode 100644 index 0000000..10ab80c --- /dev/null +++ b/apps/expo/src/stores/player/slices/types.ts @@ -0,0 +1,13 @@ +import type { StateCreator } from "zustand"; + +import type { InterfaceSlice } from "./interface"; +import type { VideoSlice } from "./video"; + +export type AllSlices = InterfaceSlice & VideoSlice; + +export type MakeSlice = StateCreator< + AllSlices, + [["zustand/immer", never]], + [], + Slice +>; diff --git a/apps/expo/src/stores/player/slices/video.ts b/apps/expo/src/stores/player/slices/video.ts new file mode 100644 index 0000000..a1542e6 --- /dev/null +++ b/apps/expo/src/stores/player/slices/video.ts @@ -0,0 +1,25 @@ +import type { AVPlaybackStatus, Video } from "expo-av"; + +import type { MakeSlice } from "./types"; + +export interface VideoSlice { + videoRef: Video | null; + status: AVPlaybackStatus | null; + + setVideoRef(ref: Video | null): void; + setStatus(status: AVPlaybackStatus | null): void; +} + +export const createVideoSlice: MakeSlice = (set) => ({ + videoRef: null, + status: null, + + setVideoRef: (ref) => { + set({ videoRef: ref }); + }, + setStatus: (status) => { + set((s) => { + s.status = status; + }); + }, +}); diff --git a/apps/expo/src/stores/player/store.ts b/apps/expo/src/stores/player/store.ts new file mode 100644 index 0000000..fabf236 --- /dev/null +++ b/apps/expo/src/stores/player/store.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; + +import type { AllSlices } from "./slices/types"; +import { createInterfaceSlice } from "./slices/interface"; +import { createVideoSlice } from "./slices/video"; + +export const usePlayerStore = create( + immer((...a) => ({ + ...createInterfaceSlice(...a), + ...createVideoSlice(...a), + })), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e927ae..abd8b65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: expo: specifier: ~50.0.5 version: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + expo-av: + specifier: ~13.10.5 + version: 13.10.5(expo@50.0.5) expo-build-properties: specifier: ~0.11.1 version: 0.11.1(expo@50.0.5) @@ -79,6 +82,9 @@ importers: expo-web-browser: specifier: ^12.8.2 version: 12.8.2(expo@50.0.5) + immer: + specifier: ^10.0.3 + version: 10.0.3 nativewind: specifier: ~4.0.23 version: 4.0.23(patch_hash=42qwizvrnoqgalbele35lpnaqi)(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1) @@ -112,15 +118,15 @@ importers: react-native-screens: specifier: ~3.29.0 version: 3.29.0(react-native@0.73.2)(react@18.2.0) - react-native-video: - specifier: 6.0.0-beta.5 - version: 6.0.0-beta.5(react-native@0.73.2)(react@18.2.0) react-native-web: specifier: ^0.19.10 version: 0.19.10(react-dom@18.2.0)(react@18.2.0) tailwind-merge: specifier: ^2.2.1 version: 2.2.1 + zustand: + specifier: ^4.4.7 + version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0) devDependencies: '@babel/core': specifier: ^7.23.9 @@ -3193,7 +3199,6 @@ packages: /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} - dev: true /@types/react@18.2.52: resolution: {integrity: sha512-E/YjWh3tH+qsLKaUzgpZb5AY0ChVa+ZJzF7ogehVILrFpdQk6nC/WXOv0bfFEABbXbgNxLBGU7IIZByPKb6eBw==} @@ -3201,11 +3206,9 @@ packages: '@types/prop-types': 15.7.11 '@types/scheduler': 0.16.8 csstype: 3.1.3 - dev: true /@types/scheduler@0.16.8: resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} - dev: true /@types/semver@7.5.6: resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} @@ -4700,7 +4703,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /dag-map@1.0.2: resolution: {integrity: sha512-+LSAiGFwQ9dRnRdOeaj7g47ZFJcOUPukAP8J3A3fuZ1g9Y44BG+P1sgApjLXTQPOzC4+7S9Wr8kXsfpINM4jpw==} @@ -5501,6 +5503,14 @@ packages: - supports-color dev: false + /expo-av@13.10.5(expo@50.0.5): + resolution: {integrity: sha512-w45oCoe+8PunDeM0rh/Ut6UaGh7OjEJOCjAiQy3nCxpA8FaXB17KaqpsvkAXIMvceHYWndH8+D29esUTS6wEsA==} + peerDependencies: + expo: '*' + dependencies: + expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + dev: false + /expo-build-properties@0.11.1(expo@50.0.5): resolution: {integrity: sha512-m4j4aEjFaDuBE6KWYMxDhWgLzzSmpE7uHKAwtvXyNmRK+6JKF0gjiXi0sXgI5ngNppDQpsyPFMvqG7uQpRuCuw==} peerDependencies: @@ -6400,6 +6410,10 @@ packages: queue: 6.0.2 dev: false + /immer@10.0.3: + resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} + dev: false + /import-fresh@2.0.0: resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} engines: {node: '>=4'} @@ -9021,16 +9035,6 @@ packages: warn-once: 0.1.1 dev: false - /react-native-video@6.0.0-beta.5(react-native@0.73.2)(react@18.2.0): - resolution: {integrity: sha512-dAfIXvtxsMI8TE3Q+1MHTP1brq3/V2VsPKVDtU8E+JcF963y5upnBb8JFiG8Yl4s4qAoQum2P02fZE30stQOHg==} - peerDependencies: - react: '*' - react-native: '*' - dependencies: - react: 18.2.0 - react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) - dev: false - /react-native-web@0.19.10(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-IQoHiTQq8egBCVVwmTrYcFLgEFyb4LMZYEktHn4k22JMk9+QTCEz5WTfvr+jdNoeqj/7rtE81xgowKbfGO74qg==} peerDependencies: @@ -10543,6 +10547,14 @@ packages: react: 18.2.0 dev: false + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -10913,3 +10925,24 @@ packages: /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + + /zustand@4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0): + resolution: {integrity: sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.52 + immer: 10.0.3 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false diff --git a/tooling/eslint/react.js b/tooling/eslint/react.js index 618e181..2b2b160 100644 --- a/tooling/eslint/react.js +++ b/tooling/eslint/react.js @@ -7,6 +7,7 @@ const config = { ], rules: { "react/prop-types": "off", + "@typescript-eslint/unbound-method": "off", }, globals: { React: "writable", From 66ac4730bd3c9f3bfdf505d15614302276c113f7 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 16:13:42 +0100 Subject: [PATCH 044/442] cleanup --- apps/expo/src/app/videoPlayer.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 8d3a4e0..b09bb5b 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useState } from "react"; import { ActivityIndicator, StyleSheet, View } from "react-native"; import { ResizeMode, Video } from "expo-av"; import { useLocalSearchParams, useRouter } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; import { findHighestQuality, @@ -71,9 +70,7 @@ const VideoPlayer: React.FC = ({ data }) => { forceVTT: true, }).catch(() => null); if (!stream) { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); + await dismissFullscreenPlayer(); return router.push("/(tabs)"); } return stream; @@ -111,9 +108,7 @@ const VideoPlayer: React.FC = ({ data }) => { setIsLoading(false); } else { - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); + await dismissFullscreenPlayer(); return router.push("/(tabs)"); } }; From 9dbe9e663f20178c18e15fee57c89af9e0ed0187 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 16:19:59 +0100 Subject: [PATCH 045/442] fix idle timeout --- apps/expo/src/stores/player/slices/interface.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/stores/player/slices/interface.ts b/apps/expo/src/stores/player/slices/interface.ts index 287e145..ee12f85 100644 --- a/apps/expo/src/stores/player/slices/interface.ts +++ b/apps/expo/src/stores/player/slices/interface.ts @@ -5,6 +5,7 @@ import type { MakeSlice } from "./types"; export interface InterfaceSlice { interface: { isIdle: boolean; + idleTimeout: NodeJS.Timeout | null; }; setIsIdle(state: boolean): void; lockOrientation: () => Promise; @@ -16,16 +17,20 @@ export interface InterfaceSlice { export const createInterfaceSlice: MakeSlice = (set, get) => ({ interface: { isIdle: true, + idleTimeout: null, }, setIsIdle: (state) => { set((s) => { - setTimeout(() => { + if (s.interface.idleTimeout) clearTimeout(s.interface.idleTimeout); + + s.interface.idleTimeout = setTimeout(() => { if (get().interface.isIdle === false) { set((s) => { s.interface.isIdle = true; }); } }, 6000); + s.interface.isIdle = state; }); }, From 5bc848ed5f69d7f3d2df20357ce2a3e418ab8d84 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 18:47:20 +0100 Subject: [PATCH 046/442] add play and seek buttons --- apps/expo/src/app/videoPlayer.tsx | 2 ++ apps/expo/src/components/player/Controls.tsx | 19 ++++++++--- apps/expo/src/components/player/Header.tsx | 14 ++++---- .../src/components/player/MiddleButtons.tsx | 21 ++++++++++++ .../expo/src/components/player/PlayButton.tsx | 29 +++++++++++++++++ .../expo/src/components/player/SeekButton.tsx | 32 +++++++++++++++++++ 6 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 apps/expo/src/components/player/MiddleButtons.tsx create mode 100644 apps/expo/src/components/player/PlayButton.tsx create mode 100644 apps/expo/src/components/player/SeekButton.tsx diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index b09bb5b..2768219 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -13,6 +13,7 @@ import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; import { Header } from "~/components/player/Header"; +import { MiddleControls } from "~/components/player/MiddleButtons"; import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { @@ -145,6 +146,7 @@ const VideoPlayer: React.FC = ({ data }) => { /> {isLoading && } {!isLoading && data &&
} + {!isLoading && } ); }; diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index cd4ef5d..3c92a87 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -1,13 +1,24 @@ -import { View } from "react-native"; +import React from "react"; +import { TouchableOpacity } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; -interface ControlsProps { +interface ControlsProps extends React.ComponentProps { children: React.ReactNode; } -export const Controls = ({ children }: ControlsProps) => { +export const Controls = ({ children, className }: ControlsProps) => { const idle = usePlayerStore((state) => state.interface.isIdle); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); - return {!idle && children}; + return ( + { + setIsIdle(false); + }} + className={className} + > + {!idle && children} + + ); }; diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 8c25fb0..c8630aa 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -11,14 +11,12 @@ interface HeaderProps { export const Header = ({ title }: HeaderProps) => { return ( - - - - {title} - - - movie-web - + + + {title} + + + movie-web ); diff --git a/apps/expo/src/components/player/MiddleButtons.tsx b/apps/expo/src/components/player/MiddleButtons.tsx new file mode 100644 index 0000000..42e769a --- /dev/null +++ b/apps/expo/src/components/player/MiddleButtons.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; + +import { Controls } from "./Controls"; +import { PlayButton } from "./PlayButton"; +import { SeekButton } from "./SeekButton"; + +export const MiddleControls = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/apps/expo/src/components/player/PlayButton.tsx b/apps/expo/src/components/player/PlayButton.tsx new file mode 100644 index 0000000..c480292 --- /dev/null +++ b/apps/expo/src/components/player/PlayButton.tsx @@ -0,0 +1,29 @@ +import { FontAwesome } from "@expo/vector-icons"; + +import { usePlayerStore } from "~/stores/player/store"; + +export const PlayButton = () => { + const videoRef = usePlayerStore((state) => state.videoRef); + const status = usePlayerStore((state) => state.status); + + return ( + { + if (status?.isLoaded) { + if (status.isPlaying) { + videoRef?.pauseAsync().catch(() => { + console.log("Error pausing video"); + }); + } else { + videoRef?.playAsync().catch(() => { + console.log("Error playing video"); + }); + } + } + }} + /> + ); +}; diff --git a/apps/expo/src/components/player/SeekButton.tsx b/apps/expo/src/components/player/SeekButton.tsx new file mode 100644 index 0000000..f6a2fc4 --- /dev/null +++ b/apps/expo/src/components/player/SeekButton.tsx @@ -0,0 +1,32 @@ +import { MaterialIcons } from "@expo/vector-icons"; + +import { usePlayerStore } from "~/stores/player/store"; + +interface SeekProps { + type: "forward" | "backward"; +} + +export const SeekButton = ({ type }: SeekProps) => { + const videoRef = usePlayerStore((state) => state.videoRef); + const status = usePlayerStore((state) => state.status); + + return ( + { + if (status?.isLoaded) { + const position = + type === "forward" + ? status.positionMillis + 10000 + : status.positionMillis - 10000; + + videoRef?.setPositionAsync(position).catch(() => { + console.log("Error seeking backwards"); + }); + } + }} + /> + ); +}; From 68ff77ec99d1faa9b967095b1e2660781e30bb47 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 18:57:19 +0100 Subject: [PATCH 047/442] feat: show season and episode in header if available --- apps/expo/src/app/videoPlayer.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 2768219..cac3b33 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -28,9 +28,16 @@ interface VideoPlayerProps { data: ItemData | null; } +interface HeaderInfo { + title: string; + season?: number; + episode?: number; +} + const VideoPlayer: React.FC = ({ data }) => { const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); + const [headerInfo, setHeaderInfo] = useState({ title: "" }); const router = useRouter(); const setVideoRef = usePlayerStore((state) => state.setVideoRef); const setStatus = usePlayerStore((state) => state.setStatus); @@ -66,6 +73,14 @@ const VideoPlayer: React.FC = ({ data }) => { episode, ); + setHeaderInfo({ + title: data.title, + ...(scrapeMedia.type === "show" && { + season: scrapeMedia.season.number, + episode: scrapeMedia.episode.number, + }), + }); + const stream = await getVideoStream({ media: scrapeMedia, forceVTT: true, @@ -145,7 +160,15 @@ const VideoPlayer: React.FC = ({ data }) => { onTouchStart={() => setIsIdle(false)} /> {isLoading && } - {!isLoading && data &&
} + {!isLoading && data && ( +
+ )} {!isLoading && } ); From a39797432532955eb26f5e5dce1ca58d87e23bf6 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:04:04 +0100 Subject: [PATCH 048/442] chore: prettier --- apps/expo/src/app/videoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index cac3b33..27d816a 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -164,7 +164,7 @@ const VideoPlayer: React.FC = ({ data }) => {
From f18a5421e52c9e46bef6bfafb9c5c64fbe89bb8b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:26:00 +0100 Subject: [PATCH 049/442] refactor: cleanup headerdata stuff --- apps/expo/src/app/videoPlayer.tsx | 32 ++++++++-------------- apps/expo/src/components/player/Header.tsx | 17 ++++++++++-- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 27d816a..95412e9 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -12,6 +12,7 @@ import { import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; +import type { HeaderData } from "~/components/player/Header"; import { Header } from "~/components/player/Header"; import { MiddleControls } from "~/components/player/MiddleButtons"; import { usePlayerStore } from "~/stores/player/store"; @@ -28,16 +29,10 @@ interface VideoPlayerProps { data: ItemData | null; } -interface HeaderInfo { - title: string; - season?: number; - episode?: number; -} - const VideoPlayer: React.FC = ({ data }) => { const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); - const [headerInfo, setHeaderInfo] = useState({ title: "" }); + const [headerData, setHeaderData] = useState(); const router = useRouter(); const setVideoRef = usePlayerStore((state) => state.setVideoRef); const setStatus = usePlayerStore((state) => state.setStatus); @@ -73,12 +68,15 @@ const VideoPlayer: React.FC = ({ data }) => { episode, ); - setHeaderInfo({ + setHeaderData({ title: data.title, - ...(scrapeMedia.type === "show" && { - season: scrapeMedia.season.number, - episode: scrapeMedia.episode.number, - }), + year: data.year, + season: + scrapeMedia.type === "show" ? scrapeMedia.season.number : undefined, + episode: + scrapeMedia.type === "show" + ? scrapeMedia.episode.number + : undefined, }); const stream = await getVideoStream({ @@ -160,15 +158,7 @@ const VideoPlayer: React.FC = ({ data }) => { onTouchStart={() => setIsIdle(false)} /> {isLoading && } - {!isLoading && data && ( -
- )} + {!isLoading && data &&
} {!isLoading && } ); diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index c8630aa..8a525a2 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -5,15 +5,26 @@ import { Text } from "../ui/Text"; import { BackButton } from "./BackButton"; import { Controls } from "./Controls"; -interface HeaderProps { +export interface HeaderData { title: string; + year: number; + season?: number; + episode?: number; } -export const Header = ({ title }: HeaderProps) => { +interface HeaderProps { + data: HeaderData; +} + +export const Header = ({ data }: HeaderProps) => { return ( - {title} + + {data.season && data.episode + ? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}` + : `${data.title} (${data.year})`} + movie-web From 7dc0512007fc9d78da62d3a1253a33f4ea2017c4 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 20:07:36 +0100 Subject: [PATCH 050/442] rename file --- .../components/player/{MiddleButtons.tsx => MiddleControls.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/expo/src/components/player/{MiddleButtons.tsx => MiddleControls.tsx} (100%) diff --git a/apps/expo/src/components/player/MiddleButtons.tsx b/apps/expo/src/components/player/MiddleControls.tsx similarity index 100% rename from apps/expo/src/components/player/MiddleButtons.tsx rename to apps/expo/src/components/player/MiddleControls.tsx From 9a04824c02fdc44ba5a045bc40af36b86937446e Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 20:07:55 +0100 Subject: [PATCH 051/442] oops --- apps/expo/src/app/videoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 95412e9..81b34c0 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -14,7 +14,7 @@ import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; import { Header } from "~/components/player/Header"; -import { MiddleControls } from "~/components/player/MiddleButtons"; +import { MiddleControls } from "~/components/player/MiddleControls"; import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { From 61cb948f3dc97966f44c13c5a251e16fbd059bdc Mon Sep 17 00:00:00 2001 From: Jorrin Date: Mon, 12 Feb 2024 20:51:56 +0100 Subject: [PATCH 052/442] remove navigation bars when in fullscreen --- apps/expo/package.json | 1 + apps/expo/src/app/videoPlayer.tsx | 6 ++++++ pnpm-lock.yaml | 15 +++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/apps/expo/package.json b/apps/expo/package.json index df8453a..9c420ca 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -30,6 +30,7 @@ "expo-build-properties": "~0.11.1", "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", + "expo-navigation-bar": "^2.8.1", "expo-router": "~3.4.6", "expo-screen-orientation": "~6.4.1", "expo-splash-screen": "~0.26.4", diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 81b34c0..ad52d09 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -2,7 +2,9 @@ import type { AVPlaybackSource } from "expo-av"; import React, { useEffect, useState } from "react"; import { ActivityIndicator, StyleSheet, View } from "react-native"; import { ResizeMode, Video } from "expo-av"; +import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; +import * as StatusBar from "expo-status-bar"; import { findHighestQuality, @@ -90,6 +92,8 @@ const VideoPlayer: React.FC = ({ data }) => { return stream; }; + StatusBar.setStatusBarHidden(true); + await NavigationBar.setVisibilityAsync("hidden"); setIsLoading(true); const stream = await fetchVideo(); @@ -133,6 +137,8 @@ const VideoPlayer: React.FC = ({ data }) => { return () => { void dismissFullscreenPlayer(); + StatusBar.setStatusBarHidden(false); + void NavigationBar.setVisibilityAsync("visible"); }; }, [data, dismissFullscreenPlayer, presentFullscreenPlayer, router]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb8ff5..1986990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: expo-linking: specifier: ~6.2.2 version: 6.2.2(expo@50.0.5) + expo-navigation-bar: + specifier: ^2.8.1 + version: 2.8.1(expo@50.0.5) expo-router: specifier: ~3.4.6 version: 3.4.6(expo-constants@15.4.5)(expo-linking@6.2.2)(expo-modules-autolinking@1.10.2)(expo-status-bar@1.11.1)(expo@50.0.5)(react-dom@18.2.0)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-screens@3.29.0)(react-native@0.73.2)(react@18.2.0) @@ -5590,6 +5593,18 @@ packages: invariant: 2.2.4 dev: false + /expo-navigation-bar@2.8.1(expo@50.0.5): + resolution: {integrity: sha512-aT5G+7SUsXDVPsRwp8fF940ycka1ABb4g3QKvTZN3YP6kMWvsiYEmRqMIJVy0zUr/i6bxBG1ZergkXimWrFt3w==} + peerDependencies: + expo: '*' + dependencies: + '@react-native/normalize-color': 2.1.0 + debug: 4.3.4 + expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + transitivePeerDependencies: + - supports-color + dev: false + /expo-router@3.4.6(expo-constants@15.4.5)(expo-linking@6.2.2)(expo-modules-autolinking@1.10.2)(expo-status-bar@1.11.1)(expo@50.0.5)(react-dom@18.2.0)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native-screens@3.29.0)(react-native@0.73.2)(react@18.2.0): resolution: {integrity: sha512-yxl0QE4KAqLmLyH8AxWsGSV3M34jsAE8X75cOB2oaK0+Pu9VHSUf6w3iRi93IiJ0rOUXm8jKrjhfhZOrrNh7EA==} peerDependencies: From 8dde4a8cd08f4ba06d4f1eb74d7e5e690be5bcdf Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 21:32:21 +0100 Subject: [PATCH 053/442] feat: provider event logic & temp loading screen --- apps/expo/src/app/loading.tsx | 108 +++++++++++++++++++++++++ apps/expo/src/app/videoPlayer.tsx | 88 +++++++------------- apps/expo/src/components/item/item.tsx | 2 +- packages/provider-utils/src/index.ts | 3 + packages/provider-utils/src/video.ts | 15 +++- 5 files changed, 157 insertions(+), 59 deletions(-) create mode 100644 apps/expo/src/app/loading.tsx diff --git a/apps/expo/src/app/loading.tsx b/apps/expo/src/app/loading.tsx new file mode 100644 index 0000000..9b76086 --- /dev/null +++ b/apps/expo/src/app/loading.tsx @@ -0,0 +1,108 @@ +import { getVideoStream, transformSearchResultToScrapeMedia } from "@movie-web/provider-utils"; +import { fetchMediaDetails } from "@movie-web/tmdb"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { Text } from 'react-native'; +import type { ItemData } from "~/components/item/item"; +import ScreenLayout from "~/components/layout/ScreenLayout"; +import type { VideoPlayerData } from "./videoPlayer"; + +export default function LoadingScreenWrapper() { + const params = useLocalSearchParams(); + const data = params.data + ? (JSON.parse(params.data as string) as ItemData) + : null; + return ; +} + +function LoadingScreen({ data }: { data: ItemData | null }) { + const router = useRouter(); + const [eventLog, setEventLog] = useState([]); + + const handleEvent = (event: unknown) => { + const formattedEvent = formatEvent(event); + setEventLog((prevLog) => [...prevLog, formattedEvent]); + }; + + useEffect(() => { + const fetchVideo = async () => { + if (!data) return null; + const { id, type } = data; + const media = await fetchMediaDetails(id, type).catch(() => null); + if (!media) return null; + + const { result } = media; + let season: number | undefined; // defaults to 1 when undefined + let episode: number | undefined; + + if (type === "tv") { + // season = ?? undefined; + // episode = ?? undefined; + } + + const scrapeMedia = transformSearchResultToScrapeMedia( + type, + result, + season, + episode, + ); + + const stream = await getVideoStream({ + media: scrapeMedia, + onEvent: handleEvent, + }).catch(() => null); + if (!stream) return null; + + return { stream, scrapeMedia } + }; + + const initialize = async () => { + const video = await fetchVideo(); + if (!video || !data) { + return router.push({ pathname: "/(tabs)" }); + } + + const videoPlayerData: VideoPlayerData = { + item: data, + stream: video.stream, + media: video.scrapeMedia + }; + + router.replace({ + pathname: "/videoPlayer", + params: { data: JSON.stringify(videoPlayerData) }, + }); + }; + + void initialize(); + }, [data, router]); + + return ( + + {eventLog.map((event, index) => ( + + {event} + + ))} + + ); + } + +function formatEvent(event: unknown): string { + if (typeof event === 'string') { + return `Start: ID - ${event}`; + } else if (typeof event === 'object' && event !== null) { + const evt = event as Record; + if ('percentage' in evt) { + return `Update: ${String(evt.percentage)}% - Status: ${String(evt.status)}`; + } else if ('sourceIds' in evt) { + return `Initialization: Source IDs - ${String(evt.sourceIds)}`; + } else if ('sourceId' in evt) { + return `Discovered Embeds: Source ID - ${String(evt.sourceId)}`; + } + } + return JSON.stringify(event); + } \ No newline at end of file diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index ad52d09..22272fc 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -6,12 +6,12 @@ import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; +import type { + ScrapeMedia, + Stream} from "@movie-web/provider-utils"; import { findHighestQuality, - getVideoStream, - transformSearchResultToScrapeMedia, } from "@movie-web/provider-utils"; -import { fetchMediaDetails } from "@movie-web/tmdb"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; @@ -22,13 +22,19 @@ import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { const params = useLocalSearchParams(); const data = params.data - ? (JSON.parse(params.data as string) as ItemData) + ? (JSON.parse(params.data as string) as VideoPlayerData) : null; return ; } +export interface VideoPlayerData { + item: ItemData; + stream: Stream; + media: ScrapeMedia; +} + interface VideoPlayerProps { - data: ItemData | null; + data: VideoPlayerData | null; } const VideoPlayer: React.FC = ({ data }) => { @@ -48,56 +54,28 @@ const VideoPlayer: React.FC = ({ data }) => { useEffect(() => { const initializePlayer = async () => { - const fetchVideo = async () => { - if (!data) return null; - const { id, type } = data; - const media = await fetchMediaDetails(id, type).catch(() => null); - if (!media) return null; - - const { result } = media; - let season: number | undefined; // defaults to 1 when undefined - let episode: number | undefined; - - if (type === "tv") { - // season = ?? undefined; - // episode = ?? undefined; - } - - const scrapeMedia = transformSearchResultToScrapeMedia( - type, - result, - season, - episode, - ); - - setHeaderData({ - title: data.title, - year: data.year, - season: - scrapeMedia.type === "show" ? scrapeMedia.season.number : undefined, - episode: - scrapeMedia.type === "show" - ? scrapeMedia.episode.number - : undefined, - }); - - const stream = await getVideoStream({ - media: scrapeMedia, - forceVTT: true, - }).catch(() => null); - if (!stream) { - await dismissFullscreenPlayer(); - return router.push("/(tabs)"); - } - return stream; - }; - - StatusBar.setStatusBarHidden(true); + StatusBar.setStatusBarHidden(true); await NavigationBar.setVisibilityAsync("hidden"); setIsLoading(true); - const stream = await fetchVideo(); + + if (!data) { + await dismissFullscreenPlayer(); + return router.push("/(tabs)"); + } + + const { item, stream, media } = data; + + setHeaderData({ + title: item.title, + year: item.year, + season: + media.type === "show" ? media.season.number : undefined, + episode: + media.type === "show" + ? media.episode.number + : undefined, + }); - if (stream) { let highestQuality; let url; @@ -125,11 +103,7 @@ const VideoPlayer: React.FC = ({ data }) => { }); setIsLoading(false); - } else { - await dismissFullscreenPlayer(); - return router.push("/(tabs)"); - } - }; + }; setIsLoading(true); void presentFullscreenPlayer(); diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index d09c5a9..32a9c01 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -17,7 +17,7 @@ export default function Item({ data }: { data: ItemData }) { const handlePress = () => { router.push({ - pathname: "/videoPlayer", + pathname: "/loading", params: { data: JSON.stringify(data) }, }); }; diff --git a/packages/provider-utils/src/index.ts b/packages/provider-utils/src/index.ts index c59ca8d..ad1b133 100644 --- a/packages/provider-utils/src/index.ts +++ b/packages/provider-utils/src/index.ts @@ -1,3 +1,6 @@ export const name = "provider-utils"; export * from "./video"; export * from "./util"; + +import type { Stream, ScrapeMedia } from "@movie-web/providers"; +export type { Stream, ScrapeMedia }; diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index f32310f..383a6b9 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -3,6 +3,7 @@ import { default as toWebVTT } from "srt-webvtt"; import type { FileBasedStream, Qualities, + RunnerOptions, ScrapeMedia, Stream, } from "@movie-web/providers"; @@ -15,9 +16,11 @@ import { export async function getVideoStream({ media, forceVTT, + onEvent, }: { media: ScrapeMedia; forceVTT?: boolean; + onEvent?: (event: unknown) => void; }): Promise { const providers = makeProviders({ fetcher: makeStandardFetcher(fetch), @@ -25,7 +28,17 @@ export async function getVideoStream({ consistentIpForRequests: true, }); - const result = await providers.runAll({ media }); + const options: RunnerOptions = { + media, + events: { + init: onEvent, + update: onEvent, + discoverEmbeds: onEvent, + start: onEvent, + } + }; + + const result = await providers.runAll(options); if (!result) return null; if (forceVTT) { From 8516060bc7be2d55851df18795ec15d11db0a714 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 21:38:34 +0100 Subject: [PATCH 054/442] chore: prettier --- apps/expo/src/app/loading.tsx | 179 ++++++++++++++------------- apps/expo/src/app/videoPlayer.tsx | 92 +++++++------- packages/provider-utils/src/index.ts | 3 +- packages/provider-utils/src/video.ts | 20 +-- 4 files changed, 146 insertions(+), 148 deletions(-) diff --git a/apps/expo/src/app/loading.tsx b/apps/expo/src/app/loading.tsx index 9b76086..88be6ea 100644 --- a/apps/expo/src/app/loading.tsx +++ b/apps/expo/src/app/loading.tsx @@ -1,108 +1,113 @@ -import { getVideoStream, transformSearchResultToScrapeMedia } from "@movie-web/provider-utils"; -import { fetchMediaDetails } from "@movie-web/tmdb"; -import { useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; -import { Text } from 'react-native'; +import { Text } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; + +import { + getVideoStream, + transformSearchResultToScrapeMedia, +} from "@movie-web/provider-utils"; +import { fetchMediaDetails } from "@movie-web/tmdb"; + +import type { VideoPlayerData } from "./videoPlayer"; import type { ItemData } from "~/components/item/item"; import ScreenLayout from "~/components/layout/ScreenLayout"; -import type { VideoPlayerData } from "./videoPlayer"; export default function LoadingScreenWrapper() { - const params = useLocalSearchParams(); - const data = params.data - ? (JSON.parse(params.data as string) as ItemData) - : null; - return ; + const params = useLocalSearchParams(); + const data = params.data + ? (JSON.parse(params.data as string) as ItemData) + : null; + return ; } function LoadingScreen({ data }: { data: ItemData | null }) { - const router = useRouter(); - const [eventLog, setEventLog] = useState([]); + const router = useRouter(); + const [eventLog, setEventLog] = useState([]); - const handleEvent = (event: unknown) => { - const formattedEvent = formatEvent(event); - setEventLog((prevLog) => [...prevLog, formattedEvent]); - }; + const handleEvent = (event: unknown) => { + const formattedEvent = formatEvent(event); + setEventLog((prevLog) => [...prevLog, formattedEvent]); + }; - useEffect(() => { - const fetchVideo = async () => { - if (!data) return null; - const { id, type } = data; - const media = await fetchMediaDetails(id, type).catch(() => null); - if (!media) return null; + useEffect(() => { + const fetchVideo = async () => { + if (!data) return null; + const { id, type } = data; + const media = await fetchMediaDetails(id, type).catch(() => null); + if (!media) return null; - const { result } = media; - let season: number | undefined; // defaults to 1 when undefined - let episode: number | undefined; + const { result } = media; + let season: number | undefined; // defaults to 1 when undefined + let episode: number | undefined; - if (type === "tv") { - // season = ?? undefined; - // episode = ?? undefined; - } + if (type === "tv") { + // season = ?? undefined; + // episode = ?? undefined; + } - const scrapeMedia = transformSearchResultToScrapeMedia( - type, - result, - season, - episode, - ); + const scrapeMedia = transformSearchResultToScrapeMedia( + type, + result, + season, + episode, + ); - const stream = await getVideoStream({ - media: scrapeMedia, - onEvent: handleEvent, - }).catch(() => null); - if (!stream) return null; + const stream = await getVideoStream({ + media: scrapeMedia, + onEvent: handleEvent, + }).catch(() => null); + if (!stream) return null; - return { stream, scrapeMedia } - }; + return { stream, scrapeMedia }; + }; - const initialize = async () => { - const video = await fetchVideo(); - if (!video || !data) { - return router.push({ pathname: "/(tabs)" }); - } + const initialize = async () => { + const video = await fetchVideo(); + if (!video || !data) { + return router.push({ pathname: "/(tabs)" }); + } - const videoPlayerData: VideoPlayerData = { - item: data, - stream: video.stream, - media: video.scrapeMedia - }; + const videoPlayerData: VideoPlayerData = { + item: data, + stream: video.stream, + media: video.scrapeMedia, + }; - router.replace({ - pathname: "/videoPlayer", - params: { data: JSON.stringify(videoPlayerData) }, - }); - }; + router.replace({ + pathname: "/videoPlayer", + params: { data: JSON.stringify(videoPlayerData) }, + }); + }; - void initialize(); - }, [data, router]); - - return ( - - {eventLog.map((event, index) => ( - - {event} - - ))} - - ); - } + void initialize(); + }, [data, router]); + + return ( + + {eventLog.map((event, index) => ( + + {event} + + ))} + + ); +} function formatEvent(event: unknown): string { - if (typeof event === 'string') { - return `Start: ID - ${event}`; - } else if (typeof event === 'object' && event !== null) { - const evt = event as Record; - if ('percentage' in evt) { - return `Update: ${String(evt.percentage)}% - Status: ${String(evt.status)}`; - } else if ('sourceIds' in evt) { - return `Initialization: Source IDs - ${String(evt.sourceIds)}`; - } else if ('sourceId' in evt) { - return `Discovered Embeds: Source ID - ${String(evt.sourceId)}`; - } - } - return JSON.stringify(event); - } \ No newline at end of file + if (typeof event === "string") { + return `Start: ID - ${event}`; + } else if (typeof event === "object" && event !== null) { + const evt = event as Record; + if ("percentage" in evt) { + return `Update: ${String(evt.percentage)}% - Status: ${String(evt.status)}`; + } else if ("sourceIds" in evt) { + return `Initialization: Source IDs - ${String(evt.sourceIds)}`; + } else if ("sourceId" in evt) { + return `Discovered Embeds: Source ID - ${String(evt.sourceId)}`; + } + } + return JSON.stringify(event); +} diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 22272fc..03c3d3b 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -6,12 +6,8 @@ import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; -import type { - ScrapeMedia, - Stream} from "@movie-web/provider-utils"; -import { - findHighestQuality, -} from "@movie-web/provider-utils"; +import type { ScrapeMedia, Stream } from "@movie-web/provider-utils"; +import { findHighestQuality } from "@movie-web/provider-utils"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; @@ -28,9 +24,9 @@ export default function VideoPlayerWrapper() { } export interface VideoPlayerData { - item: ItemData; - stream: Stream; - media: ScrapeMedia; + item: ItemData; + stream: Stream; + media: ScrapeMedia; } interface VideoPlayerProps { @@ -54,56 +50,52 @@ const VideoPlayer: React.FC = ({ data }) => { useEffect(() => { const initializePlayer = async () => { - StatusBar.setStatusBarHidden(true); + StatusBar.setStatusBarHidden(true); await NavigationBar.setVisibilityAsync("hidden"); setIsLoading(true); - - if (!data) { - await dismissFullscreenPlayer(); - return router.push("/(tabs)"); - } - const { item, stream, media } = data; + if (!data) { + await dismissFullscreenPlayer(); + return router.push("/(tabs)"); + } - setHeaderData({ - title: item.title, - year: item.year, - season: - media.type === "show" ? media.season.number : undefined, - episode: - media.type === "show" - ? media.episode.number - : undefined, - }); + const { item, stream, media } = data; - let highestQuality; - let url; + setHeaderData({ + title: item.title, + year: item.year, + season: media.type === "show" ? media.season.number : undefined, + episode: media.type === "show" ? media.episode.number : undefined, + }); - switch (stream.type) { - case "file": - highestQuality = findHighestQuality(stream); - url = highestQuality ? stream.qualities[highestQuality]?.url : null; - return url ?? null; - case "hls": - url = stream.playlist; - } + let highestQuality; + let url; - // setTextTracks( - // stream.captions && stream.captions.length > 0 - // ? convertCaptionsToTextTracks(stream.captions) - // : [], - // ); + switch (stream.type) { + case "file": + highestQuality = findHighestQuality(stream); + url = highestQuality ? stream.qualities[highestQuality]?.url : null; + return url ?? null; + case "hls": + url = stream.playlist; + } - setVideoSrc({ - uri: url, - headers: { - ...stream.preferredHeaders, - ...stream.headers, - }, - }); + // setTextTracks( + // stream.captions && stream.captions.length > 0 + // ? convertCaptionsToTextTracks(stream.captions) + // : [], + // ); - setIsLoading(false); - }; + setVideoSrc({ + uri: url, + headers: { + ...stream.preferredHeaders, + ...stream.headers, + }, + }); + + setIsLoading(false); + }; setIsLoading(true); void presentFullscreenPlayer(); diff --git a/packages/provider-utils/src/index.ts b/packages/provider-utils/src/index.ts index ad1b133..5550a8a 100644 --- a/packages/provider-utils/src/index.ts +++ b/packages/provider-utils/src/index.ts @@ -1,6 +1,7 @@ +import type { ScrapeMedia, Stream } from "@movie-web/providers"; + export const name = "provider-utils"; export * from "./video"; export * from "./util"; -import type { Stream, ScrapeMedia } from "@movie-web/providers"; export type { Stream, ScrapeMedia }; diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 383a6b9..135edd3 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -28,16 +28,16 @@ export async function getVideoStream({ consistentIpForRequests: true, }); - const options: RunnerOptions = { - media, - events: { - init: onEvent, - update: onEvent, - discoverEmbeds: onEvent, - start: onEvent, - } - }; - + const options: RunnerOptions = { + media, + events: { + init: onEvent, + update: onEvent, + discoverEmbeds: onEvent, + start: onEvent, + }, + }; + const result = await providers.runAll(options); if (!result) return null; From 239d201d9f3f1b12f3a491814a43bf9c2aca5166 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 21:43:52 +0100 Subject: [PATCH 055/442] fix: setVisibilityAsync is android only --- apps/expo/src/app/videoPlayer.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 03c3d3b..b9309e6 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -1,6 +1,6 @@ import type { AVPlaybackSource } from "expo-av"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; import { ResizeMode, Video } from "expo-av"; import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -51,7 +51,10 @@ const VideoPlayer: React.FC = ({ data }) => { useEffect(() => { const initializePlayer = async () => { StatusBar.setStatusBarHidden(true); - await NavigationBar.setVisibilityAsync("hidden"); + + if (Platform.OS === "android") { + await NavigationBar.setVisibilityAsync("hidden"); + } setIsLoading(true); if (!data) { @@ -104,7 +107,9 @@ const VideoPlayer: React.FC = ({ data }) => { return () => { void dismissFullscreenPlayer(); StatusBar.setStatusBarHidden(false); - void NavigationBar.setVisibilityAsync("visible"); + if (Platform.OS === "android") { + void NavigationBar.setVisibilityAsync("visible"); + } }; }, [data, dismissFullscreenPlayer, presentFullscreenPlayer, router]); From 26e896b6470993ff9d6d364e6947f8f6ad54cb73 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Mon, 12 Feb 2024 22:01:02 +0100 Subject: [PATCH 056/442] refactor: move video player routes onto their own dir --- apps/expo/src/app/{videoPlayer.tsx => videoPlayer/index.tsx} | 0 apps/expo/src/app/{ => videoPlayer}/loading.tsx | 2 +- apps/expo/src/components/item/item.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/expo/src/app/{videoPlayer.tsx => videoPlayer/index.tsx} (100%) rename apps/expo/src/app/{ => videoPlayer}/loading.tsx (98%) diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer/index.tsx similarity index 100% rename from apps/expo/src/app/videoPlayer.tsx rename to apps/expo/src/app/videoPlayer/index.tsx diff --git a/apps/expo/src/app/loading.tsx b/apps/expo/src/app/videoPlayer/loading.tsx similarity index 98% rename from apps/expo/src/app/loading.tsx rename to apps/expo/src/app/videoPlayer/loading.tsx index 88be6ea..92d14d1 100644 --- a/apps/expo/src/app/loading.tsx +++ b/apps/expo/src/app/videoPlayer/loading.tsx @@ -8,7 +8,7 @@ import { } from "@movie-web/provider-utils"; import { fetchMediaDetails } from "@movie-web/tmdb"; -import type { VideoPlayerData } from "./videoPlayer"; +import type { VideoPlayerData } from "."; import type { ItemData } from "~/components/item/item"; import ScreenLayout from "~/components/layout/ScreenLayout"; diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 32a9c01..196ddda 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -17,7 +17,7 @@ export default function Item({ data }: { data: ItemData }) { const handlePress = () => { router.push({ - pathname: "/loading", + pathname: "/videoPlayer/loading", params: { data: JSON.stringify(data) }, }); }; From c88ebe9715654fd97cb27da5c88e8e2ca55d4ef6 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 09:04:01 +0100 Subject: [PATCH 057/442] feat: event types and better loading log --- apps/expo/src/app/videoPlayer/loading.tsx | 34 +++++++++++++++++------ packages/provider-utils/src/video.ts | 30 +++++++++++++++++++- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/loading.tsx b/apps/expo/src/app/videoPlayer/loading.tsx index 92d14d1..9f80504 100644 --- a/apps/expo/src/app/videoPlayer/loading.tsx +++ b/apps/expo/src/app/videoPlayer/loading.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Text } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; +import type { RunnerEvent } from "@movie-web/provider-utils"; import { getVideoStream, transformSearchResultToScrapeMedia, @@ -24,7 +25,7 @@ function LoadingScreen({ data }: { data: ItemData | null }) { const router = useRouter(); const [eventLog, setEventLog] = useState([]); - const handleEvent = (event: unknown) => { + const handleEvent = (event: RunnerEvent) => { const formattedEvent = formatEvent(event); setEventLog((prevLog) => [...prevLog, formattedEvent]); }; @@ -96,17 +97,32 @@ function LoadingScreen({ data }: { data: ItemData | null }) { ); } -function formatEvent(event: unknown): string { +function formatEvent(event: RunnerEvent): string { if (typeof event === "string") { return `Start: ID - ${event}`; } else if (typeof event === "object" && event !== null) { - const evt = event as Record; - if ("percentage" in evt) { - return `Update: ${String(evt.percentage)}% - Status: ${String(evt.status)}`; - } else if ("sourceIds" in evt) { - return `Initialization: Source IDs - ${String(evt.sourceIds)}`; - } else if ("sourceId" in evt) { - return `Discovered Embeds: Source ID - ${String(evt.sourceId)}`; + if ("percentage" in event) { + const evt = event; + const statusMessage = + evt.status === "success" + ? "Completed" + : evt.status === "failure" + ? "Failed - " + (evt.reason ?? "Unknown Error") + : evt.status === "notfound" + ? "Not Found" + : evt.status === "pending" + ? "In Progress" + : "Unknown Status"; + return `Update: ${evt.percentage}% - Status: ${statusMessage}`; + } else if ("sourceIds" in event) { + const evt = event; + return `Initialization: Source IDs - ${evt.sourceIds.join(" ")}`; + } else if ("sourceId" in event) { + const evt = event; + const embedsInfo = evt.embeds + .map((embed) => `ID: ${embed.id}, Scraper: ${embed.embedScraperId}`) + .join("; "); + return `Discovered Embeds: Source ID - ${evt.sourceId} [${embedsInfo}]`; } } return JSON.stringify(event); diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 135edd3..8fe74e3 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -13,6 +13,34 @@ import { targets, } from "@movie-web/providers"; +export interface InitEvent { + sourceIds: string[]; +} + +export interface UpdateEvent { + id: string; + percentage: number; + status: UpdateEventStatus; + error?: unknown; + reason?: string; +} + +export type UpdateEventStatus = "success" | "failure" | "notfound" | "pending"; + +export interface DiscoverEmbedsEvent { + sourceId: string; + embeds: { + id: string; + embedScraperId: string; + }[]; +} + +export type RunnerEvent = + | string + | InitEvent + | UpdateEvent + | DiscoverEmbedsEvent; + export async function getVideoStream({ media, forceVTT, @@ -20,7 +48,7 @@ export async function getVideoStream({ }: { media: ScrapeMedia; forceVTT?: boolean; - onEvent?: (event: unknown) => void; + onEvent?: (event: RunnerEvent) => void; }): Promise { const providers = makeProviders({ fetcher: makeStandardFetcher(fetch), From 9db0ae544c766672a67c142d81537241295664d1 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:07:50 +0100 Subject: [PATCH 058/442] feat: prettier loading log --- apps/expo/src/app/videoPlayer/loading.tsx | 54 ++++++++++++++++------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/loading.tsx b/apps/expo/src/app/videoPlayer/loading.tsx index 9f80504..6beffdd 100644 --- a/apps/expo/src/app/videoPlayer/loading.tsx +++ b/apps/expo/src/app/videoPlayer/loading.tsx @@ -13,6 +13,12 @@ import type { VideoPlayerData } from "."; import type { ItemData } from "~/components/item/item"; import ScreenLayout from "~/components/layout/ScreenLayout"; +interface Event { + originalEvent: RunnerEvent; + formattedMessage: string; + style: object; +} + export default function LoadingScreenWrapper() { const params = useLocalSearchParams(); const data = params.data @@ -23,10 +29,15 @@ export default function LoadingScreenWrapper() { function LoadingScreen({ data }: { data: ItemData | null }) { const router = useRouter(); - const [eventLog, setEventLog] = useState([]); + const [eventLog, setEventLog] = useState([]); const handleEvent = (event: RunnerEvent) => { - const formattedEvent = formatEvent(event); + const { message, style } = formatEvent(event); + const formattedEvent: Event = { + originalEvent: event, + formattedMessage: message, + style: style, + }; setEventLog((prevLog) => [...prevLog, formattedEvent]); }; @@ -89,41 +100,54 @@ function LoadingScreen({ data }: { data: ItemData | null }) { subtitle="Fetching sources for the requested content." > {eventLog.map((event, index) => ( - - {event} + + {event.formattedMessage} ))} ); } -function formatEvent(event: RunnerEvent): string { +function formatEvent(event: RunnerEvent): { message: string; style: object } { + let message = ""; + let style = {}; + if (typeof event === "string") { - return `Start: ID - ${event}`; + message = `🚀 Start: ID - ${event}`; + style = { color: "lime" }; } else if (typeof event === "object" && event !== null) { if ("percentage" in event) { const evt = event; const statusMessage = evt.status === "success" - ? "Completed" + ? "✅ Completed" : evt.status === "failure" - ? "Failed - " + (evt.reason ?? "Unknown Error") + ? "❌ Failed - " + (evt.reason ?? "Unknown Error") : evt.status === "notfound" - ? "Not Found" + ? "🔍 Not Found" : evt.status === "pending" - ? "In Progress" - : "Unknown Status"; - return `Update: ${evt.percentage}% - Status: ${statusMessage}`; + ? "⏳ In Progress" + : "❓ Unknown Status"; + + message = `Update: ${evt.percentage}% - Status: ${statusMessage}`; + style = { color: evt.status === "success" ? "green" : "red" }; } else if ("sourceIds" in event) { const evt = event; - return `Initialization: Source IDs - ${evt.sourceIds.join(" ")}`; + message = `🔍 Initialization: Source IDs - ${evt.sourceIds.join(" ")}`; + style = { color: "skyblue" }; } else if ("sourceId" in event) { const evt = event; const embedsInfo = evt.embeds .map((embed) => `ID: ${embed.id}, Scraper: ${embed.embedScraperId}`) .join("; "); - return `Discovered Embeds: Source ID - ${evt.sourceId} [${embedsInfo}]`; + + message = `🔗 Discovered Embeds: Source ID - ${evt.sourceId} [${embedsInfo}]`; + style = { color: "orange" }; } + } else { + message = JSON.stringify(event); + style = { color: "grey" }; } - return JSON.stringify(event); + + return { message, style }; } From 6e33e0efea70cb62145fd2d7e3ce8c4592fa3135 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:13:25 +0100 Subject: [PATCH 059/442] chore: log color adjustment --- apps/expo/src/app/videoPlayer/loading.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer/loading.tsx b/apps/expo/src/app/videoPlayer/loading.tsx index 6beffdd..f6f2c13 100644 --- a/apps/expo/src/app/videoPlayer/loading.tsx +++ b/apps/expo/src/app/videoPlayer/loading.tsx @@ -130,7 +130,25 @@ function formatEvent(event: RunnerEvent): { message: string; style: object } { : "❓ Unknown Status"; message = `Update: ${evt.percentage}% - Status: ${statusMessage}`; - style = { color: evt.status === "success" ? "green" : "red" }; + let color = ""; + switch (evt.status) { + case "success": + color = "green"; + break; + case "failure": + color = "red"; + break; + case "notfound": + color = "blue"; + break; + case "pending": + color = "yellow"; + break; + default: + color = "grey"; + break; + } + style = { color }; } else if ("sourceIds" in event) { const evt = event; message = `🔍 Initialization: Source IDs - ${evt.sourceIds.join(" ")}`; From e5dc36cd6d43feb88c06b16337d81701d5e1e471 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:10:56 +0100 Subject: [PATCH 060/442] fix: player controls touch events on iOS --- apps/expo/src/components/player/Controls.tsx | 8 +---- .../src/components/player/MiddleControls.tsx | 34 ++++++++++++------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index 3c92a87..05a93d2 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -9,15 +9,9 @@ interface ControlsProps extends React.ComponentProps { export const Controls = ({ children, className }: ControlsProps) => { const idle = usePlayerStore((state) => state.interface.isIdle); - const setIsIdle = usePlayerStore((state) => state.setIsIdle); return ( - { - setIsIdle(false); - }} - className={className} - > + {!idle && children} ); diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index 42e769a..3baea7c 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -1,21 +1,31 @@ -import { View } from "react-native"; +import { TouchableWithoutFeedback, View } from "react-native"; +import { usePlayerStore } from "~/stores/player/store"; import { Controls } from "./Controls"; import { PlayButton } from "./PlayButton"; import { SeekButton } from "./SeekButton"; export const MiddleControls = () => { + const idle = usePlayerStore((state) => state.interface.isIdle); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); + + const handleTouch = () => { + setIsIdle(!idle); + }; + return ( - - - - - - - - - - - + + + + + + + + + + + + + ); }; From 38419aa385e316f5fc57feca314fd6fcd07f7aba Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:18:54 +0100 Subject: [PATCH 061/442] fix: middlecontrols blocking back button --- apps/expo/src/components/player/Header.tsx | 2 +- apps/expo/src/components/player/MiddleControls.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 8a525a2..15723f8 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -18,7 +18,7 @@ interface HeaderProps { export const Header = ({ data }: HeaderProps) => { return ( - + {data.season && data.episode diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index 3baea7c..c56404c 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -15,7 +15,7 @@ export const MiddleControls = () => { return ( - + From f6b5f3d3424ed3b36f13dc1fdccca5be3bb25196 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:19:44 +0100 Subject: [PATCH 062/442] Revert "fix: middlecontrols blocking back button" This reverts commit 38419aa385e316f5fc57feca314fd6fcd07f7aba. --- apps/expo/src/components/player/Header.tsx | 2 +- apps/expo/src/components/player/MiddleControls.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 15723f8..8a525a2 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -18,7 +18,7 @@ interface HeaderProps { export const Header = ({ data }: HeaderProps) => { return ( - + {data.season && data.episode diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index c56404c..3baea7c 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -15,7 +15,7 @@ export const MiddleControls = () => { return ( - + From e7f0d4950a98e9cfaaab5167999c9f4ad72fff6f Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:22:39 +0100 Subject: [PATCH 063/442] fix: fix controls overlapping back button without accidentally moving controls --- apps/expo/src/components/player/Header.tsx | 2 +- apps/expo/src/components/player/MiddleControls.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 8a525a2..15723f8 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -18,7 +18,7 @@ interface HeaderProps { export const Header = ({ data }: HeaderProps) => { return ( - + {data.season && data.episode diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index 3baea7c..a0dd543 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -15,7 +15,7 @@ export const MiddleControls = () => { return ( - + From 15af963aaa0d61b26505ec57209ab3c1a8cebd9e Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:25:16 +0100 Subject: [PATCH 064/442] chore: I love vscode --- apps/expo/src/components/player/MiddleControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index a0dd543..62ebe1e 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -15,7 +15,7 @@ export const MiddleControls = () => { return ( - + From 5a23ffed6927d50945daa1c7d9c7f83a5edf54e4 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:33:54 +0100 Subject: [PATCH 065/442] chore: apparently this was fine the way it was, back button works --- apps/expo/src/components/player/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 15723f8..8a525a2 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -18,7 +18,7 @@ interface HeaderProps { export const Header = ({ data }: HeaderProps) => { return ( - + {data.season && data.episode From b04c161a9462c6f85666c259bba916a688275778 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:57:39 +0100 Subject: [PATCH 066/442] fix: hide iOS home indicator when no touch --- apps/expo/src/app/_layout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx index 5759d39..e186aaa 100644 --- a/apps/expo/src/app/_layout.tsx +++ b/apps/expo/src/app/_layout.tsx @@ -67,6 +67,7 @@ function RootLayoutNav() { - + ); From 0468b2377d0fde2fcbc6b5b171f37cbc34228ca3 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:50:57 +0100 Subject: [PATCH 067/442] feat: dismiss keyboard when navigating out of video player --- apps/expo/src/components/player/BackButton.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/BackButton.tsx b/apps/expo/src/components/player/BackButton.tsx index d027cf7..d987571 100644 --- a/apps/expo/src/components/player/BackButton.tsx +++ b/apps/expo/src/components/player/BackButton.tsx @@ -1,3 +1,4 @@ +import { Keyboard } from "react-native"; import { useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; @@ -15,10 +16,16 @@ export const BackButton = ({ onPress={() => { unlockOrientation() .then(() => { - return router.back(); + router.back(); + return setTimeout(() => { + Keyboard.dismiss(); + }, 100); }) .catch(() => { - return router.back(); + router.back(); + return setTimeout(() => { + Keyboard.dismiss(); + }, 100); }); }} size={36} From 3b84adf64559b692ebf41a4b71c48591a6429488 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 20:03:37 +0100 Subject: [PATCH 068/442] fix: keyboard catching taps on search component --- apps/expo/src/app/(tabs)/search/_layout.tsx | 5 ++++- apps/expo/src/components/item/item.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index 63937ac..cc8f237 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -22,7 +22,10 @@ export default function SearchScreen() { }; return ( - + diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 196ddda..43a1337 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -1,4 +1,4 @@ -import { Image, TouchableOpacity, View } from "react-native"; +import { Image, Keyboard, TouchableOpacity, View } from "react-native"; import { useRouter } from "expo-router"; import { Text } from "~/components/ui/Text"; @@ -16,6 +16,7 @@ export default function Item({ data }: { data: ItemData }) { const { title, type, year, posterUrl } = data; const handlePress = () => { + Keyboard.dismiss(); router.push({ pathname: "/videoPlayer/loading", params: { data: JSON.stringify(data) }, From 5c5a8bf64d78b3853d72b5e063f63e50c6a7d111 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 20:16:32 +0100 Subject: [PATCH 069/442] fix: explicity check for undefined in headerdata --- apps/expo/src/components/player/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 8a525a2..62ffa05 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -21,7 +21,7 @@ export const Header = ({ data }: HeaderProps) => { - {data.season && data.episode + {data.season !== undefined && data.episode !== undefined ? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}` : `${data.title} (${data.year})`} From 0a98e86de199d648f364959017b317ead1e7e00b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 20:49:43 +0100 Subject: [PATCH 070/442] feat: pinch-to-zoom video --- apps/expo/index.js | 1 + apps/expo/src/app/_layout.tsx | 7 +++- apps/expo/src/app/videoPlayer/index.tsx | 51 +++++++++++++++++-------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/apps/expo/index.js b/apps/expo/index.js index ab16fb5..a561933 100644 --- a/apps/expo/index.js +++ b/apps/expo/index.js @@ -1,2 +1,3 @@ import "expo-router/entry"; +import "react-native-gesture-handler"; import "@react-native-anywhere/polyfill-base64"; diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx index e186aaa..8d95069 100644 --- a/apps/expo/src/app/_layout.tsx +++ b/apps/expo/src/app/_layout.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { useEffect } from "react"; import { useColorScheme } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useFonts } from "expo-font"; import { SplashScreen, Stack } from "expo-router"; import FontAwesome from "@expo/vector-icons/FontAwesome"; @@ -57,7 +58,11 @@ export default function RootLayout() { return null; } - return ; + return ( + + + + ); } function RootLayoutNav() { diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index b9309e6..23e607a 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -1,6 +1,8 @@ import type { AVPlaybackSource } from "expo-av"; import React, { useEffect, useState } from "react"; import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; import { ResizeMode, Video } from "expo-av"; import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -37,7 +39,9 @@ const VideoPlayer: React.FC = ({ data }) => { const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); const [headerData, setHeaderData] = useState(); + const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); const router = useRouter(); + const scale = useSharedValue(1); const setVideoRef = usePlayerStore((state) => state.setVideoRef); const setStatus = usePlayerStore((state) => state.setStatus); const setIsIdle = usePlayerStore((state) => state.setIsIdle); @@ -48,6 +52,19 @@ const VideoPlayer: React.FC = ({ data }) => { (state) => state.dismissFullscreenPlayer, ); + const updateResizeMode = (newMode: ResizeMode) => { + setResizeMode(newMode); + }; + + const pinchGesture = Gesture.Pinch().onUpdate((e) => { + scale.value = e.scale; + if (scale.value > 1 && resizeMode !== ResizeMode.COVER) { + runOnJS(updateResizeMode)(ResizeMode.COVER); + } else if (scale.value <= 1 && resizeMode !== ResizeMode.CONTAIN) { + runOnJS(updateResizeMode)(ResizeMode.CONTAIN); + } + }); + useEffect(() => { const initializePlayer = async () => { StatusBar.setStatusBarHidden(true); @@ -122,22 +139,24 @@ const VideoPlayer: React.FC = ({ data }) => { }; return ( - -
} - {!isLoading && } - + + +
} + {!isLoading && } + + ); }; From 649a94844a47dbf3d112956dfb488b7b36e84906 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Tue, 13 Feb 2024 21:08:24 +0100 Subject: [PATCH 071/442] feat: double tap screen to play/pause --- apps/expo/src/app/videoPlayer/index.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 23e607a..1e2617f 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -40,6 +40,7 @@ const VideoPlayer: React.FC = ({ data }) => { const [isLoading, setIsLoading] = useState(true); const [headerData, setHeaderData] = useState(); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); + const [shouldPlay, setShouldPlay] = useState(true); const router = useRouter(); const scale = useSharedValue(1); const setVideoRef = usePlayerStore((state) => state.setVideoRef); @@ -65,6 +66,18 @@ const VideoPlayer: React.FC = ({ data }) => { } }); + const togglePlayback = () => { + setShouldPlay(!shouldPlay); + }; + + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .onEnd(() => { + runOnJS(togglePlayback)(); + }); + + const composedGesture = Gesture.Exclusive(pinchGesture, doubleTapGesture); + useEffect(() => { const initializePlayer = async () => { StatusBar.setStatusBarHidden(true); @@ -139,12 +152,12 @@ const VideoPlayer: React.FC = ({ data }) => { }; return ( - +
} - {!isLoading && } + {!isLoading && headerData && ( + + )} ); diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx new file mode 100644 index 0000000..b6ced21 --- /dev/null +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -0,0 +1,28 @@ +import { View } from "react-native"; + +import { usePlayerStore } from "~/stores/player/store"; +import { Text } from "../ui/Text"; +import { Controls } from "./Controls"; +import { mapMillisecondsToTime } from "./utils"; + +export const BottomControls = () => { + const status = usePlayerStore((state) => state.status); + status?.isLoaded; + + if (status?.isLoaded) { + return ( + + + + + {mapMillisecondsToTime(status.positionMillis ?? 0)} + + + {mapMillisecondsToTime(status.durationMillis ?? 0)} + + + + + ); + } +}; diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index 05a93d2..37fb81a 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -1,5 +1,6 @@ +import type { TouchableOpacity } from "react-native"; import React from "react"; -import { TouchableOpacity } from "react-native"; +import { TouchableWithoutFeedback } from "react-native-gesture-handler"; import { usePlayerStore } from "~/stores/player/store"; @@ -9,10 +10,14 @@ interface ControlsProps extends React.ComponentProps { export const Controls = ({ children, className }: ControlsProps) => { const idle = usePlayerStore((state) => state.interface.isIdle); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); return ( - + setIsIdle(false)} + > {!idle && children} - + ); }; diff --git a/apps/expo/src/components/player/ControlsOverlay.tsx b/apps/expo/src/components/player/ControlsOverlay.tsx new file mode 100644 index 0000000..2d44c7a --- /dev/null +++ b/apps/expo/src/components/player/ControlsOverlay.tsx @@ -0,0 +1,20 @@ +import { View } from "react-native"; + +import type { HeaderData } from "./Header"; +import { BottomControls } from "./BottomControls"; +import { Header } from "./Header"; +import { MiddleControls } from "./MiddleControls"; + +interface ControlsOverlayProps { + headerData: HeaderData; +} + +export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => { + return ( + +
+ + + + ); +}; diff --git a/apps/expo/src/components/player/Header.tsx b/apps/expo/src/components/player/Header.tsx index 62ffa05..b007a58 100644 --- a/apps/expo/src/components/player/Header.tsx +++ b/apps/expo/src/components/player/Header.tsx @@ -1,5 +1,6 @@ import { Image, View } from "react-native"; +import { usePlayerStore } from "~/stores/player/store"; import Icon from "../../../assets/images/icon-transparent.png"; import { Text } from "../ui/Text"; import { BackButton } from "./BackButton"; @@ -17,18 +18,24 @@ interface HeaderProps { } export const Header = ({ data }: HeaderProps) => { - return ( - - - - {data.season !== undefined && data.episode !== undefined - ? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}` - : `${data.title} (${data.year})`} - - - - movie-web + const isIdle = usePlayerStore((state) => state.interface.isIdle); + + if (!isIdle) { + return ( + + + + + + {data.season !== undefined && data.episode !== undefined + ? `${data.title} (${data.year}) S${data.season.toString().padStart(2, "0")}E${data.episode.toString().padStart(2, "0")}` + : `${data.title} (${data.year})`} + + + + movie-web + - - ); + ); + } }; diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index 62ebe1e..a1ba53a 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -1,31 +1,21 @@ -import { TouchableWithoutFeedback, View } from "react-native"; +import { View } from "react-native"; -import { usePlayerStore } from "~/stores/player/store"; import { Controls } from "./Controls"; import { PlayButton } from "./PlayButton"; import { SeekButton } from "./SeekButton"; export const MiddleControls = () => { - const idle = usePlayerStore((state) => state.interface.isIdle); - const setIsIdle = usePlayerStore((state) => state.setIsIdle); - - const handleTouch = () => { - setIsIdle(!idle); - }; - return ( - - - - - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/apps/expo/src/components/player/utils.ts b/apps/expo/src/components/player/utils.ts new file mode 100644 index 0000000..ab8e2ef --- /dev/null +++ b/apps/expo/src/components/player/utils.ts @@ -0,0 +1,18 @@ +export const mapMillisecondsToTime = (milliseconds: number): string => { + const hours = Math.floor(milliseconds / (1000 * 60 * 60)); + const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); + + const components: string[] = []; + + if (hours > 0) { + components.push(hours.toString().padStart(2, "0")); + } + + components.push(minutes.toString().padStart(2, "0")); + components.push(seconds.toString().padStart(2, "0")); + + const formattedTime = components.join(":"); + + return formattedTime; +}; From 85372e5e5c84416fd649ec942d054b042bd07599 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Tue, 13 Feb 2024 21:50:02 +0100 Subject: [PATCH 073/442] setup progress bar without functionalities --- .../src/components/player/BottomControls.tsx | 4 +- .../src/components/player/ProgressBar.tsx | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 apps/expo/src/components/player/ProgressBar.tsx diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index b6ced21..028eb25 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; import { Text } from "../ui/Text"; import { Controls } from "./Controls"; +import { ProgressBar } from "./ProgressBar"; import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { @@ -13,10 +14,11 @@ export const BottomControls = () => { return ( - + {mapMillisecondsToTime(status.positionMillis ?? 0)} + {mapMillisecondsToTime(status.durationMillis ?? 0)} diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx new file mode 100644 index 0000000..a2b6a3f --- /dev/null +++ b/apps/expo/src/components/player/ProgressBar.tsx @@ -0,0 +1,80 @@ +import { useCallback, useRef } from "react"; +import { Dimensions, PanResponder, TouchableOpacity, View } from "react-native"; + +import { usePlayerStore } from "~/stores/player/store"; + +export const ProgressBar = () => { + const status = usePlayerStore((state) => state.status); + const videoRef = usePlayerStore((state) => state.videoRef); + + const screenWidth = Dimensions.get("window").width; + const progressBarWidth = screenWidth - 40; // Adjust the padding as needed + + const updateProgress = useCallback( + (newProgress: number) => { + videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => { + console.log("Error updating progress"); + }); + }, + [videoRef], + ); + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderMove: (e, gestureState) => { + console.log(gestureState.moveX, gestureState.x0, gestureState.dx); + }, + onPanResponderRelease: (e, gestureState) => { + console.log("onPanResponderRelease"); + const { moveX, x0 } = gestureState; + const newProgress = (moveX - x0) / progressBarWidth; + updateProgress(newProgress); + }, + }), + ).current; + + if (status?.isLoaded) { + const progressRatio = + status.durationMillis && status.durationMillis !== 0 + ? status.positionMillis / status.durationMillis + : 0; + return ( + + {/* Progress Dot */} + + + + + {/* Full bar */} + + {/* TODO: Preloaded */} + + + {/* Progress */} + + + + ); + } +}; From a4f4f6822d18bdcc2336a9b236db283aeab53c3a Mon Sep 17 00:00:00 2001 From: Jorrin Date: Tue, 13 Feb 2024 23:05:46 +0100 Subject: [PATCH 074/442] ios? --- apps/expo/src/components/player/Controls.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index 37fb81a..99bd66c 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -1,5 +1,6 @@ import type { TouchableOpacity } from "react-native"; import React from "react"; +import { View } from "react-native"; import { TouchableWithoutFeedback } from "react-native-gesture-handler"; import { usePlayerStore } from "~/stores/player/store"; @@ -17,7 +18,7 @@ export const Controls = ({ children, className }: ControlsProps) => { className={className} onPress={() => setIsIdle(false)} > - {!idle && children} + {!idle && children} ); }; From a2b70eee3a8506ee12b205a7df3da92fa7bc4023 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:46:14 +0100 Subject: [PATCH 075/442] fix: control touch events on iOS --- apps/expo/src/components/player/Controls.tsx | 13 ++---------- .../src/components/player/ControlsOverlay.tsx | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index 99bd66c..c04eb9b 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -1,7 +1,6 @@ import type { TouchableOpacity } from "react-native"; import React from "react"; import { View } from "react-native"; -import { TouchableWithoutFeedback } from "react-native-gesture-handler"; import { usePlayerStore } from "~/stores/player/store"; @@ -9,16 +8,8 @@ interface ControlsProps extends React.ComponentProps { children: React.ReactNode; } -export const Controls = ({ children, className }: ControlsProps) => { +export const Controls = ({ children }: ControlsProps) => { const idle = usePlayerStore((state) => state.interface.isIdle); - const setIsIdle = usePlayerStore((state) => state.setIsIdle); - return ( - setIsIdle(false)} - > - {!idle && children} - - ); + return {!idle && children}; }; diff --git a/apps/expo/src/components/player/ControlsOverlay.tsx b/apps/expo/src/components/player/ControlsOverlay.tsx index 2d44c7a..0350653 100644 --- a/apps/expo/src/components/player/ControlsOverlay.tsx +++ b/apps/expo/src/components/player/ControlsOverlay.tsx @@ -1,6 +1,7 @@ -import { View } from "react-native"; +import { TouchableWithoutFeedback, View } from "react-native"; import type { HeaderData } from "./Header"; +import { usePlayerStore } from "~/stores/player/store"; import { BottomControls } from "./BottomControls"; import { Header } from "./Header"; import { MiddleControls } from "./MiddleControls"; @@ -10,11 +11,19 @@ interface ControlsOverlayProps { } export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => { + const idle = usePlayerStore((state) => state.interface.isIdle); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); + + const handleTouch = () => { + setIsIdle(!idle); + }; return ( - -
- - - + + +
+ + + + ); }; From 1fad7dbfc645f46acc44a10fbce4989e3f128e0f Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:46:57 +0100 Subject: [PATCH 076/442] chore: cleanup --- apps/expo/src/components/player/Controls.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/expo/src/components/player/Controls.tsx b/apps/expo/src/components/player/Controls.tsx index c04eb9b..f80710b 100644 --- a/apps/expo/src/components/player/Controls.tsx +++ b/apps/expo/src/components/player/Controls.tsx @@ -10,6 +10,5 @@ interface ControlsProps extends React.ComponentProps { export const Controls = ({ children }: ControlsProps) => { const idle = usePlayerStore((state) => state.interface.isIdle); - return {!idle && children}; }; From 7b1dcad3dbb3e0a73677a0c78d60cb6b77f99c3a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 13:29:36 +0100 Subject: [PATCH 077/442] fix: add bottom padding to tabbar (too low on iOS) --- apps/expo/src/app/(tabs)/_layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/expo/src/app/(tabs)/_layout.tsx b/apps/expo/src/app/(tabs)/_layout.tsx index 48a098f..fb721ec 100644 --- a/apps/expo/src/app/(tabs)/_layout.tsx +++ b/apps/expo/src/app/(tabs)/_layout.tsx @@ -19,6 +19,7 @@ export default function TabLayout() { borderTopColor: "transparent", borderTopRightRadius: 20, borderTopLeftRadius: 20, + paddingBottom: 100, height: 80, }, tabBarItemStyle: { From c140fa885b24dfc48444e009b08f552df2552938 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 14:36:56 +0100 Subject: [PATCH 078/442] add buggy videoslider --- .../src/components/player/BottomControls.tsx | 3 +- .../src/components/player/ProgressBar.tsx | 65 +------ .../src/components/player/VideoSlider.tsx | 167 ++++++++++++++++++ 3 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 apps/expo/src/components/player/VideoSlider.tsx diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 028eb25..c263702 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -8,13 +8,12 @@ import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { const status = usePlayerStore((state) => state.status); - status?.isLoaded; if (status?.isLoaded) { return ( - + {mapMillisecondsToTime(status.positionMillis ?? 0)} diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx index a2b6a3f..1eb24b2 100644 --- a/apps/expo/src/components/player/ProgressBar.tsx +++ b/apps/expo/src/components/player/ProgressBar.tsx @@ -1,79 +1,26 @@ -import { useCallback, useRef } from "react"; -import { Dimensions, PanResponder, TouchableOpacity, View } from "react-native"; +import { useCallback } from "react"; +import { View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; +import VideoSlider from "./VideoSlider"; export const ProgressBar = () => { const status = usePlayerStore((state) => state.status); const videoRef = usePlayerStore((state) => state.videoRef); - const screenWidth = Dimensions.get("window").width; - const progressBarWidth = screenWidth - 40; // Adjust the padding as needed - const updateProgress = useCallback( (newProgress: number) => { videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => { - console.log("Error updating progress"); + console.error("Error updating progress"); }); }, [videoRef], ); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderMove: (e, gestureState) => { - console.log(gestureState.moveX, gestureState.x0, gestureState.dx); - }, - onPanResponderRelease: (e, gestureState) => { - console.log("onPanResponderRelease"); - const { moveX, x0 } = gestureState; - const newProgress = (moveX - x0) / progressBarWidth; - updateProgress(newProgress); - }, - }), - ).current; - if (status?.isLoaded) { - const progressRatio = - status.durationMillis && status.durationMillis !== 0 - ? status.positionMillis / status.durationMillis - : 0; return ( - - {/* Progress Dot */} - - - - - {/* Full bar */} - - {/* TODO: Preloaded */} - - - {/* Progress */} - - + + ); } diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx new file mode 100644 index 0000000..7f6be7c --- /dev/null +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -0,0 +1,167 @@ +import type { + HandlerStateChangeEvent, + PanGestureHandlerGestureEvent, + TapGestureHandlerEventPayload, +} from "react-native-gesture-handler"; +import React, { useEffect, useRef } from "react"; +import { Dimensions, StyleSheet, View } from "react-native"; +import { + PanGestureHandler, + State, + TapGestureHandler, +} from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; + +import colors from "@movie-web/tailwind-config/colors"; + +import { usePlayerStore } from "~/stores/player/store"; + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(lowerBound, value), upperBound); +}; + +interface VideoSliderProps { + onSlidingComplete?: (value: number) => void; +} + +const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { + const status = usePlayerStore((state) => state.status); + + const width = Dimensions.get("screen").width - 140; + const knobSize_ = 20; + const trackSize_ = 8; + const minimumValue = 0; + const maximumValue = status?.isLoaded ? status.durationMillis! : 0; + const value = status?.isLoaded ? status.positionMillis : 0; + + const valueToX = (v: number) => { + if (maximumValue === minimumValue) return 0; + return (width * (v - minimumValue)) / (maximumValue - minimumValue); + }; + const xToValue = (x: number) => { + "worklet"; + if (maximumValue === minimumValue) return minimumValue; + return (x / width) * (maximumValue - minimumValue) + minimumValue; + }; + const valueX = valueToX(value); + const translateX = useSharedValue(valueToX(value)); + + const tapRef = useRef(null); + const panRef = useRef(null); + + useEffect(() => { + translateX.value = clamp(valueX, 0, width - knobSize_); + }, [valueX]); + + const _onSlidingComplete = (xValue: number) => { + "worklet"; + if (onSlidingComplete) runOnJS(onSlidingComplete)(xToValue(xValue)); + }; + + const _onActive = (value: number) => { + "worklet"; + translateX.value = clamp(value, 0, width - knobSize_); + }; + + const onGestureEvent = useAnimatedGestureHandler< + PanGestureHandlerGestureEvent, + { offsetX: number } + >({ + onStart: (_, ctx) => (ctx.offsetX = translateX.value), + onActive: (event, ctx) => _onActive(event.translationX + ctx.offsetX), + }); + + const onTapEvent = ( + event: HandlerStateChangeEvent, + ) => { + if (event.nativeEvent.state === State.ACTIVE) { + _onActive(event.nativeEvent.x); + _onSlidingComplete(event.nativeEvent.x); + } + }; + + const scrollTranslationStyle = useAnimatedStyle(() => { + return { transform: [{ translateX: translateX.value }] }; + }); + + const progressStyle = useAnimatedStyle(() => { + return { + width: translateX.value + knobSize_, + }; + }); + + return ( + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + knob: { + justifyContent: "center", + alignItems: "center", + }, +}); + +export default VideoSlider; From 91d85deccb5a70a76e2908ca2daaa17ab620e2eb Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 14:41:32 +0100 Subject: [PATCH 079/442] adjust width --- apps/expo/src/components/player/VideoSlider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index 7f6be7c..d7c0c57 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -33,7 +33,7 @@ interface VideoSliderProps { const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const status = usePlayerStore((state) => state.status); - const width = Dimensions.get("screen").width - 140; + const width = Dimensions.get("screen").width - 160; const knobSize_ = 20; const trackSize_ = 8; const minimumValue = 0; From bd6c2409c3ecd52c83fcd3ea019cc040d47038e1 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 14:48:41 +0100 Subject: [PATCH 080/442] feat: gesture controls --- apps/expo/app.config.ts | 1 + apps/expo/package.json | 1 + apps/expo/src/app/videoPlayer/index.tsx | 80 ++++++++++++++++++++++++- pnpm-lock.yaml | 11 ++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index d03a74d..dc04199 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -27,6 +27,7 @@ const defineConfig = (): ExpoConfig => ({ foregroundImage: "./assets/images/adaptive-icon.png", backgroundColor: "#FFFFFF", }, + permissions: ["WRITE_SETTINGS"], }, web: { favicon: "./assets/images/favicon.png", diff --git a/apps/expo/package.json b/apps/expo/package.json index 9c420ca..02c8999 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -27,6 +27,7 @@ "clsx": "^2.1.0", "expo": "~50.0.5", "expo-av": "~13.10.5", + "expo-brightness": "~11.8.0", "expo-build-properties": "~0.11.1", "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 88dccf0..f834c8e 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -1,9 +1,16 @@ import type { AVPlaybackSource } from "expo-av"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; +import { + ActivityIndicator, + Dimensions, + Platform, + StyleSheet, + View, +} from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS, useSharedValue } from "react-native-reanimated"; import { ResizeMode, Video } from "expo-av"; +import * as Brightness from "expo-brightness"; import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; @@ -40,6 +47,7 @@ const VideoPlayer: React.FC = ({ data }) => { const [headerData, setHeaderData] = useState(); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); const [shouldPlay, setShouldPlay] = useState(true); + const [currentVolume, setCurrentVolume] = useState(0.5); const router = useRouter(); const scale = useSharedValue(1); const setVideoRef = usePlayerStore((state) => state.setVideoRef); @@ -76,7 +84,53 @@ const VideoPlayer: React.FC = ({ data }) => { runOnJS(togglePlayback)(); }); - const composedGesture = Gesture.Exclusive(pinchGesture, doubleTapGesture); + const brightness = useSharedValue(0.5); + + const handleVolumeChange = (newValue: number) => { + setCurrentVolume(newValue); + }; + + const handleBrightnessChange = async (newValue: number) => { + try { + await Brightness.setBrightnessAsync(newValue); + } catch (error) { + console.error("Failed to set brightness:", error); + } + }; + + const screenHalfWidth = Dimensions.get("window").width / 2; + + const panGesture = Gesture.Pan() + .onStart((event) => { + const isRightHalf = event.x > screenHalfWidth; + if (isRightHalf) { + runOnJS(setCurrentVolume)(0.5); + } else { + brightness.value = 0.5; + } + }) + .onUpdate((event) => { + const divisor = 5000; + if (event.x > screenHalfWidth) { + const change = -event.translationY / divisor; + const newVolume = Math.max(0, Math.min(1, currentVolume + change)); + runOnJS(handleVolumeChange)(newVolume); + } else { + const change = -event.translationY / divisor; + const newBrightness = Math.max( + 0, + Math.min(1, brightness.value + change), + ); + brightness.value = newBrightness; + runOnJS(handleBrightnessChange)(newBrightness); + } + }); + + const composedGesture = Gesture.Exclusive( + panGesture, + pinchGesture, + doubleTapGesture, + ); useEffect(() => { const initializePlayer = async () => { @@ -90,6 +144,19 @@ const VideoPlayer: React.FC = ({ data }) => { if (Platform.OS === "android") { await NavigationBar.setVisibilityAsync("hidden"); } + + const { status } = await Brightness.requestPermissionsAsync(); + if (status !== Brightness.PermissionStatus.GRANTED) { + console.warn("Brightness permissions not granted"); + } + + try { + const currentBrightness = await Brightness.getBrightnessAsync(); + brightness.value = currentBrightness; + } catch (error) { + console.error("Failed to get initial brightness:", error); + } + setIsLoading(true); const { item, stream, media } = data; @@ -141,7 +208,13 @@ const VideoPlayer: React.FC = ({ data }) => { void NavigationBar.setVisibilityAsync("visible"); } }; - }, [data, dismissFullscreenPlayer, presentFullscreenPlayer, router]); + }, [ + brightness, + data, + dismissFullscreenPlayer, + presentFullscreenPlayer, + router, + ]); const onVideoLoadStart = () => { setIsLoading(true); @@ -159,6 +232,7 @@ const VideoPlayer: React.FC = ({ data }) => { source={videoSrc} shouldPlay={shouldPlay} resizeMode={resizeMode} + volume={currentVolume} onLoadStart={onVideoLoadStart} onReadyForDisplay={onReadyForDisplay} onPlaybackStatusUpdate={setStatus} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1986990..c50e331 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: expo-av: specifier: ~13.10.5 version: 13.10.5(expo@50.0.5) + expo-brightness: + specifier: ~11.8.0 + version: 11.8.0(expo@50.0.5) expo-build-properties: specifier: ~0.11.1 version: 0.11.1(expo@50.0.5) @@ -5517,6 +5520,14 @@ packages: expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) dev: false + /expo-brightness@11.8.0(expo@50.0.5): + resolution: {integrity: sha512-ipQA7s8PvJVhy+Ls6Dsql0veXXV5CdMcbXNPwQuXTbUofRE+8FHO0vasShMZlKYcD9KNgFygjx0U+THi80dtAw==} + peerDependencies: + expo: '*' + dependencies: + expo: 50.0.5(@babel/core@7.23.9)(@react-native/babel-preset@0.73.20) + dev: false + /expo-build-properties@0.11.1(expo@50.0.5): resolution: {integrity: sha512-m4j4aEjFaDuBE6KWYMxDhWgLzzSmpE7uHKAwtvXyNmRK+6JKF0gjiXi0sXgI5ngNppDQpsyPFMvqG7uQpRuCuw==} peerDependencies: From 88da9895f92932a5ce8010404de1830ca9dca00e Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:08:16 +0100 Subject: [PATCH 081/442] feat: pretty overlays for gesture controls --- apps/expo/src/app/videoPlayer/index.tsx | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index f834c8e..96167d9 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -5,6 +5,7 @@ import { Dimensions, Platform, StyleSheet, + Text, View, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -47,6 +48,8 @@ const VideoPlayer: React.FC = ({ data }) => { const [headerData, setHeaderData] = useState(); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); const [shouldPlay, setShouldPlay] = useState(true); + const [showVolumeOverlay, setShowVolumeOverlay] = useState(false); + const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false); const [currentVolume, setCurrentVolume] = useState(0.5); const router = useRouter(); const scale = useSharedValue(1); @@ -88,11 +91,15 @@ const VideoPlayer: React.FC = ({ data }) => { const handleVolumeChange = (newValue: number) => { setCurrentVolume(newValue); + setShowVolumeOverlay(true); + setTimeout(() => setShowVolumeOverlay(false), 2000); }; const handleBrightnessChange = async (newValue: number) => { try { await Brightness.setBrightnessAsync(newValue); + setShowBrightnessOverlay(true); + setTimeout(() => setShowBrightnessOverlay(false), 2000); } catch (error) { console.error("Failed to set brightness:", error); } @@ -243,6 +250,20 @@ const VideoPlayer: React.FC = ({ data }) => { {!isLoading && headerData && ( )} + {showVolumeOverlay && ( + + + Volume: {Math.round(currentVolume * 100)}% + + + )} + {showBrightnessOverlay && ( + + + Brightness: {Math.round(brightness.value * 100)}% + + + )} ); @@ -286,4 +307,16 @@ const styles = StyleSheet.create({ left: 0, right: 0, }, + overlay: { + position: "absolute", + bottom: 50, + alignSelf: "center", + backgroundColor: "rgba(0,0,0,0.5)", + padding: 10, + borderRadius: 5, + }, + overlayText: { + color: "#fff", + fontSize: 16, + }, }); From e6ace2615f43c5bb592121d8eb71aae051bac8e3 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 15:10:52 +0100 Subject: [PATCH 082/442] only add bottom padding on ios --- apps/expo/src/app/(tabs)/_layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/(tabs)/_layout.tsx b/apps/expo/src/app/(tabs)/_layout.tsx index fb721ec..2794ca8 100644 --- a/apps/expo/src/app/(tabs)/_layout.tsx +++ b/apps/expo/src/app/(tabs)/_layout.tsx @@ -1,4 +1,4 @@ -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { Tabs } from "expo-router"; import Colors from "@movie-web/tailwind-config/colors"; @@ -19,7 +19,7 @@ export default function TabLayout() { borderTopColor: "transparent", borderTopRightRadius: 20, borderTopLeftRadius: 20, - paddingBottom: 100, + paddingBottom: Platform.OS === "ios" ? 100 : 0, height: 80, }, tabBarItemStyle: { From ea6698b6e419cae12055b4018d84881348bf00be Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 17:43:35 +0100 Subject: [PATCH 083/442] cleanup brightness hooks --- apps/expo/src/app/videoPlayer/index.tsx | 78 +++++---------------- apps/expo/src/hooks/player/useBrightness.ts | 46 ++++++++++++ apps/expo/src/hooks/useDebounce.ts | 17 +++++ 3 files changed, 81 insertions(+), 60 deletions(-) create mode 100644 apps/expo/src/hooks/player/useBrightness.ts create mode 100644 apps/expo/src/hooks/useDebounce.ts diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 96167d9..6ab8900 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -5,13 +5,11 @@ import { Dimensions, Platform, StyleSheet, - Text, View, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS, useSharedValue } from "react-native-reanimated"; import { ResizeMode, Video } from "expo-av"; -import * as Brightness from "expo-brightness"; import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; @@ -22,6 +20,8 @@ import { findHighestQuality } from "@movie-web/provider-utils"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; import { ControlsOverlay } from "~/components/player/ControlsOverlay"; +import { Text } from "~/components/ui/Text"; +import { useBrightness } from "~/hooks/player/useBrightness"; import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { @@ -43,16 +43,23 @@ interface VideoPlayerProps { } const VideoPlayer: React.FC = ({ data }) => { + const { + brightness, + debouncedBrightness, + showBrightnessOverlay, + setShowBrightnessOverlay, + handleBrightnessChange, + } = useBrightness(); const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); const [headerData, setHeaderData] = useState(); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); const [shouldPlay, setShouldPlay] = useState(true); const [showVolumeOverlay, setShowVolumeOverlay] = useState(false); - const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false); const [currentVolume, setCurrentVolume] = useState(0.5); const router = useRouter(); const scale = useSharedValue(1); + const setVideoRef = usePlayerStore((state) => state.setVideoRef); const setStatus = usePlayerStore((state) => state.setStatus); const isIdle = usePlayerStore((state) => state.interface.isIdle); @@ -87,35 +94,15 @@ const VideoPlayer: React.FC = ({ data }) => { runOnJS(togglePlayback)(); }); - const brightness = useSharedValue(0.5); - const handleVolumeChange = (newValue: number) => { setCurrentVolume(newValue); setShowVolumeOverlay(true); setTimeout(() => setShowVolumeOverlay(false), 2000); }; - const handleBrightnessChange = async (newValue: number) => { - try { - await Brightness.setBrightnessAsync(newValue); - setShowBrightnessOverlay(true); - setTimeout(() => setShowBrightnessOverlay(false), 2000); - } catch (error) { - console.error("Failed to set brightness:", error); - } - }; - const screenHalfWidth = Dimensions.get("window").width / 2; const panGesture = Gesture.Pan() - .onStart((event) => { - const isRightHalf = event.x > screenHalfWidth; - if (isRightHalf) { - runOnJS(setCurrentVolume)(0.5); - } else { - brightness.value = 0.5; - } - }) .onUpdate((event) => { const divisor = 5000; if (event.x > screenHalfWidth) { @@ -131,6 +118,9 @@ const VideoPlayer: React.FC = ({ data }) => { brightness.value = newBrightness; runOnJS(handleBrightnessChange)(newBrightness); } + }) + .onEnd(() => { + runOnJS(setShowBrightnessOverlay)(false); }); const composedGesture = Gesture.Exclusive( @@ -152,18 +142,6 @@ const VideoPlayer: React.FC = ({ data }) => { await NavigationBar.setVisibilityAsync("hidden"); } - const { status } = await Brightness.requestPermissionsAsync(); - if (status !== Brightness.PermissionStatus.GRANTED) { - console.warn("Brightness permissions not granted"); - } - - try { - const currentBrightness = await Brightness.getBrightnessAsync(); - brightness.value = currentBrightness; - } catch (error) { - console.error("Failed to get initial brightness:", error); - } - setIsLoading(true); const { item, stream, media } = data; @@ -215,13 +193,7 @@ const VideoPlayer: React.FC = ({ data }) => { void NavigationBar.setVisibilityAsync("visible"); } }; - }, [ - brightness, - data, - dismissFullscreenPlayer, - presentFullscreenPlayer, - router, - ]); + }, [data, dismissFullscreenPlayer, presentFullscreenPlayer, router]); const onVideoLoadStart = () => { setIsLoading(true); @@ -251,17 +223,15 @@ const VideoPlayer: React.FC = ({ data }) => { )} {showVolumeOverlay && ( - - + + Volume: {Math.round(currentVolume * 100)}% )} {showBrightnessOverlay && ( - - - Brightness: {Math.round(brightness.value * 100)}% - + + Brightness: {debouncedBrightness} )} @@ -307,16 +277,4 @@ const styles = StyleSheet.create({ left: 0, right: 0, }, - overlay: { - position: "absolute", - bottom: 50, - alignSelf: "center", - backgroundColor: "rgba(0,0,0,0.5)", - padding: 10, - borderRadius: 5, - }, - overlayText: { - color: "#fff", - fontSize: 16, - }, }); diff --git a/apps/expo/src/hooks/player/useBrightness.ts b/apps/expo/src/hooks/player/useBrightness.ts new file mode 100644 index 0000000..844c4b2 --- /dev/null +++ b/apps/expo/src/hooks/player/useBrightness.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSharedValue } from "react-native-reanimated"; +import * as Brightness from "expo-brightness"; + +import { useDebounce } from "../useDebounce"; + +export const useBrightness = () => { + const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false); + const debouncedShowBrightnessOverlay = useDebounce(showBrightnessOverlay, 20); + const brightness = useSharedValue(0.5); + const debouncedBrightness = useDebounce(brightness.value, 20); + + useEffect(() => { + async function init() { + try { + const { status } = await Brightness.requestPermissionsAsync(); + if (status === Brightness.PermissionStatus.GRANTED) { + const currentBrightness = await Brightness.getBrightnessAsync(); + brightness.value = currentBrightness; + } + } catch (error) { + console.error("Failed to get brightness permissions:", error); + } + } + + void init(); + }, []); + + const handleBrightnessChange = useCallback(async (newValue: number) => { + try { + setShowBrightnessOverlay(true); + brightness.value = newValue; + await Brightness.setBrightnessAsync(newValue); + } catch (error) { + console.error("Failed to set brightness:", error); + } + }, []); + + return { + showBrightnessOverlay: debouncedShowBrightnessOverlay, + brightness, + debouncedBrightness: `${Math.round(debouncedBrightness * 100)}%`, + setShowBrightnessOverlay, + handleBrightnessChange, + } as const; +}; diff --git a/apps/expo/src/hooks/useDebounce.ts b/apps/expo/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5b11976 --- /dev/null +++ b/apps/expo/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export const useDebounce = (value: T, delay?: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay ?? 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; From 82a3f431facabff190cca52691c0971db55029b3 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 18:32:37 +0100 Subject: [PATCH 084/442] fix slider progress color --- apps/expo/src/components/player/VideoSlider.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index d7c0c57..abd3ccd 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -33,7 +33,7 @@ interface VideoSliderProps { const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const status = usePlayerStore((state) => state.status); - const width = Dimensions.get("screen").width - 160; + const width = Dimensions.get("screen").width - 200; const knobSize_ = 20; const trackSize_ = 8; const minimumValue = 0; @@ -124,9 +124,10 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { ]} > Date: Wed, 14 Feb 2024 18:55:30 +0100 Subject: [PATCH 085/442] chore: this should work whenever expo-router gets a release --- apps/expo/src/app/_layout.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx index 8d95069..aad4fb0 100644 --- a/apps/expo/src/app/_layout.tsx +++ b/apps/expo/src/app/_layout.tsx @@ -74,6 +74,9 @@ function RootLayoutNav() { screenOptions={{ autoHideHomeIndicator: true, gestureEnabled: true, + animation: "default", + animationTypeForReplace: "push", + presentation: "card", headerShown: false, contentStyle: { backgroundColor: Colors.background, @@ -82,7 +85,14 @@ function RootLayoutNav() { > From c670047713ab092d086257c6048da3fd7c9cf419 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 20:00:36 +0100 Subject: [PATCH 086/442] volume cleanup --- apps/expo/src/app/videoPlayer/index.tsx | 31 +++++++++++-------- .../src/components/player/ControlsOverlay.tsx | 12 +++---- .../src/components/player/MiddleControls.tsx | 16 ++++++++-- .../src/components/player/ProgressBar.tsx | 10 ++++-- .../src/components/player/VideoSlider.tsx | 5 ++- apps/expo/src/hooks/player/useVolume.ts | 24 ++++++++++++++ 6 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 apps/expo/src/hooks/player/useVolume.ts diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 6ab8900..f2ac9ac 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -22,6 +22,7 @@ import type { HeaderData } from "~/components/player/Header"; import { ControlsOverlay } from "~/components/player/ControlsOverlay"; import { Text } from "~/components/ui/Text"; import { useBrightness } from "~/hooks/player/useBrightness"; +import { useVolume } from "~/hooks/player/useVolume"; import { usePlayerStore } from "~/stores/player/store"; export default function VideoPlayerWrapper() { @@ -50,13 +51,18 @@ const VideoPlayer: React.FC = ({ data }) => { setShowBrightnessOverlay, handleBrightnessChange, } = useBrightness(); + const { + currentVolume, + debouncedVolume, + showVolumeOverlay, + setShowVolumeOverlay, + handleVolumeChange, + } = useVolume(); const [videoSrc, setVideoSrc] = useState(); const [isLoading, setIsLoading] = useState(true); const [headerData, setHeaderData] = useState(); const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN); const [shouldPlay, setShouldPlay] = useState(true); - const [showVolumeOverlay, setShowVolumeOverlay] = useState(false); - const [currentVolume, setCurrentVolume] = useState(0.5); const router = useRouter(); const scale = useSharedValue(1); @@ -94,20 +100,20 @@ const VideoPlayer: React.FC = ({ data }) => { runOnJS(togglePlayback)(); }); - const handleVolumeChange = (newValue: number) => { - setCurrentVolume(newValue); - setShowVolumeOverlay(true); - setTimeout(() => setShowVolumeOverlay(false), 2000); - }; - const screenHalfWidth = Dimensions.get("window").width / 2; const panGesture = Gesture.Pan() .onUpdate((event) => { const divisor = 5000; + const dragIsNotInHeaderOrFooter = event.y < 100 || event.y > 400; + if (dragIsNotInHeaderOrFooter) return; + if (event.x > screenHalfWidth) { const change = -event.translationY / divisor; - const newVolume = Math.max(0, Math.min(1, currentVolume + change)); + const newVolume = Math.max( + 0, + Math.min(1, currentVolume.value + change), + ); runOnJS(handleVolumeChange)(newVolume); } else { const change = -event.translationY / divisor; @@ -120,6 +126,7 @@ const VideoPlayer: React.FC = ({ data }) => { } }) .onEnd(() => { + runOnJS(setShowVolumeOverlay)(false); runOnJS(setShowBrightnessOverlay)(false); }); @@ -211,7 +218,7 @@ const VideoPlayer: React.FC = ({ data }) => { source={videoSrc} shouldPlay={shouldPlay} resizeMode={resizeMode} - volume={currentVolume} + volume={currentVolume.value} onLoadStart={onVideoLoadStart} onReadyForDisplay={onReadyForDisplay} onPlaybackStatusUpdate={setStatus} @@ -224,9 +231,7 @@ const VideoPlayer: React.FC = ({ data }) => { )} {showVolumeOverlay && ( - - Volume: {Math.round(currentVolume * 100)}% - + Volume: {debouncedVolume} )} {showBrightnessOverlay && ( diff --git a/apps/expo/src/components/player/ControlsOverlay.tsx b/apps/expo/src/components/player/ControlsOverlay.tsx index 0350653..70d22d1 100644 --- a/apps/expo/src/components/player/ControlsOverlay.tsx +++ b/apps/expo/src/components/player/ControlsOverlay.tsx @@ -18,12 +18,12 @@ export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => { setIsIdle(!idle); }; return ( - - -
+ +
+ - - - + + + ); }; diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index a1ba53a..1737429 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -1,4 +1,4 @@ -import { View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { Controls } from "./Controls"; import { PlayButton } from "./PlayButton"; @@ -6,8 +6,8 @@ import { SeekButton } from "./SeekButton"; export const MiddleControls = () => { return ( - - + + @@ -19,3 +19,13 @@ export const MiddleControls = () => { ); }; + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 82, + }, +}); diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx index 1eb24b2..d532bbd 100644 --- a/apps/expo/src/components/player/ProgressBar.tsx +++ b/apps/expo/src/components/player/ProgressBar.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { View } from "react-native"; +import { TouchableOpacity } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; import VideoSlider from "./VideoSlider"; @@ -7,6 +7,7 @@ import VideoSlider from "./VideoSlider"; export const ProgressBar = () => { const status = usePlayerStore((state) => state.status); const videoRef = usePlayerStore((state) => state.videoRef); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); const updateProgress = useCallback( (newProgress: number) => { @@ -19,9 +20,12 @@ export const ProgressBar = () => { if (status?.isLoaded) { return ( - + setIsIdle(false)} + > - + ); } }; diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index abd3ccd..3f0becc 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -31,6 +31,8 @@ interface VideoSliderProps { } const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { + const tapRef = useRef(null); + const panRef = useRef(null); const status = usePlayerStore((state) => state.status); const width = Dimensions.get("screen").width - 200; @@ -52,9 +54,6 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const valueX = valueToX(value); const translateX = useSharedValue(valueToX(value)); - const tapRef = useRef(null); - const panRef = useRef(null); - useEffect(() => { translateX.value = clamp(valueX, 0, width - knobSize_); }, [valueX]); diff --git a/apps/expo/src/hooks/player/useVolume.ts b/apps/expo/src/hooks/player/useVolume.ts new file mode 100644 index 0000000..42e5661 --- /dev/null +++ b/apps/expo/src/hooks/player/useVolume.ts @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; +import { useSharedValue } from "react-native-reanimated"; + +import { useDebounce } from "../useDebounce"; + +export const useVolume = () => { + const [showVolumeOverlay, setShowVolumeOverlay] = useState(false); + const debouncedShowVolumeOverlay = useDebounce(showVolumeOverlay, 20); + const volume = useSharedValue(1); + const debouncedVolume = useDebounce(volume.value, 20); + + const handleVolumeChange = useCallback((newValue: number) => { + volume.value = newValue; + setShowVolumeOverlay(true); + }, []); + + return { + showVolumeOverlay: debouncedShowVolumeOverlay, + currentVolume: volume, + debouncedVolume: `${Math.round(debouncedVolume * 100)}%`, + setShowVolumeOverlay, + handleVolumeChange, + } as const; +}; From 94c3ad5862ae32113c75f70a684f4df01385e0a4 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 21:24:50 +0100 Subject: [PATCH 087/442] fix brightness positioning --- apps/expo/src/app/videoPlayer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index f2ac9ac..f865fa7 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -235,7 +235,7 @@ const VideoPlayer: React.FC = ({ data }) => { )} {showBrightnessOverlay && ( - + Brightness: {debouncedBrightness} )} From 52e90c60394911bcc38a1549fef1d88f1f22f49a Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 21:32:16 +0100 Subject: [PATCH 088/442] fix slider not triggering idle --- apps/expo/src/components/player/VideoSlider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index 3f0becc..b10bb84 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -34,6 +34,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const tapRef = useRef(null); const panRef = useRef(null); const status = usePlayerStore((state) => state.status); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); const width = Dimensions.get("screen").width - 200; const knobSize_ = 20; @@ -66,6 +67,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const _onActive = (value: number) => { "worklet"; translateX.value = clamp(value, 0, width - knobSize_); + runOnJS(setIsIdle)(false); }; const onGestureEvent = useAnimatedGestureHandler< From 6ebdb6820a4cc994e87456e09f685be81365cb7f Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:49:51 +0100 Subject: [PATCH 089/442] fix: control overlay on iOS --- apps/expo/src/components/player/ControlsOverlay.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/expo/src/components/player/ControlsOverlay.tsx b/apps/expo/src/components/player/ControlsOverlay.tsx index 70d22d1..0350653 100644 --- a/apps/expo/src/components/player/ControlsOverlay.tsx +++ b/apps/expo/src/components/player/ControlsOverlay.tsx @@ -18,12 +18,12 @@ export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => { setIsIdle(!idle); }; return ( - -
- + + +
- - - + + + ); }; From 439ba8c7e5301b9324c18e2f3e9b68afd2ffad3d Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:53:33 +0100 Subject: [PATCH 090/442] fix: fix the controls so they don't intefere with bottom controls --- .../src/components/player/ControlsOverlay.tsx | 21 ++++-------- .../src/components/player/MiddleControls.tsx | 34 ++++++++++++------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/expo/src/components/player/ControlsOverlay.tsx b/apps/expo/src/components/player/ControlsOverlay.tsx index 0350653..2d44c7a 100644 --- a/apps/expo/src/components/player/ControlsOverlay.tsx +++ b/apps/expo/src/components/player/ControlsOverlay.tsx @@ -1,7 +1,6 @@ -import { TouchableWithoutFeedback, View } from "react-native"; +import { View } from "react-native"; import type { HeaderData } from "./Header"; -import { usePlayerStore } from "~/stores/player/store"; import { BottomControls } from "./BottomControls"; import { Header } from "./Header"; import { MiddleControls } from "./MiddleControls"; @@ -11,19 +10,11 @@ interface ControlsOverlayProps { } export const ControlsOverlay = ({ headerData }: ControlsOverlayProps) => { - const idle = usePlayerStore((state) => state.interface.isIdle); - const setIsIdle = usePlayerStore((state) => state.setIsIdle); - - const handleTouch = () => { - setIsIdle(!idle); - }; return ( - - -
- - - - + +
+ + + ); }; diff --git a/apps/expo/src/components/player/MiddleControls.tsx b/apps/expo/src/components/player/MiddleControls.tsx index 1737429..6d4f269 100644 --- a/apps/expo/src/components/player/MiddleControls.tsx +++ b/apps/expo/src/components/player/MiddleControls.tsx @@ -1,22 +1,32 @@ -import { StyleSheet, View } from "react-native"; +import { StyleSheet, TouchableWithoutFeedback, View } from "react-native"; +import { usePlayerStore } from "~/stores/player/store"; import { Controls } from "./Controls"; import { PlayButton } from "./PlayButton"; import { SeekButton } from "./SeekButton"; export const MiddleControls = () => { + const idle = usePlayerStore((state) => state.interface.isIdle); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); + + const handleTouch = () => { + setIsIdle(!idle); + }; + return ( - - - - - - - - - - - + + + + + + + + + + + + + ); }; From 61f3e77f588f30a8d9828e7b01212a24ebe5fcdd Mon Sep 17 00:00:00 2001 From: Jorrin Date: Wed, 14 Feb 2024 22:18:07 +0100 Subject: [PATCH 091/442] fix slider resetting while sliding --- apps/expo/src/components/player/VideoSlider.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index b10bb84..d1f4e8a 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -54,10 +54,13 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { }; const valueX = valueToX(value); const translateX = useSharedValue(valueToX(value)); + const isDragging = useSharedValue(false); useEffect(() => { - translateX.value = clamp(valueX, 0, width - knobSize_); - }, [valueX]); + if (!isDragging.value) { + translateX.value = clamp(valueX, 0, width - knobSize_); + } + }, [valueX, isDragging.value]); const _onSlidingComplete = (xValue: number) => { "worklet"; @@ -66,16 +69,26 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const _onActive = (value: number) => { "worklet"; + isDragging.value = true; translateX.value = clamp(value, 0, width - knobSize_); runOnJS(setIsIdle)(false); }; + const _onEnd = () => { + "worklet"; + isDragging.value = false; + _onSlidingComplete(translateX.value); + }; + const onGestureEvent = useAnimatedGestureHandler< PanGestureHandlerGestureEvent, { offsetX: number } >({ onStart: (_, ctx) => (ctx.offsetX = translateX.value), onActive: (event, ctx) => _onActive(event.translationX + ctx.offsetX), + onEnd: _onEnd, + onCancel: _onEnd, + onFinish: _onEnd, }); const onTapEvent = ( From e72be7af6cd17e8787f2a7afa60106683a22564a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:11:24 +0100 Subject: [PATCH 092/442] chore: adjust some gesture stuff --- apps/expo/src/app/videoPlayer/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index f865fa7..e9d11dc 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -105,8 +105,8 @@ const VideoPlayer: React.FC = ({ data }) => { const panGesture = Gesture.Pan() .onUpdate((event) => { const divisor = 5000; - const dragIsNotInHeaderOrFooter = event.y < 100 || event.y > 400; - if (dragIsNotInHeaderOrFooter) return; + const dragIsInHeaderOrFooter = event.y < 100 || event.y > 400; + if (dragIsInHeaderOrFooter) return; if (event.x > screenHalfWidth) { const change = -event.translationY / divisor; @@ -130,7 +130,7 @@ const VideoPlayer: React.FC = ({ data }) => { runOnJS(setShowBrightnessOverlay)(false); }); - const composedGesture = Gesture.Exclusive( + const composedGesture = Gesture.Race( panGesture, pinchGesture, doubleTapGesture, From 3e4a6cc3b275dd2cd018db545be3f7b99095c17e Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:38:18 +0100 Subject: [PATCH 093/442] feat: detect pan gesture direction and adjust values accordingly --- apps/expo/src/app/videoPlayer/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index e9d11dc..d27e88d 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -108,15 +108,19 @@ const VideoPlayer: React.FC = ({ data }) => { const dragIsInHeaderOrFooter = event.y < 100 || event.y > 400; if (dragIsInHeaderOrFooter) return; + const directionMultiplier = event.velocityY < 0 ? 1 : -1; + if (event.x > screenHalfWidth) { - const change = -event.translationY / divisor; + const change = + directionMultiplier * Math.abs(event.velocityY / divisor); const newVolume = Math.max( 0, Math.min(1, currentVolume.value + change), ); runOnJS(handleVolumeChange)(newVolume); } else { - const change = -event.translationY / divisor; + const change = + directionMultiplier * Math.abs(event.velocityY / divisor); const newBrightness = Math.max( 0, Math.min(1, brightness.value + change), From 83dd90e61ca1a3121c577adc2370d80d2e32cf52 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:38:45 +0100 Subject: [PATCH 094/442] chore: wording --- apps/expo/src/app/videoPlayer/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index d27e88d..76bc000 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -105,8 +105,8 @@ const VideoPlayer: React.FC = ({ data }) => { const panGesture = Gesture.Pan() .onUpdate((event) => { const divisor = 5000; - const dragIsInHeaderOrFooter = event.y < 100 || event.y > 400; - if (dragIsInHeaderOrFooter) return; + const panIsInHeaderOrFooter = event.y < 100 || event.y > 400; + if (panIsInHeaderOrFooter) return; const directionMultiplier = event.velocityY < 0 ? 1 : -1; From 4d8a61baba98815aa7cb6353b6f3c9124a30db2e Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:39:32 +0100 Subject: [PATCH 095/442] chore: cleanup --- apps/expo/src/app/videoPlayer/index.tsx | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 76bc000..d55e80a 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -256,28 +256,6 @@ const VideoPlayer: React.FC = ({ data }) => { // language: string; // } -// const captionTypeToTextTracksType = { -// srt: TextTracksType.SUBRIP, -// vtt: TextTracksType.VTT, -// }; - -// function convertCaptionsToTextTracks(captions: Caption[]): TextTracks { -// return captions -// .map((caption) => { -// if (Platform.OS === "ios" && caption.type !== "vtt") { -// return null; -// } - -// return { -// title: caption.language, -// language: caption.language as ISO639_1, -// type: captionTypeToTextTracksType[caption.type], -// uri: caption.url, -// }; -// }) -// .filter(Boolean) as TextTracks; -// } - const styles = StyleSheet.create({ video: { position: "absolute", From 8da4ad579c33606ef8d9f863f52115738eb96c77 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:48:09 +0100 Subject: [PATCH 096/442] chore: clean this stuff up too --- apps/expo/src/app/(tabs)/about.tsx | 10 ++-------- apps/expo/src/app/(tabs)/account.tsx | 7 ++----- apps/expo/src/app/(tabs)/index.tsx | 4 ++-- apps/expo/src/app/(tabs)/search/_layout.tsx | 1 - apps/expo/src/app/(tabs)/settings.tsx | 4 ++-- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/apps/expo/src/app/(tabs)/about.tsx b/apps/expo/src/app/(tabs)/about.tsx index a4a13e9..b3930ee 100644 --- a/apps/expo/src/app/(tabs)/about.tsx +++ b/apps/expo/src/app/(tabs)/about.tsx @@ -3,14 +3,8 @@ import { Text } from "~/components/ui/Text"; export default function AboutScreen() { return ( - - - No content is served from movie-web directly and movie web does not host - anything. - + + About tab ); } diff --git a/apps/expo/src/app/(tabs)/account.tsx b/apps/expo/src/app/(tabs)/account.tsx index 9fe4517..572ac36 100644 --- a/apps/expo/src/app/(tabs)/account.tsx +++ b/apps/expo/src/app/(tabs)/account.tsx @@ -3,11 +3,8 @@ import { Text } from "~/components/ui/Text"; export default function AccountScreen() { return ( - - Hey Bro! what are you up to? + + Account tab ); } diff --git a/apps/expo/src/app/(tabs)/index.tsx b/apps/expo/src/app/(tabs)/index.tsx index 0b86033..c6085c5 100644 --- a/apps/expo/src/app/(tabs)/index.tsx +++ b/apps/expo/src/app/(tabs)/index.tsx @@ -3,8 +3,8 @@ import { Text } from "~/components/ui/Text"; export default function HomeScreen() { return ( - - Movies will be listed here + + Home tab ); } diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index cc8f237..656733a 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -32,7 +32,6 @@ export default function SearchScreen() { Search } - subtitle="Looking for something?" > diff --git a/apps/expo/src/app/(tabs)/settings.tsx b/apps/expo/src/app/(tabs)/settings.tsx index bf4fbc8..eb5441b 100644 --- a/apps/expo/src/app/(tabs)/settings.tsx +++ b/apps/expo/src/app/(tabs)/settings.tsx @@ -3,8 +3,8 @@ import { Text } from "~/components/ui/Text"; export default function SettingsScreen() { return ( - - Settings would be listed in here. Coming soon + + Settings tab ); } From 6ecf3f58416ee6b1f93da1dca1e3b13a00588367 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 14 Feb 2024 22:56:39 +0100 Subject: [PATCH 097/442] chore: make this a useful placeholder tab --- apps/expo/src/app/(tabs)/_layout.tsx | 6 +++--- apps/expo/src/app/(tabs)/{about.tsx => downloads.tsx} | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) rename apps/expo/src/app/(tabs)/{about.tsx => downloads.tsx} (55%) diff --git a/apps/expo/src/app/(tabs)/_layout.tsx b/apps/expo/src/app/(tabs)/_layout.tsx index 2794ca8..4706d6e 100644 --- a/apps/expo/src/app/(tabs)/_layout.tsx +++ b/apps/expo/src/app/(tabs)/_layout.tsx @@ -43,11 +43,11 @@ export default function TabLayout() { }} /> ( - + ), }} /> diff --git a/apps/expo/src/app/(tabs)/about.tsx b/apps/expo/src/app/(tabs)/downloads.tsx similarity index 55% rename from apps/expo/src/app/(tabs)/about.tsx rename to apps/expo/src/app/(tabs)/downloads.tsx index b3930ee..f1dbb9e 100644 --- a/apps/expo/src/app/(tabs)/about.tsx +++ b/apps/expo/src/app/(tabs)/downloads.tsx @@ -1,10 +1,10 @@ import ScreenLayout from "~/components/layout/ScreenLayout"; import { Text } from "~/components/ui/Text"; -export default function AboutScreen() { +export default function DownloadsScreen() { return ( - - About tab + + Downloads tab ); } From 4d754061ea17513e2d4b95950ac8ef4b80eb278a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 10:24:41 +0100 Subject: [PATCH 098/442] chore: bump action versions --- .github/workflows/build-mobile-comment.yml | 6 +++--- .github/workflows/build-mobile.yml | 6 +++--- .github/workflows/release-mobile.yml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-mobile-comment.yml b/.github/workflows/build-mobile-comment.yml index 189ccaa..1d11fda 100644 --- a/.github/workflows/build-mobile-comment.yml +++ b/.github/workflows/build-mobile-comment.yml @@ -26,14 +26,14 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 run_install: false - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -83,7 +83,7 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 58041f8..64771ec 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -22,14 +22,14 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 run_install: false - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -75,7 +75,7 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index 5cea704..47dd14f 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -41,14 +41,14 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 run_install: false - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -89,7 +89,7 @@ jobs: with: node-version: 21 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 From 35a3ab805092279cdfaaa3c1e1b064ed6bedca30 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 10:56:48 +0100 Subject: [PATCH 099/442] feat: add function to parse hls tracks --- packages/provider-utils/package.json | 2 ++ packages/provider-utils/src/video.ts | 27 +++++++++++++++++++++++++++ pnpm-lock.yaml | 16 ++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index 4f0f106..b971795 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -18,6 +18,7 @@ "@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0", + "@types/hls-parser": "^0.8.7", "eslint": "^8.56.0", "prettier": "^3.1.1", "typescript": "^5.3.3" @@ -30,6 +31,7 @@ "prettier": "@movie-web/prettier-config", "dependencies": { "@movie-web/providers": "^2.2.0", + "hls-parser": "^0.10.8", "srt-webvtt": "^2.0.0", "tmdb-ts": "^1.6.1" } diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 8fe74e3..50bbf92 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,3 +1,5 @@ +import { parse } from "hls-parser"; +import { MasterPlaylist } from "hls-parser/types"; import { default as toWebVTT } from "srt-webvtt"; import type { @@ -103,3 +105,28 @@ export function findHighestQuality( } return undefined; } + +export async function extractTracksFromHLS( + playlistUrl: string, + headers: Record, +) { + try { + const response = await fetch(playlistUrl, { headers }).then((res) => + res.text(), + ); + const playlist = parse(response); + if (!playlist.isMasterPlaylist) return null; + if (!(playlist instanceof MasterPlaylist)) return null; + + const tracks = playlist.variants.map((variant) => { + return { + video: variant.video, + audio: variant.audio, + subtitles: variant.subtitles, + }; + }); + return tracks; + } catch (e) { + return null; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50e331..8f66d45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@movie-web/providers': specifier: ^2.2.0 version: 2.2.0 + hls-parser: + specifier: ^0.10.8 + version: 0.10.8 srt-webvtt: specifier: ^2.0.0 version: 2.0.0 @@ -198,6 +201,9 @@ importers: '@movie-web/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/hls-parser': + specifier: ^0.8.7 + version: 0.8.7 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -3163,6 +3169,12 @@ packages: resolution: {integrity: sha512-qkcUlZmX6c4J8q45taBKTL3p+LbITgyx7qhlPYOdOHZB7B31K0mXbP5YA7i7SgDeEGuI9MnumiKPEMrxg8j3KQ==} dev: false + /@types/hls-parser@0.8.7: + resolution: {integrity: sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ==} + dependencies: + '@types/node': 20.11.16 + dev: true + /@types/inquirer@6.5.0: resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} dependencies: @@ -6338,6 +6350,10 @@ packages: source-map: 0.7.4 dev: false + /hls-parser@0.10.8: + resolution: {integrity: sha512-7FSqn7HYqXEW7I3qcgHJbtpNzi2nLKlBblPvHV6Uc6esVZ8JGfN3xUV7739gfaOh+w/O6TFxysLyW3GeLlAJTA==} + dev: false + /hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} dependencies: From 0ab9ebbcc6ab26ed66b85e7fe0b1af23d3995543 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:42:57 +0100 Subject: [PATCH 100/442] chore: typeroots --- packages/provider-utils/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index 12305a4..9472c13 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "typeRoots": ["./node_modules/@types"], }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From 4aa964d1e1c5ac5c2546942c7bfa537897273d92 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:43:13 +0100 Subject: [PATCH 101/442] chore: adjustment --- packages/provider-utils/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index 9472c13..6133a2d 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "typeRoots": ["./node_modules/@types"], + "typeRoots": ["node_modules/@types"], }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From 4090869b48ce856751036c71f92e5c35d7ba7b18 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:47:26 +0100 Subject: [PATCH 102/442] chore: come on ci --- packages/provider-utils/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index 6133a2d..3fbd23f 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "typeRoots": ["node_modules/@types"], + "typeRoots": ["../node_modules/@types"], }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From bbff23985bbef10e8b7735b252e18d637efe90d9 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:49:34 +0100 Subject: [PATCH 103/442] chore: fine keep failing then --- packages/provider-utils/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index 3fbd23f..12305a4 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,7 +2,6 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "typeRoots": ["../node_modules/@types"], }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From bf19b1c8edc8004106a19c3a7380f5109afa1c86 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 12:03:43 +0100 Subject: [PATCH 104/442] chore: add this for when I find media with tracks --- apps/expo/src/app/videoPlayer/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index d55e80a..f741911 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -15,7 +15,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; import type { ScrapeMedia, Stream } from "@movie-web/provider-utils"; -import { findHighestQuality } from "@movie-web/provider-utils"; +import { extractTracksFromHLS, findHighestQuality } from "@movie-web/provider-utils"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; @@ -166,6 +166,7 @@ const VideoPlayer: React.FC = ({ data }) => { let highestQuality; let url; + let tracks; switch (stream.type) { case "file": @@ -174,13 +175,12 @@ const VideoPlayer: React.FC = ({ data }) => { return url ?? null; case "hls": url = stream.playlist; + tracks = await extractTracksFromHLS(url, { ...stream.preferredHeaders, ...stream.headers }); } - // setTextTracks( - // stream.captions && stream.captions.length > 0 - // ? convertCaptionsToTextTracks(stream.captions) - // : [], - // ); + if (tracks) { + console.log(tracks); + } setVideoSrc({ uri: url, From c0d0730cfe0d8ddb35e38654c2a1dbd513350a27 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 12:04:00 +0100 Subject: [PATCH 105/442] chore: formatting --- apps/expo/src/app/videoPlayer/index.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index f741911..397e623 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -15,7 +15,10 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; import type { ScrapeMedia, Stream } from "@movie-web/provider-utils"; -import { extractTracksFromHLS, findHighestQuality } from "@movie-web/provider-utils"; +import { + extractTracksFromHLS, + findHighestQuality, +} from "@movie-web/provider-utils"; import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; @@ -166,7 +169,7 @@ const VideoPlayer: React.FC = ({ data }) => { let highestQuality; let url; - let tracks; + let tracks; switch (stream.type) { case "file": @@ -175,12 +178,15 @@ const VideoPlayer: React.FC = ({ data }) => { return url ?? null; case "hls": url = stream.playlist; - tracks = await extractTracksFromHLS(url, { ...stream.preferredHeaders, ...stream.headers }); + tracks = await extractTracksFromHLS(url, { + ...stream.preferredHeaders, + ...stream.headers, + }); } - if (tracks) { - console.log(tracks); - } + if (tracks) { + console.log(tracks); + } setVideoSrc({ uri: url, From 33b2f04da6f55ee6369cfd0b0a64178e4984c3a9 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 15 Feb 2024 19:04:07 +0100 Subject: [PATCH 106/442] Update tsconfig.json --- packages/provider-utils/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index 12305a4..de6f73b 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "skipLibCheck": true, }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From 76c277ac967c1679812eddd00346190514332667 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 15 Feb 2024 19:19:01 +0100 Subject: [PATCH 107/442] revert --- packages/provider-utils/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/provider-utils/tsconfig.json b/packages/provider-utils/tsconfig.json index de6f73b..12305a4 100644 --- a/packages/provider-utils/tsconfig.json +++ b/packages/provider-utils/tsconfig.json @@ -2,7 +2,6 @@ "extends": "@movie-web/tsconfig/base.json", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "skipLibCheck": true, }, "include": ["*.ts", "src"], "exclude": ["node_modules"], From 36678a6580a37f9dadf60409ac0227fc574bcc74 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 19:28:00 +0100 Subject: [PATCH 108/442] feat: show remaining time in bottomcontrols when time is tapped --- .../src/components/player/BottomControls.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index c263702..e68eb2b 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -1,4 +1,5 @@ -import { View } from "react-native"; +import { useState } from "react"; +import { TouchableOpacity, View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; import { Text } from "../ui/Text"; @@ -8,6 +9,22 @@ import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { const status = usePlayerStore((state) => state.status); + const [showRemaining, setShowRemaining] = useState(false); + + const toggleTimeDisplay = () => { + setShowRemaining(!showRemaining); + }; + + const getTimeDisplay = () => { + if (status?.isLoaded) { + if (showRemaining) { + const remainingTime = + (status.durationMillis ?? 0) - (status.positionMillis ?? 0); + return "-" + mapMillisecondsToTime(remainingTime); + } + return mapMillisecondsToTime(status.durationMillis ?? 0); + } + }; if (status?.isLoaded) { return ( @@ -18,9 +35,9 @@ export const BottomControls = () => { {mapMillisecondsToTime(status.positionMillis ?? 0)} - - {mapMillisecondsToTime(status.durationMillis ?? 0)} - + + {getTimeDisplay()} + From 9147472b84b6d551834c8bbca31b7fb46c040603 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 15 Feb 2024 19:47:28 +0100 Subject: [PATCH 109/442] fix types --- packages/provider-utils/src/video.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 50bbf92..a2b2752 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,5 +1,4 @@ -import { parse } from "hls-parser"; -import { MasterPlaylist } from "hls-parser/types"; +import { parse, types } from "hls-parser"; import { default as toWebVTT } from "srt-webvtt"; import type { @@ -116,7 +115,7 @@ export async function extractTracksFromHLS( ); const playlist = parse(response); if (!playlist.isMasterPlaylist) return null; - if (!(playlist instanceof MasterPlaylist)) return null; + if (!(playlist instanceof types.MasterPlaylist)) return null; const tracks = playlist.variants.map((variant) => { return { From b81ff76d982121ffeb9fbe9e49a51253b109de33 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 19:58:55 +0100 Subject: [PATCH 110/442] refactor: use parse-hls --- packages/provider-utils/package.json | 3 +-- packages/provider-utils/src/video.ts | 20 +++++++------------- pnpm-lock.yaml | 23 +++++++---------------- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/provider-utils/package.json b/packages/provider-utils/package.json index b971795..0da05a1 100644 --- a/packages/provider-utils/package.json +++ b/packages/provider-utils/package.json @@ -18,7 +18,6 @@ "@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0", - "@types/hls-parser": "^0.8.7", "eslint": "^8.56.0", "prettier": "^3.1.1", "typescript": "^5.3.3" @@ -31,7 +30,7 @@ "prettier": "@movie-web/prettier-config", "dependencies": { "@movie-web/providers": "^2.2.0", - "hls-parser": "^0.10.8", + "parse-hls": "^1.0.7", "srt-webvtt": "^2.0.0", "tmdb-ts": "^1.6.1" } diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index a2b2752..a02dd14 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,4 +1,4 @@ -import { parse, types } from "hls-parser"; +import hls from "parse-hls"; import { default as toWebVTT } from "srt-webvtt"; import type { @@ -113,18 +113,12 @@ export async function extractTracksFromHLS( const response = await fetch(playlistUrl, { headers }).then((res) => res.text(), ); - const playlist = parse(response); - if (!playlist.isMasterPlaylist) return null; - if (!(playlist instanceof types.MasterPlaylist)) return null; - - const tracks = playlist.variants.map((variant) => { - return { - video: variant.video, - audio: variant.audio, - subtitles: variant.subtitles, - }; - }); - return tracks; + const playlist = hls.parse(response); + return { + video: playlist.streamRenditions, + audio: playlist.audioRenditions, + subtitles: playlist.subtitlesRenditions, + }; } catch (e) { return null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f66d45..e56b976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,9 +182,9 @@ importers: '@movie-web/providers': specifier: ^2.2.0 version: 2.2.0 - hls-parser: - specifier: ^0.10.8 - version: 0.10.8 + parse-hls: + specifier: ^1.0.7 + version: 1.0.7 srt-webvtt: specifier: ^2.0.0 version: 2.0.0 @@ -201,9 +201,6 @@ importers: '@movie-web/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript - '@types/hls-parser': - specifier: ^0.8.7 - version: 0.8.7 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -3169,12 +3166,6 @@ packages: resolution: {integrity: sha512-qkcUlZmX6c4J8q45taBKTL3p+LbITgyx7qhlPYOdOHZB7B31K0mXbP5YA7i7SgDeEGuI9MnumiKPEMrxg8j3KQ==} dev: false - /@types/hls-parser@0.8.7: - resolution: {integrity: sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ==} - dependencies: - '@types/node': 20.11.16 - dev: true - /@types/inquirer@6.5.0: resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} dependencies: @@ -6350,10 +6341,6 @@ packages: source-map: 0.7.4 dev: false - /hls-parser@0.10.8: - resolution: {integrity: sha512-7FSqn7HYqXEW7I3qcgHJbtpNzi2nLKlBblPvHV6Uc6esVZ8JGfN3xUV7739gfaOh+w/O6TFxysLyW3GeLlAJTA==} - dev: false - /hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} dependencies: @@ -8427,6 +8414,10 @@ packages: safe-buffer: 5.2.1 dev: false + /parse-hls@1.0.7: + resolution: {integrity: sha512-tnAK2nXe8J/Jf66SwY2cUAKKXInLR9hkNhTtcS7t6J4CgkG8LGBfC1GuuXg7kLLbIQLXpVhZrY/tfyhDbqfzwg==} + dev: false + /parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} From 53106d8b7b0eb4cfa851eaa23c2386cab1d0d661 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 20:07:48 +0100 Subject: [PATCH 111/442] chore: cleanup --- apps/expo/src/app/videoPlayer/index.tsx | 8 ++------ packages/provider-utils/src/video.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 397e623..d3788d6 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -169,7 +169,7 @@ const VideoPlayer: React.FC = ({ data }) => { let highestQuality; let url; - let tracks; + let _tracks; switch (stream.type) { case "file": @@ -178,16 +178,12 @@ const VideoPlayer: React.FC = ({ data }) => { return url ?? null; case "hls": url = stream.playlist; - tracks = await extractTracksFromHLS(url, { + _tracks = await extractTracksFromHLS(url, { ...stream.preferredHeaders, ...stream.headers, }); } - if (tracks) { - console.log(tracks); - } - setVideoSrc({ uri: url, headers: { diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index a02dd14..144b369 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -1,3 +1,4 @@ +import type { Item } from "parse-hls"; import hls from "parse-hls"; import { default as toWebVTT } from "srt-webvtt"; @@ -105,10 +106,16 @@ export function findHighestQuality( return undefined; } +export interface HLSPlaylist { + video: Item[]; + audio: Item[]; + subtitles: Item[]; +} + export async function extractTracksFromHLS( playlistUrl: string, headers: Record, -) { +): Promise { try { const response = await fetch(playlistUrl, { headers }).then((res) => res.text(), From b3dbb7f33467c251aef3293394b6e9da2d503ba4 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 20:09:19 +0100 Subject: [PATCH 112/442] chore: more cleanup --- apps/expo/src/app/videoPlayer/index.tsx | 4 ++-- packages/provider-utils/src/video.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index d3788d6..f0bcf78 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -14,7 +14,7 @@ import * as NavigationBar from "expo-navigation-bar"; import { useLocalSearchParams, useRouter } from "expo-router"; import * as StatusBar from "expo-status-bar"; -import type { ScrapeMedia, Stream } from "@movie-web/provider-utils"; +import type { HLSTracks, ScrapeMedia, Stream } from "@movie-web/provider-utils"; import { extractTracksFromHLS, findHighestQuality, @@ -169,7 +169,7 @@ const VideoPlayer: React.FC = ({ data }) => { let highestQuality; let url; - let _tracks; + let _tracks: HLSTracks | null; switch (stream.type) { case "file": diff --git a/packages/provider-utils/src/video.ts b/packages/provider-utils/src/video.ts index 144b369..4fd6bb1 100644 --- a/packages/provider-utils/src/video.ts +++ b/packages/provider-utils/src/video.ts @@ -106,7 +106,7 @@ export function findHighestQuality( return undefined; } -export interface HLSPlaylist { +export interface HLSTracks { video: Item[]; audio: Item[]; subtitles: Item[]; @@ -115,7 +115,7 @@ export interface HLSPlaylist { export async function extractTracksFromHLS( playlistUrl: string, headers: Record, -): Promise { +): Promise { try { const response = await fetch(playlistUrl, { headers }).then((res) => res.text(), From 149daa3435e6f19c9328a2b07d06cdbfab7acb93 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 20:59:54 +0100 Subject: [PATCH 113/442] feat: pretty native context menu on search items --- apps/expo/package.json | 1 + apps/expo/src/components/item/item.tsx | 39 +++++++++++++++----------- pnpm-lock.yaml | 13 +++++++++ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/expo/package.json b/apps/expo/package.json index 02c8999..0026c21 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -42,6 +42,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-native": "0.73.2", + "react-native-context-menu-view": "^1.14.1", "react-native-css-interop": "~0.0.22", "react-native-gesture-handler": "~2.14.1", "react-native-quick-base64": "^2.0.8", diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 43a1337..130c83b 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -1,4 +1,5 @@ import { Image, Keyboard, TouchableOpacity, View } from "react-native"; +import ContextMenu from "react-native-context-menu-view"; import { useRouter } from "expo-router"; import { Text } from "~/components/ui/Text"; @@ -23,28 +24,32 @@ export default function Item({ data }: { data: ItemData }) { }); }; + const contextMenuActions = [ + { title: "Bookmark" }, + ...(type === "movie" ? [{ title: "Download" }] : []), + ]; + + const onContextMenuPress = (_e: unknown) => { + // do stuff + }; + return ( - { - + + - - - {title} - - - {type === "tv" ? "Show" : "Movie"} - - - {year} + + + {title} + + + {type === "tv" ? "Show" : "Movie"} + + + {year} - } + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e56b976..a16c188 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: react-native: specifier: 0.73.2 version: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) + react-native-context-menu-view: + specifier: ^1.14.1 + version: 1.14.1(react-native@0.73.2)(react@18.2.0) react-native-css-interop: specifier: ~0.0.22 version: 0.0.22(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1) @@ -8949,6 +8952,16 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: false + /react-native-context-menu-view@1.14.1(react-native@0.73.2)(react@18.2.0): + resolution: {integrity: sha512-rPtC6RCbEVismTQ6M7WSt1HisNvgbS9bWqWX4RQXNXHKOKsVvXpI+bWRypFAjeBN/P+winn6Dxn1+meLBMrjmQ==} + peerDependencies: + react: ^16.8.1 || ^17.0.0 || ^18.0.0 + react-native: '>=0.60.0-rc.0 <1.0.x' + dependencies: + react: 18.2.0 + react-native: 0.73.2(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) + dev: false + /react-native-css-interop@0.0.22(@babel/core@7.23.9)(react-native-reanimated@3.6.2)(react-native-safe-area-context@4.8.2)(react-native@0.73.2)(react@18.2.0)(tailwindcss@3.4.1): resolution: {integrity: sha512-JHLYHlLEqM13dy0XSxIPOWvqmQkPrqUt+KHPkbLV0sIiw/4aN6B5TPsNKZFX9bJJaZ//dAECn782R0MqDrTBWQ==} engines: {node: '>=16'} From 4f86d44f35657d4796349b5fe05261e8a3f818c4 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 21:04:51 +0100 Subject: [PATCH 114/442] chore: add type --- apps/expo/src/components/item/item.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index 130c83b..a805ac6 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -1,3 +1,5 @@ +import type { NativeSyntheticEvent } from "react-native"; +import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view"; import { Image, Keyboard, TouchableOpacity, View } from "react-native"; import ContextMenu from "react-native-context-menu-view"; import { useRouter } from "expo-router"; @@ -29,7 +31,9 @@ export default function Item({ data }: { data: ItemData }) { ...(type === "movie" ? [{ title: "Download" }] : []), ]; - const onContextMenuPress = (_e: unknown) => { + const onContextMenuPress = ( + _e: NativeSyntheticEvent, + ) => { // do stuff }; From 37360c427739b8ef98e94e80c66e0132258142ee Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 22:13:48 +0100 Subject: [PATCH 115/442] feat: searchbar UX --- apps/expo/src/app/(tabs)/search/Searchbar.tsx | 2 +- apps/expo/src/app/(tabs)/search/_layout.tsx | 71 +++++++++++++------ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/expo/src/app/(tabs)/search/Searchbar.tsx b/apps/expo/src/app/(tabs)/search/Searchbar.tsx index c26a4e2..0125e92 100644 --- a/apps/expo/src/app/(tabs)/search/Searchbar.tsx +++ b/apps/expo/src/app/(tabs)/search/Searchbar.tsx @@ -32,7 +32,7 @@ export default function Searchbar({ }; return ( - + diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index 656733a..e7c46f3 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; -import { ScrollView, View } from "react-native"; +import React, { useRef, useState } from "react"; +import { Animated, ScrollView, View } from "react-native"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; @@ -11,6 +11,7 @@ import Searchbar from "./Searchbar"; export default function SearchScreen() { const [searchResults, setSearchResults] = useState([]); + const fadeAnim = useRef(new Animated.Value(1)).current; const handleSearchChange = async (query: string) => { if (query.length > 0) { @@ -21,28 +22,58 @@ export default function SearchScreen() { } }; + const handleScrollBegin = () => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(); + }; + + const handleScrollEnd = () => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + }; + return ( - - - Search + + + + Search + + } + > + + {searchResults.map((item, index) => ( + + + + ))} - } + + + - - {searchResults.map((item, index) => ( - - - - ))} - - - + + ); } From 2723c44b089a526228d48b7ebfd891aceba003d7 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 22:50:12 +0100 Subject: [PATCH 116/442] feat: searchbar follows keyboard --- apps/expo/src/app/(tabs)/search/_layout.tsx | 58 +++++++++++++++++-- .../src/components/player/VideoSlider.tsx | 2 +- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index e7c46f3..b15b0da 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -1,5 +1,5 @@ -import React, { useRef, useState } from "react"; -import { Animated, ScrollView, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { Animated, Dimensions, Keyboard, ScrollView, View } from "react-native"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; @@ -12,6 +12,7 @@ import Searchbar from "./Searchbar"; export default function SearchScreen() { const [searchResults, setSearchResults] = useState([]); const fadeAnim = useRef(new Animated.Value(1)).current; + const translateYAnim = useRef(new Animated.Value(0)).current; const handleSearchChange = async (query: string) => { if (query.length > 0) { @@ -22,10 +23,56 @@ export default function SearchScreen() { } }; + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + "keyboardDidShow", + (e) => { + const screenHeight = Dimensions.get("window").height; + const endY = e.endCoordinates.screenY; + const translateY = screenHeight - endY; + + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(translateYAnim, { + toValue: -translateY + 100, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }, + ); + const keyboardDidHideListener = Keyboard.addListener( + "keyboardDidHide", + () => { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(translateYAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }, + ); + + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, [fadeAnim, translateYAnim]); + const handleScrollBegin = () => { Animated.timing(fadeAnim, { toValue: 0, - duration: 300, + duration: 100, useNativeDriver: true, }).start(); }; @@ -33,7 +80,7 @@ export default function SearchScreen() { const handleScrollEnd = () => { Animated.timing(fadeAnim, { toValue: 1, - duration: 300, + duration: 100, useNativeDriver: true, }).start(); }; @@ -65,9 +112,10 @@ export default function SearchScreen() { diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index d1f4e8a..30a3c70 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -60,7 +60,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { if (!isDragging.value) { translateX.value = clamp(valueX, 0, width - knobSize_); } - }, [valueX, isDragging.value]); + }, [valueX, isDragging.value, translateX, width]); const _onSlidingComplete = (xValue: number) => { "worklet"; From 1a08ed0c106f7adc1c261dc440874d19486ff1d3 Mon Sep 17 00:00:00 2001 From: Jorrin Date: Thu, 15 Feb 2024 23:00:12 +0100 Subject: [PATCH 117/442] opacity on overlay --- apps/expo/src/app/videoPlayer/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index f0bcf78..967bfd4 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -228,7 +228,14 @@ const VideoPlayer: React.FC = ({ data }) => { onLoadStart={onVideoLoadStart} onReadyForDisplay={onReadyForDisplay} onPlaybackStatusUpdate={setStatus} - style={styles.video} + style={[ + styles.video, + { + ...(!isIdle && { + opacity: 0.7, + }), + }, + ]} onTouchStart={() => setIsIdle(!isIdle)} /> {isLoading && } From 1676bc71d3f35e1719644be42cd0354f2e49faf4 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 23:17:33 +0100 Subject: [PATCH 118/442] chore: higher value for android maybe --- apps/expo/src/app/(tabs)/search/_layout.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index b15b0da..228918a 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -1,5 +1,12 @@ import React, { useEffect, useRef, useState } from "react"; -import { Animated, Dimensions, Keyboard, ScrollView, View } from "react-native"; +import { + Animated, + Dimensions, + Keyboard, + Platform, + ScrollView, + View, +} from "react-native"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; @@ -38,7 +45,9 @@ export default function SearchScreen() { useNativeDriver: true, }), Animated.timing(translateYAnim, { - toValue: -translateY + 100, + toValue: + -translateY + + Platform.select({ ios: 100, android: 300, default: 0 }), duration: 200, useNativeDriver: true, }), From ff3bd54fcd48d04d1539c634758fac6f007254d2 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 15 Feb 2024 23:22:38 +0100 Subject: [PATCH 119/442] chore: use platform select here as well --- apps/expo/src/app/(tabs)/_layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/app/(tabs)/_layout.tsx b/apps/expo/src/app/(tabs)/_layout.tsx index 4706d6e..0b598b5 100644 --- a/apps/expo/src/app/(tabs)/_layout.tsx +++ b/apps/expo/src/app/(tabs)/_layout.tsx @@ -19,7 +19,7 @@ export default function TabLayout() { borderTopColor: "transparent", borderTopRightRadius: 20, borderTopLeftRadius: 20, - paddingBottom: Platform.OS === "ios" ? 100 : 0, + paddingBottom: Platform.select({ ios: 100, android: 0 }), height: 80, }, tabBarItemStyle: { From c811800afb577ee8887abd15a0325292a66b7274 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Fri, 16 Feb 2024 09:02:29 +0100 Subject: [PATCH 120/442] fix: context menu long press on android --- apps/expo/src/components/item/item.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx index a805ac6..6fbb25e 100644 --- a/apps/expo/src/components/item/item.tsx +++ b/apps/expo/src/components/item/item.tsx @@ -38,7 +38,12 @@ export default function Item({ data }: { data: ItemData }) { }; return ( - + {}} + style={{ width: "100%" }} + > From a9918824845af191a3c6fc7715bdc16ac950616c Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:04:50 +0100 Subject: [PATCH 121/442] fix: scrollview shouldn't scroll when no results --- apps/expo/src/app/(tabs)/search/_layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/expo/src/app/(tabs)/search/_layout.tsx b/apps/expo/src/app/(tabs)/search/_layout.tsx index 228918a..30199cb 100644 --- a/apps/expo/src/app/(tabs)/search/_layout.tsx +++ b/apps/expo/src/app/(tabs)/search/_layout.tsx @@ -99,6 +99,7 @@ export default function SearchScreen() { 0} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" > From eaeb535208df6384fa8d7bc3f790ba77f47d30ca Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:36:59 +0100 Subject: [PATCH 122/442] feat: move duration above progress bar --- .../src/components/player/BottomControls.tsx | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index e68eb2b..78abddb 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -15,29 +15,40 @@ export const BottomControls = () => { setShowRemaining(!showRemaining); }; - const getTimeDisplay = () => { + const getCurrentTime = () => { if (status?.isLoaded) { - if (showRemaining) { - const remainingTime = - (status.durationMillis ?? 0) - (status.positionMillis ?? 0); - return "-" + mapMillisecondsToTime(remainingTime); - } - return mapMillisecondsToTime(status.durationMillis ?? 0); + return mapMillisecondsToTime(status.positionMillis ?? 0); + } + }; + + const getRemainingTime = () => { + if (status?.isLoaded) { + const remainingTime = + (status.durationMillis ?? 0) - (status.positionMillis ?? 0); + return "-" + mapMillisecondsToTime(remainingTime); } }; if (status?.isLoaded) { return ( - - - - {mapMillisecondsToTime(status.positionMillis ?? 0)} - - - - {getTimeDisplay()} - + + + + {getCurrentTime()} + / + + + {showRemaining + ? "-" + getRemainingTime() + : mapMillisecondsToTime(status.durationMillis ?? 0)} + + + + + + + From 404c269e8df5d60cc5cb18c17ecfc16d273c0197 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:18:32 +0100 Subject: [PATCH 123/442] fix: double dash oops --- apps/expo/src/components/player/BottomControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 78abddb..06b623b 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -40,7 +40,7 @@ export const BottomControls = () => { {showRemaining - ? "-" + getRemainingTime() + ? getRemainingTime() : mapMillisecondsToTime(status.durationMillis ?? 0)} From d9964f5a72f726542fd0deb60f189b3347ca61fc Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Fri, 16 Feb 2024 14:41:36 +0100 Subject: [PATCH 124/442] fix: slider lenght & duration position --- apps/expo/src/components/player/BottomControls.tsx | 2 +- apps/expo/src/components/player/VideoSlider.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 06b623b..035df80 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -34,7 +34,7 @@ export const BottomControls = () => { - + {getCurrentTime()} / diff --git a/apps/expo/src/components/player/VideoSlider.tsx b/apps/expo/src/components/player/VideoSlider.tsx index 30a3c70..1679360 100644 --- a/apps/expo/src/components/player/VideoSlider.tsx +++ b/apps/expo/src/components/player/VideoSlider.tsx @@ -36,7 +36,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const status = usePlayerStore((state) => state.status); const setIsIdle = usePlayerStore((state) => state.setIsIdle); - const width = Dimensions.get("screen").width - 200; + const width = Dimensions.get("screen").width - 100; const knobSize_ = 20; const trackSize_ = 8; const minimumValue = 0; From 52eab1e8e8d16c760543b5bf28498c66662583bc Mon Sep 17 00:00:00 2001 From: Jorrin Date: Fri, 16 Feb 2024 21:25:29 +0100 Subject: [PATCH 125/442] first version of a really buggy and ugly caption selector and renderer --- apps/expo/package.json | 2 + apps/expo/src/app/videoPlayer/index.tsx | 7 +- .../src/components/player/BottomControls.tsx | 14 ++- .../src/components/player/CaptionRenderer.tsx | 101 ++++++++++++++++++ .../components/player/CaptionsSelector.tsx | 84 +++++++++++++++ .../src/components/player/ControlsOverlay.tsx | 2 +- apps/expo/src/components/player/Header.tsx | 4 +- .../src/components/player/MiddleControls.tsx | 3 + .../src/components/player/ProgressBar.tsx | 2 +- .../src/components/player/VideoSlider.tsx | 2 +- apps/expo/src/components/ui/Button.tsx | 60 +++++++++++ apps/expo/src/hooks/useBoolean.ts | 19 ++++ apps/expo/src/lib/number.ts | 3 + apps/expo/src/stores/captions/index.ts | 33 ++++++ .../src/stores/player/slices/interface.ts | 12 +++ pnpm-lock.yaml | 29 +++++ tooling/tailwind/colors.ts | 3 + 17 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 apps/expo/src/components/player/CaptionRenderer.tsx create mode 100644 apps/expo/src/components/player/CaptionsSelector.tsx create mode 100644 apps/expo/src/components/ui/Button.tsx create mode 100644 apps/expo/src/hooks/useBoolean.ts create mode 100644 apps/expo/src/lib/number.ts create mode 100644 apps/expo/src/stores/captions/index.ts diff --git a/apps/expo/package.json b/apps/expo/package.json index 0026c21..f0b5e44 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -45,12 +45,14 @@ "react-native-context-menu-view": "^1.14.1", "react-native-css-interop": "~0.0.22", "react-native-gesture-handler": "~2.14.1", + "react-native-modal": "^13.0.1", "react-native-quick-base64": "^2.0.8", "react-native-quick-crypto": "^0.6.1", "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "~4.8.2", "react-native-screens": "~3.29.0", "react-native-web": "^0.19.10", + "subsrt-ts": "^2.1.2", "tailwind-merge": "^2.2.1", "zustand": "^4.4.7" }, diff --git a/apps/expo/src/app/videoPlayer/index.tsx b/apps/expo/src/app/videoPlayer/index.tsx index 967bfd4..d078918 100644 --- a/apps/expo/src/app/videoPlayer/index.tsx +++ b/apps/expo/src/app/videoPlayer/index.tsx @@ -22,6 +22,7 @@ import { import type { ItemData } from "~/components/item/item"; import type { HeaderData } from "~/components/player/Header"; +import { CaptionRenderer } from "~/components/player/CaptionRenderer"; import { ControlsOverlay } from "~/components/player/ControlsOverlay"; import { Text } from "~/components/ui/Text"; import { useBrightness } from "~/hooks/player/useBrightness"; @@ -69,9 +70,10 @@ const VideoPlayer: React.FC = ({ data }) => { const router = useRouter(); const scale = useSharedValue(1); + const isIdle = usePlayerStore((state) => state.interface.isIdle); + const setStream = usePlayerStore((state) => state.setStream); const setVideoRef = usePlayerStore((state) => state.setVideoRef); const setStatus = usePlayerStore((state) => state.setStatus); - const isIdle = usePlayerStore((state) => state.interface.isIdle); const setIsIdle = usePlayerStore((state) => state.setIsIdle); const presentFullscreenPlayer = usePlayerStore( (state) => state.presentFullscreenPlayer, @@ -160,6 +162,8 @@ const VideoPlayer: React.FC = ({ data }) => { const { item, stream, media } = data; + setStream(stream); + setHeaderData({ title: item.title, year: item.year, @@ -252,6 +256,7 @@ const VideoPlayer: React.FC = ({ data }) => { Brightness: {debouncedBrightness} )} + ); diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx index 035df80..8ece3a2 100644 --- a/apps/expo/src/components/player/BottomControls.tsx +++ b/apps/expo/src/components/player/BottomControls.tsx @@ -3,15 +3,18 @@ import { TouchableOpacity, View } from "react-native"; import { usePlayerStore } from "~/stores/player/store"; import { Text } from "../ui/Text"; +import { CaptionsSelector } from "./CaptionsSelector"; import { Controls } from "./Controls"; import { ProgressBar } from "./ProgressBar"; import { mapMillisecondsToTime } from "./utils"; export const BottomControls = () => { const status = usePlayerStore((state) => state.status); + const setIsIdle = usePlayerStore((state) => state.setIsIdle); const [showRemaining, setShowRemaining] = useState(false); const toggleTimeDisplay = () => { + setIsIdle(false); setShowRemaining(!showRemaining); }; @@ -32,9 +35,9 @@ export const BottomControls = () => { if (status?.isLoaded) { return ( - - - + + + {getCurrentTime()} / @@ -46,9 +49,12 @@ export const BottomControls = () => { - + + + + diff --git a/apps/expo/src/components/player/CaptionRenderer.tsx b/apps/expo/src/components/player/CaptionRenderer.tsx new file mode 100644 index 0000000..85023da --- /dev/null +++ b/apps/expo/src/components/player/CaptionRenderer.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from "react-native-reanimated"; + +import { Text } from "~/components/ui/Text"; +import { convertMilliSecondsToSeconds } from "~/lib/number"; +import { useCaptionsStore } from "~/stores/captions"; +import { usePlayerStore } from "~/stores/player/store"; + +export const captionIsVisible = ( + start: number, + end: number, + delay: number, + currentTime: number, +) => { + const delayedStart = start / 1000 + delay; + const delayedEnd = end / 1000 + delay; + return ( + Math.max(0, delayedStart) <= currentTime && + Math.max(0, delayedEnd) >= currentTime + ); +}; + +export const CaptionRenderer = () => { + const isIdle = usePlayerStore((state) => state.interface.isIdle); + const selectedCaption = useCaptionsStore((state) => state.selectedCaption); + const delay = useCaptionsStore((state) => state.delay); + const status = usePlayerStore((state) => state.status); + + const translateY = useSharedValue(0); + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [{ translateY: translateY.value }], + }; + }); + + const transitionValue = useDerivedValue(() => { + return isIdle ? 50 : 0; + }, [isIdle]); + + useAnimatedReaction( + () => { + return transitionValue.value; + }, + (newValue) => { + translateY.value = withSpring(newValue); + }, + ); + + const visibleCaptions = useMemo( + () => + selectedCaption?.data.filter(({ start, end }) => + captionIsVisible( + start, + end, + delay, + status?.isLoaded + ? convertMilliSecondsToSeconds(status.positionMillis) + : 0, + ), + ), + [selectedCaption, delay, status], + ); + + console.log(visibleCaptions); + + if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length) + return null; + + return ( + // https://github.com/marklawlor/nativewind/issues/790 + + {visibleCaptions?.map((caption) => ( + + {caption.text} + + ))} + + ); +}; diff --git a/apps/expo/src/components/player/CaptionsSelector.tsx b/apps/expo/src/components/player/CaptionsSelector.tsx new file mode 100644 index 0000000..957da04 --- /dev/null +++ b/apps/expo/src/components/player/CaptionsSelector.tsx @@ -0,0 +1,84 @@ +import type { ContentCaption } from "subsrt-ts/dist/types/handler"; +import { useCallback } from "react"; +import { ScrollView, View } from "react-native"; +import Modal from "react-native-modal"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { parse } from "subsrt-ts"; + +import type { Stream } from "@movie-web/provider-utils"; +import colors from "@movie-web/tailwind-config/colors"; + +import { useBoolean } from "~/hooks/useBoolean"; +import { useCaptionsStore } from "~/stores/captions"; +import { usePlayerStore } from "~/stores/player/store"; +import { Button } from "../ui/Button"; +import { Text } from "../ui/Text"; + +const parseCaption = async ( + caption: Stream["captions"][0], +): Promise => { + const response = await fetch(caption.url); + const data = await response.text(); + return parse(data).filter( + (cue) => cue.type === "caption", + ) as ContentCaption[]; +}; + +export const CaptionsSelector = () => { + const captions = usePlayerStore((state) => state.interface.stream?.captions); + const setSelectedCaption = useCaptionsStore( + (state) => state.setSelectedCaption, + ); + const { isTrue, on, off } = useBoolean(); + + const downloadAndSetCaption = useCallback( + (caption: Stream["captions"][0]) => { + parseCaption(caption) + .then((data) => { + setSelectedCaption({ ...caption, data }); + }) + .catch(console.error); + }, + [setSelectedCaption], + ); + + if (!captions?.length) return null; + + return ( + + + + + + + + + {props.children} + + + + + ); +} diff --git a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx index 7068b95..2553474 100644 --- a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx +++ b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx @@ -1,71 +1,82 @@ -import { Pressable, ScrollView, View } from "react-native"; -import Modal from "react-native-modal"; +import { useState } from "react"; import { MaterialCommunityIcons } from "@expo/vector-icons"; - -import { defaultTheme } from "@movie-web/tailwind-config/themes"; +import { useTheme } from "tamagui"; import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed"; -import { useBoolean } from "~/hooks/useBoolean"; -import { Button } from "../ui/Button"; -import { Text } from "../ui/Text"; +import { MWButton } from "../ui/Button"; import { Controls } from "./Controls"; +import { Settings } from "./settings/Sheet"; export const PlaybackSpeedSelector = () => { + const theme = useTheme(); + const [open, setOpen] = useState(false); const { currentSpeed, changePlaybackSpeed } = usePlaybackSpeed(); - const { isTrue, on, off } = useBoolean(); const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; return ( - + <> -