This commit is contained in:
Adrian Castro
2024-04-13 19:56:26 +00:00
committed by GitHub
162 changed files with 25402 additions and 1149 deletions

40
.fleet/run.json Normal file
View File

@@ -0,0 +1,40 @@
{
"configurations": [
{
"type": "command",
"name": "Run iOS",
"program": "pnpm",
"args": [
"ios"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Run Android",
"program": "pnpm",
"args": [
"android"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Build IPA",
"program": "pnpm",
"args": [
"ipa"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Build APK",
"program": "pnpm",
"args": [
"apk"
],
"workingDir": "apps/expo/",
},
]
}

5
.fleet/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.formatOnSave": true,
"nodejs.editor.formatOnSave.prettier.mode": "Enabled",
"nodejs.editor.formatOnSave.eslint.mode": "Enabled"
}

View File

@@ -4,7 +4,7 @@
"packageRules": [
{
"matchPackagePatterns": ["^@movie-web/"],
"enabled": false
"enabled": true
}
],
"updateInternalDeps": true,

View File

@@ -0,0 +1,112 @@
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 }}
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build Android app
run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4
with:
name: apk
path: ./apps/expo/android/app/build/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 }}
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4
with:
name: ipa
path: ./apps/expo/ios/build/movie-web.ipa

View File

@@ -7,6 +7,7 @@ on:
permissions:
contents: write
pull-requests: write
jobs:
build-android:
@@ -16,40 +17,47 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
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
- name: Install Node.js
uses: actions/setup-node@v4
with:
java-version: '17'
distribution: 'temurin'
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Build Android app
run: cd apps/expo && pnpm run apk
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- 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: Build Android app
run: cd apps/expo && pnpm 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
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
runs-on: macos-14
@@ -58,35 +66,35 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- 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
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Build iOS app
run: cd apps/expo && pnpm run ipa
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- 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: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: write
@@ -19,12 +20,13 @@ jobs:
fetch-depth: 0
- name: Automated Version Bump
uses: phips28/gh-action-bump-version@v10.1.1
uses: phips28/gh-action-bump-version@v11.0.0
with:
skip-tag: 'true'
commit-message: 'chore: bump mobile version to {{version}} [skip ci]'
skip-tag: "true"
commit-message: "chore: bump mobile version to {{version}} [skip ci]"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGEJSON_DIR: "apps/expo"
build-android:
runs-on: ubuntu-latest
@@ -34,40 +36,50 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
- name: Pull version bump
run: git pull --all
- 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
- name: Install Node.js
uses: actions/setup-node@v4
with:
java-version: '17'
distribution: 'temurin'
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Build Android app
run: cd apps/expo && pnpm run apk
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- 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: Build Android app
run: cd apps/expo && pnpm 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
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
runs-on: macos-14
@@ -77,35 +89,38 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Xcode Select Version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.1.0'
- name: Pull version bump
run: git pull --all
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Build iOS app
run: cd apps/expo && pnpm run ipa
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- 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: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4
@@ -121,6 +136,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
@@ -129,6 +147,8 @@ jobs:
- name: Get package version
id: package-version
uses: martinbeentjes/npm-get-version-action@v1.3.1
with:
path: apps/expo
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
@@ -137,7 +157,37 @@ jobs:
files: |
movie-web.apk
movie-web.ipa
generate_release_notes: true
fail_on_unmatched_files: true
token: ${{ env.GITHUB_TOKEN }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
app-repo:
continue-on-error: true
runs-on: ubuntu-latest
needs: [build-ios, release-app]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ipa
- name: Update app-repo.json
run: |
VERSION=$(jq -r '.version' apps/expo/package.json)
DATE=$(date -u +"%Y-%m-%d")
IPA_SIZE=$(ls -l movie-web.ipa | awk '{print $5}')
NEW_ENTRY=$(jq -n --arg version "$VERSION" --arg date "$DATE" --arg size "$IPA_SIZE" --arg downloadURL "https://github.com/movie-web/native-app/releases/download/v$VERSION/movie-web.ipa" '{version: $version, date: $date, size: ($size | tonumber), downloadURL: $downloadURL}')
jq --argjson newEntry "$NEW_ENTRY" '.apps[0].versions |= [$newEntry] + .' apps/expo/app-repo.json > temp.json && mv temp.json apps/expo/app-repo.json
- uses: EndBug/add-and-commit@v9
with:
default_author: github_actions
message: "chore: update app-repo.json"

5
.gitignore vendored
View File

@@ -19,6 +19,8 @@ expo-env.d.ts
apps/expo/.gitignore
ios/
android/
!modules/*/ios/
!modules/*/android/
# production
build
@@ -45,3 +47,6 @@ yarn-error.log*
# turbo
.turbo
# tamagui
.tamagui

60
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,60 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Run iOS",
"request": "launch",
"runtimeArgs": [
"ios",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Run Android",
"request": "launch",
"runtimeArgs": [
"android",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Build IPA",
"request": "launch",
"runtimeArgs": [
"ipa",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Build APK",
"request": "launch",
"runtimeArgs": [
"apk",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
]
}

View File

@@ -21,5 +21,11 @@
"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"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

View File

@@ -1,8 +1,32 @@
# movie-web native-app
<!---
used a table bc this shit is annoying to resize to match, someone pls fix
--->
| iOS | Android |
|:--------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------------------------------------------:|
| <a href="https://tinyurl.com/axk7vadz"><img src="https://i.imgur.com/46qhEAv.png" width="230"></a> | <a href="https://github.com/movie-web/native-app/releases/latest/download/movie-web.apk"><img src="https://i.imgur.com/WwPPgSZ.png" width="200"></a> |
## iOS Installation
> [!IMPORTANT]
> Sideloading with a paid certificate breaks a few features, most notably:
> - Downloads
> - Alternate App Icons
>
> We reccomend you use a local development certificate if you care about any of these.
- **AltStore:**
- Click the Add to AltStore badge to add the movie-web repository to AltStore.
- **Other:**
- Employ [Sideloadly](https://sideloadly.io/) or a sideloading method of your preference to install
the [ipa](https://github.com/movie-web/native-app/releases/latest/download/movie-web.ipa) directly.
## About
It uses [Turborepo](https://turborepo.org) and contains:
This repository uses [Turborepo](https://turborepo.org) and contains:
```text
.github
@@ -15,56 +39,37 @@ apps
├─ Expo SDK 50
├─ React Native using React 18
├─ Navigation using Expo Router
Tailwind using Nativewind
└─ Typesafe API calls using tRPC
Styling with Tamagui
packages
├─ api
| └─ Typesafe API calls to the backend
├─ tmdb
└─ Typesafe API calls to The Movie Database
| └─ Typesafe API calls to The Movie Database
└─ provider-utils
└─ Typesafe API calls to the video providers
tooling
├─ color
| └─ shared color palette
├─ eslint
| └─ shared, fine-grained, eslint presets
├─ prettier
| └─ shared prettier configuration
├─ tailwind
| └─ shared tailwind configuration
└─ typescript
└─ shared tsconfig you can extend from
```
### Configure Expo `dev`-script
## Getting started
#### Use iOS Simulator
### When it's time to add a new package
1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator).
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).
> **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
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).
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package.
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary
configurations for tooling around your package such as formatting, linting and typechecking. When the package is
created, you're ready to go build out the package.
### References
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo) and [Turborepo](https://turborepo.org).
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo)
and [Turborepo](https://turborepo.org).

View File

@@ -0,0 +1 @@
tamagui-web.css

18
apps/expo/app-repo.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "movie-web",
"apps": [
{
"name": "movie-web",
"bundleIdentifier": "dev.movieweb.app",
"category": "entertainment",
"developerName": "movie-web",
"iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true",
"localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.",
"subtitle": "A small app for watching movies and shows easily",
"tintColor": "a87fd1",
"versions": [],
"appPermissions": {}
}
],
"news": []
}

View File

@@ -1,17 +1,19 @@
import type { ExpoConfig } from "expo/config";
import { version } from "./package.json";
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
const defineConfig = (): ExpoConfig => ({
name: "movie-web",
slug: "mw-mobile",
scheme: "dev.movieweb.app",
version: "0.1.0",
orientation: "portrait",
scheme: "movieweb",
version,
icon: "./assets/images/icon.png",
userInterfaceStyle: "automatic",
splash: {
image: "./assets/images/splash.png",
resizeMode: "contain",
backgroundColor: "#ffffff",
backgroundColor: "#000000",
},
updates: {
fallbackToCacheTimeout: 0,
@@ -20,28 +22,72 @@ const defineConfig = (): ExpoConfig => ({
ios: {
bundleIdentifier: "dev.movieweb.app",
supportsTablet: true,
requireFullScreen: true,
infoPlist: {
CFBundleName: "movie-web",
NSPhotoLibraryUsageDescription:
"This app saves videos to the photo library.",
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: true,
},
},
},
android: {
package: "dev.movieweb.app",
adaptiveIcon: {
foregroundImage: "./assets/images/adaptive-icon.png",
backgroundColor: "#FFFFFF",
},
permissions: ["WRITE_SETTINGS"],
},
web: {
favicon: "./assets/images/favicon.png",
bundler: "metro",
},
// extra: {
// eas: {
// projectId: "your-eas-project-id",
// },
// },
experiments: {
tsconfigPaths: true,
typedRoutes: true,
},
plugins: ["expo-router"],
plugins: [
"expo-router",
[withRemoveiOSNotificationEntitlement as unknown as string],
[
"expo-screen-orientation",
{
initialOrientation: "PORTRAIT_UP",
},
],
[
"expo-build-properties",
{
android: {
minSdkVersion: 24,
packagingOptions: {
pickFirst: [
"lib/x86/libcrypto.so",
"lib/x86_64/libcrypto.so",
"lib/armeabi-v7a/libcrypto.so",
"lib/arm64-v8a/libcrypto.so",
],
},
},
},
],
[
"expo-alternate-app-icons",
[
"./assets/images/main.png",
"./assets/images/blue.png",
"./assets/images/gray.png",
"./assets/images/red.png",
"./assets/images/teal.png",
],
],
[
"expo-media-library",
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
isAccessMediaLocationEnabled: true,
},
],
],
});
export default defineConfig;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -2,10 +2,20 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
presets: ["babel-preset-expo"],
plugins: [
"@babel/plugin-transform-class-static-block",
"react-native-reanimated/plugin",
[
"module-resolver",
{
alias: {
crypto: "react-native-quick-crypto",
stream: "stream-browserify",
buffer: "@craftzdog/react-native-buffer",
},
},
],
],
plugins: ["react-native-reanimated/plugin"],
};
};

3
apps/expo/index.js Normal file
View File

@@ -0,0 +1,3 @@
import "expo-router/entry";
import "react-native-gesture-handler";
import "@react-native-anywhere/polyfill-base64";

View File

@@ -1,19 +1,19 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withNativeWind } = require("nativewind/metro");
const { withTamagui } = require("@tamagui/metro-plugin");
const path = require("path");
module.exports = withTurborepoManagedCache(
withMonorepoPaths(
withNativeWind(
withTamagui(
getDefaultConfig(__dirname, {
isCSSEnabled: true,
}),
{
input: "./src/app/styles/global.css",
configPath: "./tailwind.config.ts",
components: ["tamagui"],
config: "./tamagui.config.ts",
},
),
),

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["CheckIosCertificateModule"]
}
}

View File

@@ -0,0 +1,11 @@
import CheckIosCertificateModule from "./src/CheckIosCertificateModule";
interface CheckIosCertificateModule {
isDevelopmentProvisioningProfile(): boolean;
}
export function isDevelopmentProvisioningProfile(): boolean {
return (
CheckIosCertificateModule as CheckIosCertificateModule
).isDevelopmentProvisioningProfile();
}

View File

@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'CheckIosCertificate'
s.version = '1.0.0'
s.summary = 'Check if iOS certificate is Development or Production.'
s.description = 'Check if iOS certificate is Development or Production.'
s.author = 'castdrian'
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,37 @@
import ExpoModulesCore
public class CheckIosCertificateModule: Module {
// Each module class must implement the definition function. The definition consists of components
// that describes the module's functionality and behavior.
// See https://docs.expo.dev/modules/module-api for more details about available components.
public func definition() -> ModuleDefinition {
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
// The module will be accessible from `requireNativeModule('CheckIosCertificate')` in JavaScript.
Name("CheckIosCertificate")
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
Function("isDevelopmentProvisioningProfile") { () -> Any in
#if targetEnvironment(simulator)
// Running on the Simulator
return true
#else
// Check for provisioning profile for non-Simulator execution
guard let filePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else {
return false
}
let fileURL = URL(fileURLWithPath: filePath)
do {
let data = try String(contentsOf: fileURL, encoding: .ascii)
let cleared = data.components(separatedBy: .whitespacesAndNewlines).joined()
return cleared.contains("<key>get-task-allow</key><true/>")
} catch {
// Handling error if the file read fails
print("Error reading provisioning profile: \(error)")
return false
}
#endif
}
}
}

View File

@@ -0,0 +1,10 @@
import { UnavailabilityError } from "expo-modules-core";
export default {
isDevelopmentProvisioningProfile: () => {
throw new UnavailabilityError(
"CheckIosCertificate",
"isDevelopmentProvisioningProfile",
);
},
};

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("CheckIosCertificate");

View File

@@ -1,8 +1,8 @@
{
"name": "@movie-web/mobile",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"main": "expo-router/entry",
"main": "index.js",
"scripts": {
"clean": "git clean -xdf .expo .turbo node_modules",
"dev": "expo start",
@@ -10,36 +10,72 @@
"dev:ios": "expo start -c --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease",
"ipa": "expo prebuild --platform=ios && cd ios && xcodebuild -workspace movieweb.xcworkspace -scheme movieweb -sdk iphoneos -configuration Release -derivedDataPath build -destination generic/platform=iOS CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO",
"ipa:sim": "expo prebuild --platform=ios && cd ios && xcodebuild -workspace movieweb.xcworkspace -scheme movieweb -sdk iphonesimulator -configuration Release -derivedDataPath build -destination \"generic/platform=iOS Simulator\" CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO",
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease && mv app/build/outputs/apk/release/app-release.apk app/build/movie-web.apk",
"ipa": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination generic/platform=iOS -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
"ipa:sim": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination \"generic/platform=iOS Simulator\" -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/metro-config": "^0.17.3",
"@movie-web/api": "*",
"@movie-web/colors": "*",
"@movie-web/provider-utils": "*",
"@movie-web/tmdb": "*",
"@octokit/rest": "^20.0.2",
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
"@react-navigation/native": "^6.1.9",
"@salihgun/react-native-video-processor": "^0.3.1",
"@tamagui/animations-moti": "^1.94.0",
"@tamagui/babel-plugin": "^1.94.0",
"@tamagui/config": "^1.94.0",
"@tamagui/metro-plugin": "^1.94.0",
"@tamagui/toast": "1.94.0",
"@tanstack/react-query": "^5.22.2",
"burnt": "^0.12.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"expo": "~50.0.5",
"expo": "~50.0.14",
"expo-alternate-app-icons": "^0.1.7",
"expo-application": "~5.8.3",
"expo-av": "~13.10.5",
"expo-brightness": "~11.8.0",
"expo-build-properties": "~0.11.1",
"expo-constants": "~15.4.5",
"expo-file-system": "~16.0.8",
"expo-haptics": "~12.8.1",
"expo-linear-gradient": "^12.7.2",
"expo-linking": "~6.2.2",
"expo-router": "~3.4.6",
"expo-media-library": "~15.9.1",
"expo-navigation-bar": "^2.8.1",
"expo-network": "~5.8.0",
"expo-router": "~3.4.8",
"expo-screen-orientation": "~6.4.1",
"expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1",
"expo-system-ui": "^2.9.3",
"expo-web-browser": "^12.8.2",
"nativewind": "~4.0.23",
"ffmpeg-kit-react-native": "^6.0.2",
"immer": "^10.0.3",
"iso-639-1": "^3.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.2",
"react-native-css-interop": "~0.0.22",
"react-native": "0.73.6",
"react-native-context-menu-view": "^1.14.1",
"react-native-gesture-handler": "~2.14.1",
"react-native-markdown-display": "^7.0.2",
"react-native-mmkv": "^2.12.2",
"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-svg": "14.1.0",
"react-native-web": "^0.19.10",
"tailwind-merge": "^2.2.1"
"subsrt-ts": "^2.1.2",
"tamagui": "^1.94.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.23.9",
@@ -47,14 +83,14 @@
"@babel/runtime": "^7.23.9",
"@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tailwind-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "workspace:^0.1.0",
"@tanstack/eslint-plugin-query": "^5.20.1",
"@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",
"typescript": "^5.3.3"
"typescript": "^5.4.3"
},
"eslintConfig": {
"root": true,

View File

@@ -0,0 +1,61 @@
import { useMemo } from "react";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { YStack } from "tamagui";
import { DownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings";
export default function Page() {
const { tmdbId } = useLocalSearchParams();
const allDownloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter();
const download = useMemo(() => {
return allDownloads.find((download) => download.media.tmdbId === tmdbId);
}, [allDownloads, tmdbId]);
const handlePress = (localPath?: string) => {
if (!localPath) return;
resetVideo();
setIsLocalFile(true);
setPlayerStatus(PlayerStatus.READY);
setVideoSrc({
uri: localPath,
});
router.push({
pathname: "/videoPlayer",
});
};
return (
<ScreenLayout showHeader={false}>
<Stack.Screen
options={{
title: download?.media.title ?? "Downloads",
}}
/>
<YStack gap="$4">
{download?.downloads.map((download) => {
return (
<DownloadItem
key={
download.media.type === "show"
? download.media.episode.tmdbId
: download.media.tmdbId
}
item={download}
onPress={() => handlePress(download.localPath)}
/>
);
})}
</YStack>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
import { BrandPill } from "~/components/BrandPill";
export default function Layout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerRight: BrandPill,
}}
/>
);
}

View File

@@ -1,24 +1,39 @@
import { View } from "react-native";
import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { Tabs } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useTheme, View } from "tamagui";
import Colors from "@movie-web/tailwind-config/colors";
import { MovieWebSvg } from "~/components/Icon";
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
import TabBarIcon from "~/components/TabBarIcon";
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs
sceneContainerStyle={{
backgroundColor: Colors.background,
backgroundColor: theme.screenBackground.val,
}}
screenListeners={() => ({
tabPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
void ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
},
})}
screenOptions={{
headerShown: false,
tabBarActiveTintColor: Colors.primary[100],
tabBarActiveTintColor: theme.tabBarIconFocused.val,
tabBarStyle: {
backgroundColor: Colors.secondary[700],
backgroundColor: theme.tabBarBackground.val,
borderTopColor: "transparent",
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
paddingBottom: Platform.select({ ios: 100 }),
height: 80,
},
tabBarItemStyle: {
@@ -42,11 +57,11 @@ export default function TabLayout() {
}}
/>
<Tabs.Screen
name="about"
name="downloads"
options={{
title: "About",
title: "Downloads",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="info-circle" focused={focused} />
<TabBarIcon name="download" focused={focused} />
),
}}
/>
@@ -55,13 +70,35 @@ export default function TabLayout() {
options={{
title: "Search",
tabBarLabel: "",
tabBarIcon: () => (
<View className="android:top-2 ios:top-2 flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary-400 text-center align-middle text-2xl text-white">
tabBarIcon: ({ focused }) => (
<View
top={2}
height={56}
width={56}
alignItems="center"
justifyContent="center"
overflow="hidden"
borderRadius={100}
backgroundColor={
focused ? theme.tabBarIconFocused : theme.tabBarIcon
}
>
<TabBarIcon name="search" color="#FFF" />
</View>
),
}}
/>
<Tabs.Screen
name="movie-web"
options={{
title: "movie-web",
tabBarIcon: ({ focused }) => (
<SvgTabBarIcon focused={focused}>
<MovieWebSvg />
</SvgTabBarIcon>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
@@ -71,15 +108,6 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="account"
options={{
title: "Account",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="user" focused={focused} />
),
}}
/>
</Tabs>
);
}

View File

@@ -1,16 +0,0 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function AboutScreen() {
return (
<ScreenLayout
title="About"
subtitle="What is movie-web and how content is served?"
>
<Text>
No content is served from movie-web directly and movie web does not host
anything.
</Text>
</ScreenLayout>
);
}

View File

@@ -1,13 +0,0 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function AccountScreen() {
return (
<ScreenLayout
title="Account"
subtitle="Manage your movie web account from here"
>
<Text>Hey Bro! what are you up to?</Text>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,165 @@
import React from "react";
import { Alert, Platform } from "react-native";
import { useFocusEffect, useRouter } from "expo-router";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
import { ScrollView, useTheme, YStack } from "tamagui";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import { DownloadItem, ShowDownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings";
const exampleMovieMedia: ScrapeMedia = {
type: "movie",
title: "Avengers: Endgame",
releaseYear: 2019,
imdbId: "tt4154796",
tmdbId: "299534",
};
const getExampleShowMedia = (seasonNumber: number, episodeNumber: number) =>
({
type: "show",
title: "Loki",
releaseYear: 2021,
imdbId: "tt9140554",
tmdbId: "84958",
season: {
number: seasonNumber,
tmdbId: seasonNumber.toString(),
},
episode: {
number: episodeNumber,
tmdbId: episodeNumber.toString(),
},
}) as const;
const TestDownloadButton = (props: {
media: ScrapeMedia;
type: "hls" | "mp4";
url: string;
}) => {
const { startDownload } = useDownloadManager();
const theme = useTheme();
return (
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={async () => {
await startDownload(props.url, props.type, props.media).catch(
console.error,
);
}}
>
test download
{props.type === "hls" ? " (hls)" : "(mp4)"}{" "}
{props.media.type === "show" ? "show" : "movie"}
</MWButton>
);
};
const DownloadsScreen: React.FC = () => {
const downloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter();
useFocusEffect(
React.useCallback(() => {
if (Platform.OS === "ios" && !isDevelopmentProvisioningProfile()) {
Alert.alert(
"Production Certificate",
"Download functionality is not available when the application is signed with a distribution certificate.",
[
{
text: "OK",
onPress: () => router.back(),
},
],
);
}
}, [router]),
);
const handlePress = (localPath?: string) => {
if (!localPath) return;
resetVideo();
setIsLocalFile(true);
setPlayerStatus(PlayerStatus.READY);
setVideoSrc({
uri: localPath,
});
router.push({
pathname: "/videoPlayer",
});
};
return (
<ScreenLayout>
<YStack gap={2} style={{ padding: 10 }}>
<TestDownloadButton
media={exampleMovieMedia}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 1)}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 2)}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 1)}
type="hls"
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
/>
</YStack>
<ScrollView
contentContainerStyle={{
gap: "$4",
}}
>
{downloads.map((download) => {
if (download.downloads.length === 0) return null;
if (download.media.type === "movie") {
return (
<DownloadItem
key={download.media.tmdbId}
item={download.downloads[0]!}
onPress={() => handlePress(download.downloads[0]!.localPath)}
/>
);
} else {
return (
<ShowDownloadItem
key={download.media.tmdbId}
download={download}
/>
);
}
})}
</ScrollView>
</ScreenLayout>
);
};
export default DownloadsScreen;

View File

@@ -1,10 +1,23 @@
import React from "react";
import { View } from "tamagui";
import { ItemListSection } from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
export default function HomeScreen() {
const { bookmarks } = useBookmarkStore();
const { watchHistory } = useWatchHistoryStore();
return (
<ScreenLayout title="Home" subtitle="This is where all magic happens">
<Text>Movies will be listed here</Text>
<View style={{ flex: 1 }} flex={1}>
<ScreenLayout>
<ItemListSection title="Bookmarks" items={bookmarks} />
<ItemListSection
title="Continue Watching"
items={watchHistory.map((x) => x.item)}
/>
</ScreenLayout>
</View>
);
}

View File

@@ -0,0 +1,5 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
export default function MovieWebScreen() {
return <ScreenLayout></ScreenLayout>;
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from "react";
import { Keyboard } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { View, ZStack } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar";
export default function SearchScreen() {
const [query, setQuery] = useState("");
const translateY = useSharedValue(0);
const fadeAnim = useSharedValue(1);
const searchResultsOpacity = useSharedValue(0);
const searchResultsScale = useSharedValue(0.95);
const [searchResultsLoaded, setSearchResultsLoaded] = useState(false);
const { data } = useQuery({
queryKey: ["searchResults", query],
queryFn: () => fetchSearchResults(query),
});
useEffect(() => {
if (data && data.length > 0 && query) {
searchResultsOpacity.value = withTiming(1, { duration: 500 });
searchResultsScale.value = withTiming(1, { duration: 500 });
setSearchResultsLoaded(true);
} else if (!query) {
searchResultsOpacity.value = withTiming(0, { duration: 500 });
searchResultsScale.value = withTiming(0.95, { duration: 500 });
setSearchResultsLoaded(false);
}
}, [data, query, searchResultsOpacity, searchResultsScale]);
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
"keyboardWillShow",
(e) => {
translateY.value = withTiming(
-(e.endCoordinates.height - 100), // determines the height of the Searchbar above keyboard, use Platform.select to adjust value if needed
{
duration: e.duration ?? 250, // duration always returns 0 on Android, adjust value if needed
easing: Easing.out(Easing.ease),
},
);
},
);
const keyboardWillHideListener = Keyboard.addListener(
"keyboardWillHide",
() => {
translateY.value = withTiming(0, {
duration: 250,
easing: Easing.out(Easing.ease),
});
},
);
return () => {
keyboardWillShowListener.remove();
keyboardWillHideListener.remove();
};
}, [translateY]);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
opacity: fadeAnim.value,
};
});
const searchResultsStyle = useAnimatedStyle(() => {
return {
opacity: searchResultsOpacity.value,
transform: [{ scale: searchResultsScale.value }],
};
});
const handleScrollBegin = () => {
fadeAnim.value = withTiming(0, {
duration: 100,
});
};
const handleScrollEnd = () => {
fadeAnim.value = withTiming(1, {
duration: 100,
});
};
return (
<ZStack flex={1}>
<ScreenLayout
onScrollBeginDrag={handleScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEnabled={searchResultsLoaded ? true : false}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ flexGrow: 1 }}
>
<View>
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
<View flexDirection="row" flexWrap="wrap">
{data?.map((item, index) => (
<View
key={index}
paddingHorizontal={12}
paddingBottom={12}
width="50%"
>
<Item data={item} />
</View>
))}
</View>
</Animated.View>
</View>
</ScreenLayout>
<Animated.View
style={[
{
position: "absolute",
bottom: 5,
left: 0,
right: 0,
},
animatedStyle,
]}
>
<SearchBar onSearchChange={setQuery} />
</Animated.View>
</ZStack>
);
}
async function fetchSearchResults(query: string): Promise<ItemData[]> {
const results = await searchTitle(query);
return results.map((result) => ({
id: result.id.toString(),
title: result.media_type === "tv" ? result.name : result.title,
posterUrl: getMediaPoster(result.poster_path),
release_date: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
),
year: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
).getFullYear(),
type: result.media_type,
}));
}

View File

@@ -1,49 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { useFocusEffect } from "expo-router";
import { FontAwesome5 } from "@expo/vector-icons";
import Colors from "@movie-web/tailwind-config/colors";
export default function Searchbar({
onSearchChange,
}: {
onSearchChange: (text: string) => void;
}) {
const [keyword, setKeyword] = useState("");
const inputRef = useRef<TextInput>(null);
useFocusEffect(
useCallback(() => {
// When the screen is focused
const focus = () => {
setTimeout(() => {
inputRef?.current?.focus();
}, 20);
};
focus();
return focus; // cleanup
}, []),
);
const handleChange = (text: string) => {
setKeyword(text);
onSearchChange(text);
};
return (
<View className="mb-6 mt-4 flex-row items-center rounded-full border border-primary-400 focus-within:border-primary-300">
<View className="ml-1 w-12 items-center justify-center">
<FontAwesome5 name="search" size={18} color={Colors.secondary[200]} />
</View>
<TextInput
value={keyword}
onChangeText={handleChange}
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"
/>
</View>
);
}

View File

@@ -1,59 +0,0 @@
import React, { useState } from "react";
import { ScrollView, View } from "react-native";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
import Searchbar from "./Searchbar";
export default function SearchScreen() {
const [searchResults, setSearchResults] = useState<ItemData[]>([]);
const handleSearchChange = async (query: string) => {
if (query.length > 0) {
const results = await fetchSearchResults(query).catch(() => []);
setSearchResults(results);
} else {
setSearchResults([]);
}
};
return (
<ScrollView>
<ScreenLayout
title={
<View className="flex-row items-center">
<Text className="text-2xl font-bold">Search</Text>
</View>
}
subtitle="Looking for something?"
>
<Searchbar onSearchChange={handleSearchChange} />
<View className="flex w-full flex-1 flex-row flex-wrap justify-start">
{searchResults.map((item, index) => (
<View key={index} className="basis-1/2 px-3 pb-3">
<Item data={item} />
</View>
))}
</View>
</ScreenLayout>
</ScrollView>
);
}
async function fetchSearchResults(query: string): Promise<ItemData[]> {
const results = await searchTitle(query);
return results.map((result) => ({
id: result.id.toString(),
title: result.media_type === "tv" ? result.name : result.title,
posterUrl: getMediaPoster(result.poster_path),
year: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
).getFullYear(),
type: result.media_type,
}));
}

View File

@@ -1,10 +1,525 @@
import type { SelectProps } from "tamagui";
import React, { useState } from "react";
import { Platform } from "react-native";
import Markdown from "react-native-markdown-display";
import * as Application from "expo-application";
import * as Brightness from "expo-brightness";
import * as FileSystem from "expo-file-system";
import * as WebBrowser from "expo-web-browser";
import {
FontAwesome,
MaterialCommunityIcons,
MaterialIcons,
} from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import {
Adapt,
ScrollView,
Select,
Sheet,
Spinner,
Text,
useTheme,
View,
XStack,
YStack,
} from "tamagui";
import type { ThemeStoreOption } from "~/stores/theme";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
import { MWButton } from "~/components/ui/Button";
import { MWSelect } from "~/components/ui/Select";
import { MWSeparator } from "~/components/ui/Separator";
import { MWSwitch } from "~/components/ui/Switch";
import { useToast } from "~/hooks/useToast";
import { checkForUpdate } from "~/lib/update";
import {
useNetworkSettingsStore,
usePlayerSettingsStore,
} from "~/stores/settings";
import { useThemeStore } from "~/stores/theme";
const themeOptions: ThemeStoreOption[] = [
"main",
"blue",
"gray",
"red",
"teal",
];
const defaultQualityOptions = ["Highest", "Lowest"];
export default function SettingsScreen() {
const theme = useTheme();
const { gestureControls, setGestureControls, autoPlay, setAutoPlay } =
usePlayerSettingsStore();
const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore();
const [showUpdateSheet, setShowUpdateSheet] = useState(false);
const [updateMarkdownContent, setUpdateMarkdownContent] = useState("");
const [downloadUrl, setDownloadUrl] = useState("");
const { showToast } = useToast();
const mutation = useMutation({
mutationKey: ["checkForUpdate"],
mutationFn: checkForUpdate,
onSuccess: (res) => {
if (res) {
setUpdateMarkdownContent(res.data.body!);
setDownloadUrl(
res.data.assets.find(
(asset) =>
asset.name ===
`movie-web.${Platform.select({ ios: "ipa", android: "apk" })}`,
)?.browser_download_url ?? "",
);
setShowUpdateSheet(true);
} else {
showToast("No updates available");
}
},
});
const handleGestureControlsToggle = async (isEnabled: boolean) => {
if (isEnabled) {
const { status } = await Brightness.requestPermissionsAsync();
if (status === Brightness.PermissionStatus.GRANTED) {
setGestureControls(isEnabled);
}
} else {
setGestureControls(isEnabled);
}
};
const clearCacheDirectory = async () => {
const cacheDirectory = `${FileSystem.cacheDirectory}movie-web`;
if (!cacheDirectory) return;
try {
await FileSystem.deleteAsync(cacheDirectory, { idempotent: true });
showToast("Cache cleared", {
burntOptions: { preset: "done" },
});
} catch (error) {
console.error("Error clearing cache directory:", error);
showToast("Error clearing cache", {
burntOptions: { preset: "error" },
});
}
};
return (
<ScreenLayout title="Settings" subtitle="Need to change something?">
<Text>Settings would be listed in here. Coming soon</Text>
<ScreenLayout>
<View>
<YStack gap="$8">
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Appearance
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Theme
</Text>
<ThemeSelector />
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Player
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Gesture controls
</Text>
<MWSwitch
checked={gestureControls}
onCheckedChange={handleGestureControlsToggle}
>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Autoplay
</Text>
<MWSwitch checked={autoPlay} onCheckedChange={setAutoPlay}>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Network
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Default quality (Wi-Fi)
</Text>
<DefaultQualitySelector qualityType="wifi" />
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Default quality (Data)
</Text>
<DefaultQualitySelector qualityType="data" />
</XStack>
<XStack gap="$3" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Allow downloads on mobile data
</Text>
<MWSwitch
checked={allowMobileData}
onCheckedChange={setAllowMobileData}
>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
App
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Version {Application.nativeApplicationVersion}
</Text>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name={Platform.select({
ios: "apple",
android: "android",
})}
size={24}
color={theme.buttonSecondaryText.val}
/>
}
iconAfter={
<>{mutation.isPending && <Spinner color="$purple200" />}</>
}
disabled={mutation.isPending}
onPress={() => mutation.mutate()}
>
Update
</MWButton>
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Storage
</Text>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="broom"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => clearCacheDirectory()}
>
Clear Cache
</MWButton>
</XStack>
</YStack>
</YStack>
</YStack>
</View>
<UpdateSheet
markdownContent={updateMarkdownContent}
open={showUpdateSheet}
setShowUpdateSheet={setShowUpdateSheet}
downloadUrl={downloadUrl}
/>
</ScreenLayout>
);
}
export function UpdateSheet({
markdownContent,
open,
setShowUpdateSheet,
downloadUrl,
}: {
markdownContent: string;
open: boolean;
setShowUpdateSheet: (value: boolean) => void;
downloadUrl: string;
}) {
const theme = useTheme();
return (
<Sheet
modal
open={open}
onOpenChange={setShowUpdateSheet}
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<ScrollView>
<Markdown
style={{
text: {
color: "white",
},
}}
>
{markdownContent}
</Markdown>
</ScrollView>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name={Platform.select({ ios: "apple", android: "android" })}
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
>
Download
</MWButton>
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
);
}
export function ThemeSelector(props: SelectProps) {
const theme = useTheme();
const themeStore = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
return (
<MWSelect
value={themeStore}
onValueChange={setTheme}
disablePreventBodyScroll
{...props}
>
<MWSelect.Trigger
maxWidth="$12"
iconAfter={
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
}
>
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
</MWSelect.Trigger>
<Adapt platform="native">
<Sheet
modal
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Select.Content>
<Select.Viewport
animation="static"
animateOnly={["transform", "opacity"]}
enterStyle={{ o: 0, y: -10 }}
exitStyle={{ o: 0, y: 10 }}
>
{themeOptions.map((item, i) => (
<Select.Item
index={i}
key={item}
value={item}
backgroundColor="$sheetItemBackground"
borderTopRightRadius={i === 0 ? "$8" : 0}
borderTopLeftRadius={i === 0 ? "$8" : 0}
borderBottomRightRadius={i === themeOptions.length - 1 ? "$8" : 0}
borderBottomLeftRadius={i === themeOptions.length - 1 ? "$8" : 0}
>
<Select.ItemText
textTransform="capitalize"
fontWeight="$semibold"
>
{item}
</Select.ItemText>
<Select.ItemIndicator ml="auto">
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</MWSelect>
);
}
interface DefaultQualitySelectorProps extends SelectProps {
qualityType: "wifi" | "data";
}
export function DefaultQualitySelector(props: DefaultQualitySelectorProps) {
const theme = useTheme();
const {
wifiDefaultQuality,
mobileDataDefaultQuality,
setWifiDefaultQuality,
setMobileDataDefaultQuality,
} = useNetworkSettingsStore();
return (
<MWSelect
value={
props.qualityType === "wifi"
? wifiDefaultQuality
: mobileDataDefaultQuality
}
onValueChange={
props.qualityType === "wifi"
? setWifiDefaultQuality
: setMobileDataDefaultQuality
}
disablePreventBodyScroll
{...props}
>
<MWSelect.Trigger
maxWidth="$10"
iconAfter={
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
}
>
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
</MWSelect.Trigger>
<Adapt platform="native">
<Sheet
modal
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Select.Content>
<Select.Viewport
animation="static"
animateOnly={["transform", "opacity"]}
enterStyle={{ o: 0, y: -10 }}
exitStyle={{ o: 0, y: 10 }}
>
{defaultQualityOptions.map((item, i) => (
<Select.Item
index={i}
key={item}
value={item}
backgroundColor="$sheetItemBackground"
borderTopRightRadius={i === 0 ? "$8" : 0}
borderTopLeftRadius={i === 0 ? "$8" : 0}
borderBottomRightRadius={
i === defaultQualityOptions.length - 1 ? "$8" : 0
}
borderBottomLeftRadius={
i === defaultQualityOptions.length - 1 ? "$8" : 0
}
>
<Select.ItemText
textTransform="capitalize"
fontWeight="$semibold"
>
{item}
</Select.ItemText>
<Select.ItemIndicator ml="auto">
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</MWSelect>
);
}

View File

@@ -1,19 +1,23 @@
import { View } from "react-native";
import * as Linking from "expo-linking";
import { Link, Stack } from "expo-router";
import { Text } from "~/components/ui/Text";
import { Text, View } from "tamagui";
export default function NotFoundScreen() {
if (Linking.useURL()) return null;
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View className="flex-1 items-center justify-center p-5">
<Text className="text-lg font-bold">
This screen doesn&apos;t exist.
</Text>
<View flex={1} alignItems="center" justifyContent="center" padding={5}>
<Text fontWeight="bold">This screen doesn&apos;t exist.</Text>
<Link href="/" className="mt-4 py-4">
<Text className="text-sm text-sky-500">Go to home screen!</Text>
<Link
href="/"
style={{
marginTop: 16,
paddingVertical: 16,
}}
>
<Text color="skyblue">Go to home screen!</Text>
</Link>
</View>
</>

View File

@@ -1,18 +1,18 @@
/* 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";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { ToastProvider, ToastViewport } from "@tamagui/toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TamaguiProvider, Theme, useTheme } from "tamagui";
import tamaguiConfig from "tamagui.config";
import Colors from "@movie-web/tailwind-config/colors";
import "./styles/global.css";
import { useThemeStore } from "~/stores/theme";
// @ts-expect-error - Without named import it causes an infinite loop
import _styles from "../../tamagui-web.css";
export {
// Catch any errors thrown by the Layout component.
@@ -29,6 +29,8 @@ SplashScreen.preventAutoHideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */
});
const queryClient = new QueryClient();
export default function RootLayout() {
const [loaded, error] = useFonts({
OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"),
@@ -57,25 +59,60 @@ export default function RootLayout() {
return null;
}
return <RootLayoutNav />;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RootLayoutNav />
</GestureHandlerRootView>
);
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
function ScreenStacks() {
const theme = useTheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack
screenOptions={{
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: Colors.background,
backgroundColor: theme.screenBackground.val,
},
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
}}
/>
</Stack>
</ThemeProvider>
);
}
function RootLayoutNav() {
const themeStore = useThemeStore((s) => s.theme);
return (
<QueryClientProvider client={queryClient}>
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
<ToastProvider>
<ThemeProvider value={DarkTheme}>
<Theme name={themeStore}>
<ScreenStacks />
</Theme>
</ThemeProvider>
<ToastViewport />
</ToastProvider>
</TamaguiProvider>
</QueryClientProvider>
);
}

View File

@@ -1,36 +0,0 @@
import { Image, View } from "react-native";
import { Text } from "~/components/ui/Text";
export interface ItemData {
id: string;
title: string;
type: "movie" | "tv";
year: number;
posterUrl: string;
}
export default function Item({ data }: { data: ItemData }) {
const { title, type, year, posterUrl } = data;
return (
<View className="w-full">
<View className="mb-2 aspect-[9/14] w-full overflow-hidden rounded-2xl">
<Image
source={{
uri: posterUrl,
}}
className="h-full w-full"
/>
</View>
<Text className="font-bold">{title}</Text>
<View className="flex-row items-center gap-3">
<Text className="text-xs text-gray-600">
{type === "tv" ? "Show" : "Movie"}
</Text>
<View className="h-1 w-1 rounded-3xl bg-gray-600" />
<Text className="text-sm text-gray-600">{year}</Text>
</View>
</View>
);
}

View File

@@ -1,22 +0,0 @@
import { View } from "react-native";
import { Text } from "~/components/ui/Text";
interface Props {
title?: React.ReactNode | string;
subtitle?: string;
children?: React.ReactNode;
}
export default function ScreenLayout({ title, subtitle, children }: Props) {
return (
<View className="bg-shade-900 flex-1 p-12">
{typeof title === "string" && (
<Text className="text-2xl font-bold">{title}</Text>
)}
{typeof title !== "string" && title}
<Text className="mt-1 text-sm font-bold">{subtitle}</Text>
<View className="py-3">{children}</View>
</View>
);
}

View File

@@ -1,18 +0,0 @@
import type { TextProps } from "react-native";
import { Text as RNText } from "react-native";
import { cva } from "class-variance-authority";
import { cn } from "~/app/lib/utils";
const textVariants = cva("text-white");
export function Text({ className, ...props }: TextProps) {
return (
<RNText
className={cn(className, textVariants(), {
"font-sans": !className?.includes("font-"),
})}
{...props}
/>
);
}

View File

@@ -1,7 +0,0 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,45 @@
import { useLocalSearchParams } from "expo-router";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import type { ItemData } from "~/components/item/item";
import { ScraperProcess } from "~/components/player/ScraperProcess";
import { VideoPlayer } from "~/components/player/VideoPlayer";
import { usePlayer } from "~/hooks/player/usePlayer";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
export default function VideoPlayerWrapper() {
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
const { presentFullscreenPlayer } = usePlayer();
const params = useLocalSearchParams();
let data;
if ("data" in params) {
if (typeof params.data === "string") {
data = JSON.parse(params.data) as Partial<ItemData>;
} else {
data = undefined;
}
} else {
data = params as Partial<ItemData>;
}
const media = params.media
? (JSON.parse(params.media as string) as ScrapeMedia)
: undefined;
const download = params.download === "true";
void presentFullscreenPlayer();
if (download) {
return <ScraperProcess data={data} download />;
}
if (playerStatus === PlayerStatus.SCRAPING) {
return <ScraperProcess data={data} media={media} />;
}
if (playerStatus === PlayerStatus.READY) {
return <VideoPlayer />;
}
}

View File

@@ -0,0 +1,35 @@
import * as Haptics from "expo-haptics";
import { Text, useTheme, View } from "tamagui";
import { MovieWebSvg } from "./Icon";
export function BrandPill() {
const theme = useTheme();
return (
<View
flexDirection="row"
alignItems="center"
justifyContent="center"
paddingHorizontal="$3"
paddingVertical="$2.5"
gap="$2.5"
opacity={0.8}
backgroundColor="$pillBackground"
borderRadius={24}
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onLongPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)}
>
<MovieWebSvg
fillColor={theme.tabBarIconFocused.val}
width={20}
height={20}
/>
<Text fontSize="$6" fontWeight="$bold">
movie-web
</Text>
</View>
);
}

View File

@@ -0,0 +1,201 @@
import type { NativeSyntheticEvent } from "react-native";
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
import React from "react";
import ContextMenu from "react-native-context-menu-view";
import { TouchableOpacity } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { Image, Text, View, XStack, YStack } from "tamagui";
import type { Download, DownloadContent } from "~/hooks/useDownloadManager";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
import { MWProgress } from "./ui/Progress";
import { FlashingText } from "./ui/Text";
export interface DownloadItemProps {
item: Download;
onPress: (localPath?: string) => void;
}
enum ContextMenuActions {
Cancel = "Cancel",
Remove = "Remove",
}
const statusToTextMap: Record<Download["status"], string> = {
downloading: "Downloading",
finished: "Finished",
error: "Error",
merging: "Merging",
cancelled: "Cancelled",
importing: "Importing",
};
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
export function DownloadItem(props: DownloadItemProps) {
const percentage = props.item.progress * 100;
const formattedFileSize = formatBytes(props.item.fileSize);
const formattedDownloaded = formatBytes(props.item.downloaded);
const { removeDownload, cancelDownload } = useDownloadManager();
const contextMenuActions = [
{
title: ContextMenuActions.Remove,
},
...(props.item.status !== "finished"
? [{ title: ContextMenuActions.Cancel }]
: []),
];
const onContextMenuPress = (
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
) => {
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
void cancelDownload(props.item);
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
removeDownload(props.item);
}
};
const isInProgress = !(
props.item.status === "finished" ||
props.item.status === "error" ||
props.item.status === "cancelled"
);
return (
<ContextMenu
actions={contextMenuActions}
onPress={onContextMenuPress}
previewBackgroundColor="transparent"
>
<TouchableOpacity
onPress={() => props.onPress(props.item.localPath)}
onLongPress={() => {
return;
}}
activeOpacity={0.7}
>
<XStack gap="$4" alignItems="center">
<View
aspectRatio={9 / 14}
width={70}
maxHeight={180}
overflow="hidden"
borderRadius="$2"
>
<Image
source={{
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
}}
width="100%"
height="100%"
/>
</View>
<YStack gap="$2" flex={1}>
<XStack justifyContent="space-between" alignItems="center">
<Text
fontWeight="$bold"
numberOfLines={1}
ellipsizeMode="tail"
flex={1}
>
{props.item.media.type === "show" &&
`${mapSeasonAndEpisodeNumberToText(
props.item.media.season.number,
props.item.media.episode.number,
)} `}
{props.item.media.title}
</Text>
{props.item.type !== "hls" && (
<Text fontSize="$2" color="gray">
{props.item.speed.toFixed(2)} MB/s
</Text>
)}
</XStack>
<MWProgress value={percentage} height={10} maxWidth="100%">
<MWProgress.Indicator />
</MWProgress>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="gray">
{props.item.type === "hls"
? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<FlashingText
isInProgress={isInProgress}
style={{
fontSize: 12,
color: "gray",
}}
>
{statusToTextMap[props.item.status]}
</FlashingText>
</View>
</XStack>
</YStack>
</XStack>
</TouchableOpacity>
</ContextMenu>
);
}
export function ShowDownloadItem({ download }: { download: DownloadContent }) {
const router = useRouter();
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(downloads)/[tmdbId]",
params: { tmdbId: download.media.tmdbId },
})
}
activeOpacity={0.7}
>
<XStack gap="$4" alignItems="center">
<View
aspectRatio={9 / 14}
width={70}
maxHeight={180}
overflow="hidden"
borderRadius="$2"
>
<Image
source={{
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
}}
width="100%"
height="100%"
/>
</View>
<YStack gap="$2">
<YStack gap="$1">
<Text fontWeight="$bold" ellipse flexGrow={1} fontSize="$5">
{download.media.title}
</Text>
<Text fontSize="$2">
{download.downloads.length} Episode
{download.downloads.length > 1 ? "s" : ""} |{" "}
{formatBytes(
download.downloads.reduce(
(acc, curr) => acc + curr.fileSize,
0,
),
)}
</Text>
</YStack>
</YStack>
</XStack>
</TouchableOpacity>
);
}

View File

@@ -0,0 +1,15 @@
import { Image } from "tamagui";
// TODO: Improve flag icons. This is incomplete.
export function FlagIcon({ languageCode }: { languageCode: string }) {
return (
<Image
source={{
uri: `https://flagcdn.com/w80/${languageCode.toLowerCase()}.png`,
}}
width="100%"
height="100%"
resizeMode="contain"
/>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import Svg, { G, Path } from "react-native-svg";
export const MovieWebSvg = ({
fillColor,
height = 24,
width = 24,
}: {
fillColor?: string;
height?: number;
width?: number;
}) => {
const svgPath =
"M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z";
return (
<Svg
width={width}
height={height}
viewBox="0 0 20.927 20.927"
fill={fillColor}
>
<G transform="translate(10.018 -7.425) rotate(45)">
<Path d={svgPath} />
</G>
</Svg>
);
};

View File

@@ -0,0 +1,23 @@
import React from "react";
import { useTheme } from "tamagui";
interface SvgTabBarIconProps {
focused?: boolean;
children: React.ReactElement;
}
export default function SvgTabBarIcon({
focused,
children,
}: SvgTabBarIconProps) {
const theme = useTheme();
const fillColor = focused
? theme.tabBarIconFocused.val
: theme.tabBarIcon.val;
if (React.isValidElement(children)) {
return React.cloneElement(children, { fillColor } as React.Attributes);
}
return null;
}

View File

@@ -1,17 +1,12 @@
import { FontAwesome } from "@expo/vector-icons";
import Colors from "@movie-web/tailwind-config/colors";
import { useTheme } from "tamagui";
type Props = {
focused?: boolean;
} & React.ComponentProps<typeof FontAwesome>;
export default function TabBarIcon({ focused, ...rest }: Props) {
return (
<FontAwesome
color={focused ? Colors.primary[300] : Colors.secondary[300]}
size={24}
{...rest}
/>
);
const theme = useTheme();
const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
return <FontAwesome color={color} size={24} {...rest} />;
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { Dimensions } from "react-native";
import { ScrollView, Text, View } from "tamagui";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
const padding = 20;
const screenWidth = Dimensions.get("window").width;
const itemWidth = screenWidth / 2.3 - padding;
export const ItemListSection = ({
title,
items,
}: {
title: string;
items: ItemData[];
}) => {
return (
<View>
<Text marginBottom={8} marginTop={16} fontWeight="bold" fontSize="$8">
{title}
</Text>
<ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
{items.map((item, index) => (
<View key={index} width={itemWidth} paddingBottom={padding}>
<Item data={item} />
</View>
))}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,152 @@
import type { NativeSyntheticEvent } from "react-native";
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
import { useCallback } from "react";
import { Keyboard, TouchableOpacity } from "react-native";
import ContextMenu from "react-native-context-menu-view";
import { useRouter } from "expo-router";
import { Image, Text, View } from "tamagui";
import { useToast } from "~/hooks/useToast";
import { usePlayerStore } from "~/stores/player/store";
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
export interface ItemData {
id: string;
title: string;
type: "movie" | "tv";
season?: number;
episode?: number;
year: number;
release_date?: Date;
posterUrl: string;
}
enum ContextMenuActions {
Bookmark = "Bookmark",
RemoveBookmark = "Remove Bookmark",
Download = "Download",
RemoveWatchHistoryItem = "Remove from Continue Watching",
}
function checkReleased(media: ItemData): boolean {
const isReleasedYear = Boolean(
media.year && media.year <= new Date().getFullYear(),
);
const isReleasedDate = Boolean(
media.release_date && media.release_date <= new Date(),
);
// If the media has a release date, use that, otherwise use the year
const isReleased = media.release_date ? isReleasedDate : isReleasedYear;
return isReleased;
}
export default function Item({ data }: { data: ItemData }) {
const resetVideo = usePlayerStore((state) => state.resetVideo);
const router = useRouter();
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
const { hasWatchHistoryItem, removeFromWatchHistory } =
useWatchHistoryStore();
const { showToast } = useToast();
const { title, type, year, posterUrl } = data;
const isReleased = useCallback(() => checkReleased(data), [data]);
const handlePress = () => {
if (!isReleased()) {
showToast("This media is not released yet", {
burntOptions: { preset: "error" },
});
return;
}
resetVideo();
Keyboard.dismiss();
router.push({
pathname: "/videoPlayer",
params: { data: JSON.stringify(data) },
});
};
const contextMenuActions = [
{
title: isBookmarked(data)
? ContextMenuActions.RemoveBookmark
: ContextMenuActions.Bookmark,
},
...(type === "movie" ? [{ title: ContextMenuActions.Download }] : []),
...(hasWatchHistoryItem(data)
? [{ title: ContextMenuActions.RemoveWatchHistoryItem }]
: []),
];
const onContextMenuPress = (
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
) => {
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
addBookmark(data);
showToast("Added to bookmarks", {
burntOptions: { preset: "done" },
});
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
removeBookmark(data);
showToast("Removed from bookmarks", {
burntOptions: { preset: "done" },
});
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
router.push({
pathname: "/videoPlayer",
params: { data: JSON.stringify(data), download: "true" },
});
} else if (
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
) {
removeFromWatchHistory(data);
showToast("Removed from Continue Watching", {
burntOptions: { preset: "done" },
});
}
};
return (
<TouchableOpacity
onPress={handlePress}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onLongPress={() => {}}
style={{ width: "100%" }}
>
<View width="100%">
<ContextMenu actions={contextMenuActions} onPress={onContextMenuPress}>
<View
marginBottom={4}
aspectRatio={9 / 14}
width="100%"
overflow="hidden"
borderRadius={24}
height="$14"
>
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
</View>
</ContextMenu>
<Text fontWeight="bold" fontSize={14}>
{title}
</Text>
<View flexDirection="row" alignItems="center" gap={3}>
<Text fontSize={12} color="$ash100">
{type === "tv" ? "Show" : "Movie"}
</Text>
<View
height={6}
width={6}
borderRadius={24}
backgroundColor="$ash100"
/>
<Text fontSize={12} color="$ash100">
{isReleased() ? year : "Unreleased"}
</Text>
</View>
</View>
</TouchableOpacity>
);
}

View File

@@ -0,0 +1,48 @@
import { Linking } from "react-native";
import * as Haptics from "expo-haptics";
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
import { Circle, View } from "tamagui";
import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
import { BrandPill } from "../BrandPill";
export function Header() {
return (
<View alignItems="center" gap="$3" flexDirection="row">
<BrandPill />
<Circle
backgroundColor="$pillBackground"
size="$3.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(DISCORD_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<MaterialIcons name="discord" size={28} color="white" />
</Circle>
<Circle
backgroundColor="$pillBackground"
size="$3.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(GITHUB_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<FontAwesome6 name="github" size={28} color="white" />
</Circle>
</View>
);
}

View File

@@ -0,0 +1,64 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollView } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import { Header } from "./Header";
interface Props {
children?: React.ReactNode;
onScrollBeginDrag?: () => void;
onMomentumScrollEnd?: () => void;
showHeader?: boolean;
scrollEnabled?: boolean;
keyboardDismissMode?: "none" | "on-drag" | "interactive";
keyboardShouldPersistTaps?: "always" | "never" | "handled";
contentContainerStyle?: Record<string, unknown>;
}
export default function ScreenLayout({
children,
onScrollBeginDrag,
onMomentumScrollEnd,
showHeader = true,
scrollEnabled,
keyboardDismissMode,
keyboardShouldPersistTaps,
contentContainerStyle,
}: Props) {
const insets = useSafeAreaInsets();
return (
<LinearGradient
flex={1}
paddingVertical="$4"
paddingHorizontal="$4"
colors={[
"$shade900",
"$purple900",
"$purple800",
"$shade700",
"$shade900",
]}
locations={[0.02, 0.15, 0.2, 0.4, 0.8]}
start={[0, 0]}
end={[1, 1]}
flexGrow={1}
paddingTop={showHeader ? insets.top + 16 : insets.top + 50}
>
{showHeader && <Header />}
<ScrollView
onScrollBeginDrag={onScrollBeginDrag}
onMomentumScrollEnd={onMomentumScrollEnd}
scrollEnabled={scrollEnabled}
keyboardDismissMode={keyboardDismissMode}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
contentContainerStyle={contentContainerStyle}
marginTop="$4"
flexGrow={1}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</LinearGradient>
);
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
export interface AudioTrack {
uri: string;
name: string;
language: string;
active?: boolean;
}
export const AudioTrackSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const tracks = usePlayerStore((state) => state.interface.audioTracks);
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const stream = usePlayerStore((state) => state.interface.currentStream);
const selectedTrack = useAudioTrackStore((state) => state.selectedTrack);
const setSelectedAudioTrack = useAudioTrackStore(
(state) => state.setSelectedAudioTrack,
);
const { synchronizePlayback } = useAudioTrack();
useEffect(() => {
if (tracks && selectedTrack) {
const needsUpdate = tracks.some(
(t) => t.active !== (t.uri === selectedTrack.uri),
);
if (needsUpdate) {
const updatedTracks = tracks.map((t) => ({
...t,
active: t.uri === selectedTrack.uri,
}));
setAudioTracks(updatedTracks);
}
}
}, [selectedTrack, setAudioTracks, tracks]);
if (!tracks?.length) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="volume-high"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}
>
Audio
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Audio"
/>
<Settings.Content>
{tracks?.map((track) => (
<Settings.Item
key={track.language}
title={track.name}
iconRight={
track.active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
)
}
onPress={() => {
setSelectedAudioTrack(track);
if (stream) {
void synchronizePlayback(track, stream);
}
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,44 @@
import { Keyboard } from "react-native";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { usePlayer } from "~/hooks/player/usePlayer";
export const BackButton = () => {
const { dismissFullscreenPlayer } = usePlayer();
const router = useRouter();
return (
<Ionicons
name="arrow-back"
onPress={() => {
dismissFullscreenPlayer()
.then(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/");
}
return setTimeout(() => {
Keyboard.dismiss();
}, 100);
})
.catch(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/");
}
return setTimeout(() => {
Keyboard.dismiss();
}, 100);
});
}}
size={36}
color="white"
style={{
width: 100,
}}
/>
);
};

View File

@@ -0,0 +1,95 @@
import { useCallback, useMemo, useState } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { CaptionsSelector } from "./CaptionsSelector";
import { Controls } from "./Controls";
import { DownloadButton } from "./DownloadButton";
import { ProgressBar } from "./ProgressBar";
import { SeasonSelector } from "./SeasonEpisodeSelector";
import { SettingsSelector } from "./SettingsSelector";
import { SourceSelector } from "./SourceSelector";
import { mapMillisecondsToTime } from "./utils";
export const BottomControls = () => {
const status = usePlayerStore((state) => state.status);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
const [showRemaining, setShowRemaining] = useState(false);
const toggleTimeDisplay = useCallback(() => {
setIsIdle(false);
setShowRemaining(!showRemaining);
}, [showRemaining, setIsIdle]);
const { currentTime, remainingTime } = useMemo(() => {
if (status?.isLoaded) {
const current = mapMillisecondsToTime(status.positionMillis ?? 0);
const remaining = `-${mapMillisecondsToTime(
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
)}`;
return { currentTime: current, remainingTime: remaining };
} else {
return { currentTime: "", remainingTime: "" };
}
}, [status]);
const durationTime = useMemo(() => {
if (status?.isLoaded) {
return mapMillisecondsToTime(status.durationMillis ?? 0);
}
}, [status]);
if (status?.isLoaded) {
return (
<View
height={128}
width="100%"
flexDirection="column"
alignItems="center"
justifyContent="center"
padding={24}
>
<Controls>
<View flexDirection="row" justifyContent="space-between" width="$11">
<Text fontWeight="bold">{currentTime}</Text>
<Text marginHorizontal={1} fontWeight="bold">
/
</Text>
<TouchableOpacity onPress={toggleTimeDisplay}>
<Text fontWeight="bold">
{showRemaining ? remainingTime : durationTime}
</Text>
</TouchableOpacity>
</View>
<ProgressBar />
</Controls>
<View
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={4}
paddingBottom={40}
>
{!isLocalFile && (
<>
<SeasonSelector />
<CaptionsSelector />
<SourceSelector />
<AudioTrackSelector />
<SettingsSelector />
{Platform.OS === "android" ||
(Platform.OS === "ios" && isDevelopmentProvisioningProfile()) ? (
<DownloadButton />
) : null}
</>
)}
</View>
</View>
);
}
};

View File

@@ -0,0 +1,95 @@
import { useMemo } from "react";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Text, View } from "tamagui";
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],
);
if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length)
return null;
return (
<Animated.View
style={[
{
position: "absolute",
bottom: 95,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.6)",
paddingHorizontal: 16,
paddingVertical: 8,
},
animatedStyles,
]}
>
{visibleCaptions?.map((caption) => (
<View key={caption.index}>
<Text style={{ textAlign: "center" }}>{caption.text}</Text>
</View>
))}
</Animated.View>
);
};

View File

@@ -0,0 +1,174 @@
import type { LanguageCode } from "iso-639-1";
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useState } from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import { parse } from "subsrt-ts";
import { Spinner, useTheme, View } from "tamagui";
import type { Stream } from "@movie-web/provider-utils";
import type { CaptionWithData } from "~/stores/captions";
import { useToast } from "~/hooks/useToast";
import {
getCountryCodeFromLanguage,
getPrettyLanguageNameFromLocale,
} from "~/lib/language";
import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store";
import { FlagIcon } from "../FlagIcon";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const parseCaption = async (
caption: Stream["captions"][0],
): Promise<CaptionWithData> => {
const response = await fetch(caption.url);
const data = await response.text();
return {
...caption,
data: parse(data).filter(
(cue) => cue.type === "caption",
) as ContentCaption[],
};
};
export const CaptionsSelector = () => {
const { showToast } = useToast();
const theme = useTheme();
const [open, setOpen] = useState(false);
const captions = usePlayerStore(
(state) => state.interface.currentStream?.captions,
);
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
const setSelectedCaption = useCaptionsStore(
(state) => state.setSelectedCaption,
);
const downloadCaption = useMutation({
mutationKey: ["captions", selectedCaption?.id],
mutationFn: parseCaption,
onSuccess: (data) => {
setSelectedCaption(data);
},
});
if (!captions?.length) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="subtitles"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}
>
Subtitles
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Subtitles"
rightButton={
<MWButton
color="$playerSettingsUnactiveText"
fontWeight="bold"
chromeless
onPress={() => {
showToast("Work in progress");
}}
>
Customize
</MWButton>
}
/>
<Settings.Content>
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
/>
}
title={"Off"}
iconRight={
<>
{!selectedCaption?.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
</>
}
onPress={() => setSelectedCaption(null)}
/>
{captions?.map((caption) => (
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
overflow="hidden"
>
<FlagIcon
languageCode={getCountryCodeFromLanguage(
caption.language as LanguageCode,
)}
/>
</View>
}
title={getPrettyLanguageNameFromLocale(caption.language) ?? ""}
iconRight={
<>
{selectedCaption?.id === caption.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
{downloadCaption.isPending &&
downloadCaption.variables.id === caption.id && (
<Spinner size="small" color="$loadingIndicator" />
)}
</>
}
onPress={() => downloadCaption.mutate(caption)}
key={caption.id}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,14 @@
import type { ViewProps } from "tamagui";
import React from "react";
import { View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
interface ControlsProps extends ViewProps {
children: React.ReactNode;
}
export const Controls = ({ children, ...props }: ControlsProps) => {
const idle = usePlayerStore((state) => state.interface.isIdle);
return <View {...props}>{!idle && children}</View>;
};

View File

@@ -0,0 +1,20 @@
import { View } from "tamagui";
import { BottomControls } from "./BottomControls";
import { Header } from "./Header";
import { MiddleControls } from "./MiddleControls";
export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => {
return (
<View
width="100%"
flex={1}
flexDirection="column"
justifyContent="space-between"
>
<Header />
{!isLoading && <MiddleControls />}
<BottomControls />
</View>
);
};

View File

@@ -0,0 +1,58 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { findQuality } from "@movie-web/provider-utils";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { convertMetaToScrapeMedia } from "~/lib/meta";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
export const DownloadButton = () => {
const theme = useTheme();
const { startDownload } = useDownloadManager();
const stream = usePlayerStore((state) => state.interface.currentStream);
const meta = usePlayerStore((state) => state.meta);
if (!meta) return null;
const scrapeMedia = convertMetaToScrapeMedia(meta);
let url: string | undefined | null = null;
if (stream?.type === "file") {
const highestQuality = findQuality(stream);
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
} else if (stream?.type === "hls") {
url = stream.playlist;
}
if (!url) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() =>
url &&
startDownload(
url,
stream?.type === "hls" ? "hls" : "mp4",
scrapeMedia,
).catch(console.error)
}
>
Download
</MWButton>
</Controls>
</>
);
};

View File

@@ -0,0 +1,45 @@
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { BrandPill } from "../BrandPill";
import { BackButton } from "./BackButton";
import { Controls } from "./Controls";
import { mapSeasonAndEpisodeNumberToText } from "./utils";
export const Header = () => {
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const meta = usePlayerStore((state) => state.meta);
if (!isIdle) {
return (
<View
zIndex={50}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
height={64}
paddingHorizontal="$8"
>
<View width={150}>
<Controls>
<BackButton />
</Controls>
</View>
{meta && (
<Text fontWeight="bold">
{meta.title} ({meta.releaseYear}){" "}
{meta.season !== undefined && meta.episode !== undefined
? mapSeasonAndEpisodeNumberToText(
meta.season.number,
meta.episode.number,
)
: ""}
</Text>
)}
<View alignItems="center" justifyContent="center" width={150}>
<BrandPill />
</View>
</View>
);
}
};

View File

@@ -0,0 +1,41 @@
import { TouchableWithoutFeedback } from "react-native";
import { View } from "tamagui";
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 (
<TouchableWithoutFeedback onPress={handleTouch}>
<View
position="absolute"
height="100%"
width="100%"
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={82}
>
<Controls>
<SeekButton type="backward" />
</Controls>
<Controls>
<PlayButton />
</Controls>
<Controls>
<SeekButton type="forward" />
</Controls>
</View>
</TouchableWithoutFeedback>
);
};

View File

@@ -0,0 +1,33 @@
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);
const playAudio = usePlayerStore((state) => state.playAudio);
const pauseAudio = usePlayerStore((state) => state.pauseAudio);
return (
<FontAwesome
name={status?.isLoaded && status.isPlaying ? "pause" : "play"}
size={36}
color="white"
onPress={() => {
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.pauseAsync().catch(() => {
console.log("Error pausing video");
});
void pauseAudio();
} else {
videoRef?.playAsync().catch(() => {
console.log("Error playing video");
});
void playAudio();
}
}
}}
/>
);
};

View File

@@ -0,0 +1,59 @@
import type { SheetProps } from "tamagui";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { Settings } from "./settings/Sheet";
export const PlaybackSpeedSelector = (props: SheetProps) => {
const theme = useTheme();
const { speeds, currentSpeed, changePlaybackSpeed } = usePlaybackSpeed();
return (
<Settings.Sheet
forceRemoveScrollEnabled={props.open}
open={props.open}
onOpenChange={props.onOpenChange}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => props.onOpenChange?.(false)}
/>
}
title="Playback settings"
/>
<Settings.Content>
{speeds.map((speed) => (
<Settings.Item
key={speed}
title={`${speed}x`}
iconRight={
speed === currentSpeed && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
changePlaybackSpeed(speed)
.then(() => props.onOpenChange?.(false))
.catch((err) => {
console.log("error", err);
});
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};

View File

@@ -0,0 +1,37 @@
import { useCallback } from "react";
import { TouchableOpacity } 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 setIsIdle = usePlayerStore((state) => state.setIsIdle);
const updateProgress = useCallback(
(newProgress: number) => {
videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => {
console.error("Error updating progress");
});
},
[videoRef],
);
if (status?.isLoaded) {
return (
<TouchableOpacity
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingBottom: 36,
paddingTop: 24,
}}
onPress={() => setIsIdle(false)}
>
<VideoSlider onSlidingComplete={updateProgress} />
</TouchableOpacity>
);
}
};

View File

@@ -0,0 +1,93 @@
import type { SheetProps } from "tamagui";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { constructFullUrl } from "@movie-web/provider-utils";
import { usePlayerStore } from "~/stores/player/store";
import { Settings } from "./settings/Sheet";
export const QualitySelector = (props: SheetProps) => {
const theme = useTheme();
const videoRef = usePlayerStore((state) => state.videoRef);
const videoSrc = usePlayerStore((state) => state.videoSrc);
const stream = usePlayerStore((state) => state.interface.currentStream);
const hlsTracks = usePlayerStore((state) => state.interface.hlsTracks);
if (!videoRef || !videoSrc || !stream) return null;
let qualityMap: { quality: string; url: string }[];
let currentQuality: string | undefined;
if (stream.type === "file") {
const { qualities } = stream;
currentQuality = Object.keys(qualities).find(
(key) => qualities[key as keyof typeof qualities]!.url === videoSrc.uri,
);
qualityMap = Object.keys(qualities).map((key) => ({
quality: key,
url: qualities[key as keyof typeof qualities]!.url,
}));
} else if (stream.type === "hls") {
if (!hlsTracks?.video) return null;
qualityMap = hlsTracks.video.map((video) => ({
quality:
(video.properties[0]?.attributes.resolution as string) ?? "unknown",
url: constructFullUrl(stream.playlist, video.uri),
}));
} else {
return null;
}
return (
<>
<Settings.Sheet
forceRemoveScrollEnabled={props.open}
open={props.open}
onOpenChange={props.onOpenChange}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => props.onOpenChange?.(false)}
/>
}
title="Quality settings"
/>
<Settings.Content>
{qualityMap?.map((quality) => (
<Settings.Item
key={quality.quality}
title={quality.quality}
iconRight={
quality.quality === currentQuality && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
void videoRef.unloadAsync();
void videoRef.loadAsync(
{ uri: quality.url, headers: stream.headers },
{ shouldPlay: true },
);
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,115 @@
import type { ReactNode } from "react";
import React from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { Text, useTheme, View } from "tamagui";
export interface ScrapeItemProps {
status: "failure" | "pending" | "notfound" | "success" | "waiting";
name: string;
id?: string;
percentage?: number;
children?: ReactNode;
}
export interface ScrapeCardProps extends ScrapeItemProps {
hasChildren?: boolean;
}
const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
notfound: "Doesn't have the video",
failure: "Failed to scrape",
pending: "Checking for videos...",
};
const mapPercentageToIcon = (percentage: number) => {
const slice = Math.floor(percentage / 12.5);
return `circle-slice-${slice === 0 ? 1 : slice}`;
};
export function StatusCircle({
type,
percentage,
}: {
type: ScrapeItemProps["status"];
percentage: number;
}) {
const theme = useTheme();
return (
<>
{type === "waiting" && (
<MaterialCommunityIcons
name="circle-outline"
size={40}
color={theme.scrapingNoResult.val}
/>
)}
{type === "pending" && (
<MaterialCommunityIcons
name={mapPercentageToIcon(percentage) as "circle-slice-1"}
size={40}
color={theme.scrapingLoading.val}
/>
)}
{type === "failure" && (
<MaterialCommunityIcons
name="close-circle"
size={40}
color={theme.scrapingError.val}
/>
)}
{type === "notfound" && (
<MaterialIcons
name="remove-circle"
size={40}
color={theme.scrapingNoResult.val}
/>
)}
{type === "success" && (
<MaterialIcons
name="check-circle"
size={40}
color={theme.scrapingSuccess.val}
/>
)}
</>
);
}
export function ScrapeItem(props: ScrapeItemProps) {
const text = statusTextMap[props.status];
return (
<View flex={1} flexDirection="column">
<View flexDirection="row" alignItems="center" gap={16}>
<StatusCircle type={props.status} percentage={props.percentage ?? 0} />
<Text
fontSize={18}
color={props.status === "pending" ? "$scrapingLoading" : "white"}
>
{props.name}
</Text>
</View>
<View flexDirection="row" alignItems="center" gap={16}>
<View width={40} />
<View>{text && <Text fontSize={18}>{text}</Text>}</View>
</View>
<View marginLeft={48}>{props.children}</View>
</View>
);
}
export function ScrapeCard(props: ScrapeCardProps) {
return (
<View width={384}>
<View
width="100%"
borderRadius={10}
paddingVertical={12}
paddingHorizontal={24}
backgroundColor={props.hasChildren ? "$scrapingCard" : "transparent"}
>
<ScrapeItem {...props} />
</View>
</View>
);
}

View File

@@ -0,0 +1,214 @@
import { useEffect, useRef } from "react";
import { SafeAreaView } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { View } from "tamagui";
import type { RunOutput, ScrapeMedia } from "@movie-web/provider-utils";
import {
extractTracksFromHLS,
filterAudioTracks,
findQuality,
} from "@movie-web/provider-utils";
import type { ItemData } from "../item/item";
import type { PlayerMeta } from "~/stores/player/slices/video";
import { useMeta } from "~/hooks/player/useMeta";
import { useScrape } from "~/hooks/player/useSourceScrape";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { convertMetaToScrapeMedia } from "~/lib/meta";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { BackButton } from "./BackButton";
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
interface ScraperProcessProps {
data?: Partial<ItemData>;
media?: ScrapeMedia;
download?: boolean;
}
export const ScraperProcess = ({
data,
media,
download,
}: ScraperProcessProps) => {
const router = useRouter();
const { startDownload } = useDownloadManager();
const scrollViewRef = useRef<ScrollView>(null);
const { convertIdToMeta } = useMeta();
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
const setStream = usePlayerStore((state) => state.setCurrentStream);
const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const setSourceId = usePlayerStore((state) => state.setSourceId);
useEffect(() => {
const fetchData = async () => {
if (!data?.id && !media) return router.back();
let streamResult: RunOutput | null = null;
let meta: PlayerMeta | undefined = undefined;
if (!media && data?.id && data.type) {
meta = await convertIdToMeta(
data.id,
data.type,
data.season,
data.episode,
);
if (!meta) return router.back();
}
const scrapeMedia = media ?? (meta && convertMetaToScrapeMedia(meta));
if (!scrapeMedia) return router.back();
streamResult = await startScraping(scrapeMedia);
if (!streamResult) return router.back();
if (download) {
if (streamResult.stream.type === "file") {
const quality = findQuality(streamResult.stream);
const url = quality
? streamResult.stream.qualities[quality]?.url
: null;
if (!url) return;
startDownload(url, "mp4", scrapeMedia).catch(console.error);
} else if (streamResult.stream.type === "hls") {
startDownload(streamResult.stream.playlist, "hls", scrapeMedia).catch(
console.error,
);
}
return router.back();
}
setStream(streamResult.stream);
if (streamResult.stream.type === "hls") {
const tracks = await extractTracksFromHLS(
streamResult.stream.playlist,
{
...streamResult.stream.preferredHeaders,
...streamResult.stream.headers,
},
);
if (tracks) setHlsTracks(tracks);
if (tracks?.audio.length) {
setAudioTracks(
filterAudioTracks(tracks, streamResult.stream.playlist),
);
}
}
setPlayerStatus(PlayerStatus.READY);
setSourceId(streamResult.sourceId);
};
void fetchData();
}, [
convertIdToMeta,
data,
download,
media,
router,
setAudioTracks,
setHlsTracks,
setPlayerStatus,
setSourceId,
setStream,
startDownload,
startScraping,
]);
let currentProviderIndex = sourceOrder.findIndex(
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
);
if (currentProviderIndex === -1) {
currentProviderIndex = sourceOrder.length - 1;
}
useEffect(() => {
scrollViewRef.current?.scrollTo({
y: currentProviderIndex * 110,
animated: true,
});
}, [currentProviderIndex]);
return (
<SafeAreaView
style={{
display: "flex",
height: "100%",
flexDirection: "column",
flex: 1,
}}
>
<View
flex={1}
alignItems="center"
justifyContent="center"
backgroundColor="$screenBackground"
>
<View position="absolute" top={40} left={40}>
<BackButton />
</View>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingVertical: 64,
}}
>
{sourceOrder.map((order) => {
const source = sources[order.id];
if (!source) return null;
const distance = Math.abs(
sourceOrder.findIndex((o) => o.id === order.id) -
currentProviderIndex,
);
return (
<View
key={order.id}
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
>
<ScrapeCard
id={order.id}
name={source.name}
status={source.status}
hasChildren={order.children.length > 0}
percentage={source.percentage}
>
<View
marginTop={order.children.length > 0 ? 8 : 0}
flexDirection="column"
gap={16}
>
{order.children.map((embedId) => {
const embed = sources[embedId];
if (!embed) return null;
return (
<ScrapeItem
id={embedId}
name={embed.name}
status={embed.status}
percentage={embed.percentage}
key={embedId}
/>
);
})}
</View>
</ScrapeCard>
</View>
);
})}
</ScrollView>
</View>
</SafeAreaView>
);
};

View File

@@ -0,0 +1,184 @@
import type { SheetProps } from "tamagui";
import { useState } from "react";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useTheme, View } from "tamagui";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const EpisodeSelector = ({
seasonNumber,
setSelectedSeason,
...props
}: SheetProps & {
seasonNumber: number;
setSelectedSeason: (season: number | null) => void;
}) => {
const theme = useTheme();
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const { data, isLoading } = useQuery({
queryKey: ["seasonEpisodes", meta!.tmdbId, seasonNumber],
queryFn: async () => {
return fetchSeasonDetails(meta!.tmdbId, seasonNumber);
},
enabled: meta !== null,
});
if (!meta) return null;
return (
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={24}
color={theme.buttonSecondaryText.val}
onPress={() => {
setSelectedSeason(null);
props.onOpenChange?.(false);
}}
/>
}
title={`Season ${data?.season_number}`}
/>
<Settings.Content>
{data?.episodes.map((episode) => (
<Settings.Item
key={episode.id}
iconLeft={
<View
width={32}
height={32}
backgroundColor="#121c24"
justifyContent="center"
alignItems="center"
borderRadius={6}
>
<Settings.Text fontSize={14}>
E{episode.episode_number}
</Settings.Text>
</View>
}
title={episode.name}
onPress={() => {
setMeta({
...meta,
episode: {
number: episode.episode_number,
tmdbId: episode.id.toString(),
},
});
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SeasonSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [episodeOpen, setEpisodeOpen] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const meta = usePlayerStore((state) => state.meta);
const { data, isLoading } = useQuery({
queryKey: ["seasons", meta!.tmdbId],
queryFn: async () => {
return fetchMediaDetails(meta!.tmdbId, "tv");
},
enabled: meta !== null,
});
if (meta?.type !== "show") return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="audio-video"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}
>
Episodes
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
{episodeOpen && selectedSeason ? (
<EpisodeSelector
seasonNumber={selectedSeason}
setSelectedSeason={setSelectedSeason}
open={episodeOpen}
onOpenChange={setEpisodeOpen}
/>
) : (
<>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title={data?.result.name ?? ""}
/>
<Settings.Content>
{data?.result.seasons.map((season) => (
<Settings.Item
key={season.season_number}
title={`Season ${season.season_number}`}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => {
setSelectedSeason(season.season_number);
setEpisodeOpen(true);
}}
/>
))}
</Settings.Content>
</>
)}
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,36 @@
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);
const setAudioPositionAsync = usePlayerStore(
(state) => state.setAudioPositionAsync,
);
return (
<MaterialIcons
name={type === "forward" ? "forward-10" : "replay-10"}
size={36}
color="white"
onPress={() => {
if (status?.isLoaded) {
const position =
type === "forward"
? status.positionMillis + 10000
: status.positionMillis - 10000;
videoRef?.setPositionAsync(position).catch(() => {
console.log("Error seeking backwards");
});
void setAudioPositionAsync(position);
}
}}
/>
);
};

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { PlaybackSpeedSelector } from "./PlaybackSpeedSelector";
import { QualitySelector } from "./QualitySelector";
import { Settings } from "./settings/Sheet";
export const SettingsSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [qualityOpen, setQualityOpen] = useState(false);
const [playbackOpen, setPlaybackOpen] = useState(false);
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialIcons
name="display-settings"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}
>
Settings
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<QualitySelector open={qualityOpen} onOpenChange={setQualityOpen} />
<PlaybackSpeedSelector
open={playbackOpen}
onOpenChange={setPlaybackOpen}
/>
<Settings.Header
icon={
<MaterialIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Settings"
/>
<Settings.Content>
<Settings.Item
title="Quality"
iconLeft={
<MaterialIcons
name="hd"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => setQualityOpen(true)}
/>
<Settings.Item
title="Playback speed"
iconLeft={
<MaterialIcons
name="speed"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => setPlaybackOpen(true)}
/>
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,222 @@
import type { SheetProps } from "tamagui";
import { useCallback, useEffect, useState } from "react";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Spinner, Text, useTheme, View } from "tamagui";
import { getBuiltinSources, providers } from "@movie-web/provider-utils";
import {
useEmbedScrape,
useSourceScrape,
} from "~/hooks/player/useSourceScrape";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const SourceItem = ({
name,
id,
active,
embed,
onPress,
}: {
name: string;
id: string;
active?: boolean;
embed?: { url: string; embedId: string };
onPress?: (id: string) => void;
}) => {
const theme = useTheme();
const { mutate, isPending, isError } = useEmbedScrape();
return (
<Settings.Item
title={name}
iconRight={
<>
{active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
{isError && (
<MaterialCommunityIcons
name="alert-circle"
size={24}
color={theme.scrapingError.val}
/>
)}
{isPending && <Spinner size="small" color="$scrapingLoading" />}
</>
}
onPress={() => {
if (onPress) {
onPress(id);
return;
}
if (embed) {
mutate({
url: embed.url,
embedId: embed.embedId,
sourceId: id,
});
}
}}
/>
);
};
const EmbedsPart = ({
sourceId,
closeParent,
...props
}: SheetProps & {
sourceId: string;
closeParent?: (open: boolean) => void;
}) => {
const theme = useTheme();
const { data, isPending, isError, error, status } = useSourceScrape(sourceId);
useEffect(() => {
if (status === "success" && !isError && data && data?.length <= 1) {
props.onOpenChange?.(false);
closeParent?.(false);
}
}, [status, data, isError, props, closeParent]);
return (
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={24}
color={theme.buttonSecondaryText.val}
onPress={() => {
props.onOpenChange?.(false);
}}
/>
}
title={providers.getMetadata(sourceId)?.name ?? "Embeds"}
/>
<Settings.Content>
<View alignItems="center" justifyContent="center">
{isPending && <Spinner size="small" color="$loadingIndicator" />}
{error && <Text>Something went wrong!</Text>}
</View>
{data && data?.length > 1 && (
<Settings.Content>
{data.map((embed) => {
const metaData = providers.getMetadata(embed.embedId)!;
return (
<SourceItem
key={embed.embedId}
name={metaData.name}
id={embed.embedId}
embed={embed}
/>
);
})}
</Settings.Content>
)}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SourceSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [embedOpen, setEmbedOpen] = useState(false);
const sourceId = usePlayerStore((state) => state.interface.sourceId);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const isActive = useCallback(
(id: string) => {
return sourceId === id;
},
[sourceId],
);
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="video"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={() => setOpen(true)}
>
Source
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
{embedOpen && sourceId ? (
<EmbedsPart
sourceId={sourceId}
open={embedOpen}
onOpenChange={setEmbedOpen}
closeParent={setOpen}
/>
) : (
<>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Sources"
/>
<Settings.Content>
{getBuiltinSources()
.sort((a, b) => b.rank - a.rank)
.map((source) => (
<SourceItem
key={source.id}
name={source.name}
id={source.id}
active={isActive(source.id)}
onPress={(id) => {
setSourceId(id);
setEmbedOpen(true);
}}
/>
))}
</Settings.Content>
</>
)}
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import Animated, {
Easing,
useAnimatedProps,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Circle, Svg } from "react-native-svg";
import { AntDesign } from "@expo/vector-icons";
import { View } from "tamagui";
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export const StatusCircle = ({
type,
percentage = 0,
}: {
type: string;
percentage: number;
}) => {
const radius = 25;
const strokeWidth = 5;
const circleCircumference = 2 * Math.PI * radius;
const strokeDashoffset = useSharedValue(circleCircumference);
React.useEffect(() => {
strokeDashoffset.value = withTiming(
circleCircumference - (circleCircumference * percentage) / 100,
{
duration: 500,
easing: Easing.linear,
},
);
}, [circleCircumference, percentage, strokeDashoffset]);
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: strokeDashoffset.value,
}));
const renderIcon = () => {
switch (type) {
case "success":
return <AntDesign name="checkcircle" size={50} color="green" />;
case "error":
return <AntDesign name="closecircle" size={50} color="red" />;
default:
return null;
}
};
return (
<View justifyContent="center" alignItems="center" position="relative">
<Svg height="60" width="60" viewBox="0 0 60 60">
{type === "loading" && (
<AnimatedCircle
cx="30"
cy="30"
r={radius}
stroke="blue"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circleCircumference}
animatedProps={animatedProps}
strokeLinecap="round"
/>
)}
</Svg>
{renderIcon()}
</View>
);
};

View File

@@ -0,0 +1,397 @@
import type { AVPlaybackStatus } from "expo-av";
import type { SharedValue } from "react-native-reanimated";
import { useEffect, useState } from "react";
import { Dimensions, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ResizeMode, Video } from "expo-av";
import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import * as Network from "expo-network";
import { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import { Feather } from "@expo/vector-icons";
import { Spinner, useTheme, View } from "tamagui";
import { findHLSQuality, findQuality } from "@movie-web/provider-utils";
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
import { useBrightness } from "~/hooks/player/useBrightness";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { usePlayer } from "~/hooks/player/usePlayer";
import { useVolume } from "~/hooks/player/useVolume";
import {
convertMetaToItemData,
convertMetaToScrapeMedia,
getNextEpisode,
} from "~/lib/meta";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import {
DefaultQuality,
useNetworkSettingsStore,
usePlayerSettingsStore,
useWatchHistoryStore,
} from "~/stores/settings";
import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay";
export const VideoPlayer = () => {
const {
brightness,
showBrightnessOverlay,
setShowBrightnessOverlay,
handleBrightnessChange,
} = useBrightness();
const { volume, showVolumeOverlay, setShowVolumeOverlay } = useVolume();
const { currentSpeed } = usePlaybackSpeed();
const { synchronizePlayback } = useAudioTrack();
const { dismissFullscreenPlayer } = usePlayer();
const [isLoading, setIsLoading] = useState(true);
const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN);
const [hasStartedPlaying, setHasStartedPlaying] = useState(false);
const router = useRouter();
const scale = useSharedValue(1);
const state = usePlayerStore((state) => state.interface.state);
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const stream = usePlayerStore((state) => state.interface.currentStream);
const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack);
const videoRef = usePlayerStore((state) => state.videoRef);
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined;
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setStatus = usePlayerStore((state) => state.setStatus);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const toggleAudio = usePlayerStore((state) => state.toggleAudio);
const toggleState = usePlayerStore((state) => state.toggleState);
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
const { gestureControls, autoPlay } = usePlayerSettingsStore();
const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } =
useWatchHistoryStore();
const { wifiDefaultQuality, mobileDataDefaultQuality } =
useNetworkSettingsStore();
const updateResizeMode = (newMode: ResizeMode) => {
setResizeMode(newMode);
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
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);
}
});
const doubleTapGesture = Gesture.Tap()
.enabled(gestureControls && isIdle)
.numberOfTaps(2)
.onEnd(() => {
runOnJS(toggleAudio)();
runOnJS(toggleState)();
});
const screenHalfWidth = Dimensions.get("window").width / 2;
const panGesture = Gesture.Pan()
.enabled(gestureControls && isIdle)
.onStart((event) => {
if (event.x > screenHalfWidth) {
runOnJS(setShowVolumeOverlay)(true);
} else {
runOnJS(setShowBrightnessOverlay)(true);
}
})
.onUpdate((event) => {
const divisor = 5000;
const directionMultiplier = event.velocityY < 0 ? 1 : -1;
const change = directionMultiplier * Math.abs(event.velocityY / divisor);
if (event.x > screenHalfWidth) {
const newVolume = Math.max(0, Math.min(1, volume.value + change));
volume.value = newVolume;
} else {
const newBrightness = Math.max(
0,
Math.min(1, brightness.value + change),
);
brightness.value = newBrightness;
runOnJS(handleBrightnessChange)(newBrightness);
}
})
.onEnd((event) => {
if (event.x > screenHalfWidth) {
runOnJS(setShowVolumeOverlay)(false);
} else {
runOnJS(setShowBrightnessOverlay)(false);
}
});
const composedGesture = Gesture.Race(
panGesture,
pinchGesture,
doubleTapGesture,
);
StatusBar.setStatusBarHidden(true);
if (Platform.OS === "android") {
void NavigationBar.setVisibilityAsync("hidden");
}
useEffect(() => {
const initializePlayer = async () => {
if (videoSrc?.uri && isLocalFile) return;
if (!stream) {
await dismissFullscreenPlayer();
return router.back();
}
setIsLoading(true);
const { type: networkType } = await Network.getNetworkStateAsync();
const defaultQuality =
networkType === Network.NetworkStateType.WIFI
? wifiDefaultQuality
: mobileDataDefaultQuality;
const highest = defaultQuality === DefaultQuality.Highest;
let url = null;
if (stream.type === "hls") {
url = await findHLSQuality(stream.playlist, stream.headers, highest);
}
if (stream.type === "file") {
const chosenQuality = findQuality(stream, highest);
url = chosenQuality ? stream.qualities[chosenQuality]?.url : null;
}
if (!url) {
await dismissFullscreenPlayer();
return router.back();
}
setVideoSrc({
uri: url,
headers: {
...stream.preferredHeaders,
...stream.headers,
},
});
setIsLoading(false);
};
void initializePlayer();
const timeout = setTimeout(() => {
if (!hasStartedPlaying) {
router.back();
}
}, 60000);
return () => {
if (meta) {
const item = convertMetaToItemData(meta);
const scrapeMedia = convertMetaToScrapeMedia(meta);
updateWatchHistory(
item,
scrapeMedia,
videoRef?.props.positionMillis ?? 0,
);
}
clearTimeout(timeout);
void synchronizePlayback();
};
}, [
isLocalFile,
dismissFullscreenPlayer,
hasStartedPlaying,
meta,
router,
selectedAudioTrack,
setVideoSrc,
stream,
synchronizePlayback,
updateWatchHistory,
videoRef?.props.positionMillis,
videoSrc?.uri,
wifiDefaultQuality,
mobileDataDefaultQuality,
]);
const onVideoLoadStart = () => {
setIsLoading(true);
};
const onReadyForDisplay = () => {
setIsLoading(false);
setHasStartedPlaying(true);
if (videoRef) {
void videoRef.setRateAsync(currentSpeed, true);
if (meta) {
const media = convertMetaToScrapeMedia(meta);
const watchHistoryItem = getWatchHistoryItem(media);
if (watchHistoryItem) {
void videoRef.setPositionAsync(watchHistoryItem.positionMillis);
}
}
}
};
const onPlaybackStatusUpdate = async (status: AVPlaybackStatus) => {
setStatus(status);
if (meta && status.isLoaded && status.didJustFinish) {
const item = convertMetaToItemData(meta);
removeFromWatchHistory(item);
}
if (
status.isLoaded &&
status.didJustFinish &&
!status.isLooping &&
autoPlay
) {
if (meta?.type !== "show") return;
const nextEpisodeMeta = await getNextEpisode(meta);
if (!nextEpisodeMeta) return;
setMeta(nextEpisodeMeta);
const media = convertMetaToScrapeMedia(nextEpisodeMeta);
router.replace({
pathname: "/videoPlayer",
params: { media: JSON.stringify(media) },
});
}
};
return (
<GestureDetector gesture={composedGesture}>
<View
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
backgroundColor="black"
>
<Video
ref={setVideoRef}
source={videoSrc}
shouldPlay={state === "playing"}
resizeMode={resizeMode}
volume={volume.value}
rate={currentSpeed}
onLoadStart={onVideoLoadStart}
onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
...(!isIdle && {
opacity: 0.7,
}),
},
]}
onTouchStart={() => setIsIdle(!isIdle)}
/>
<View
height="100%"
width="100%"
alignItems="center"
justifyContent="center"
>
{isLoading && (
<Spinner
size="large"
color="$loadingIndicator"
position="absolute"
/>
)}
<ControlsOverlay isLoading={isLoading} />
</View>
{showVolumeOverlay && <GestureOverlay value={volume} type="volume" />}
{showBrightnessOverlay && (
<GestureOverlay value={brightness} type="brightness" />
)}
<CaptionRenderer />
</View>
</GestureDetector>
);
};
function GestureOverlay(props: {
value: SharedValue<number>;
type: "brightness" | "volume";
}) {
const theme = useTheme();
const insets = useSafeAreaInsets();
const animatedStyle = useAnimatedStyle(() => {
return {
height: `${props.value.value * 100}%`,
borderTopLeftRadius: props.value.value >= 0.98 ? 44 : 0,
borderTopRightRadius: props.value.value >= 0.98 ? 44 : 0,
};
});
return (
<View
position="absolute"
left={props.type === "brightness" ? insets.left + 20 : undefined}
right={props.type === "volume" ? insets.right + 20 : undefined}
borderRadius="$4"
gap={8}
height="50%"
>
<Feather
size={24}
color="white"
style={{
bottom: 20,
}}
name={props.type === "brightness" ? "sun" : "volume-2"}
/>
<View
width={14}
backgroundColor={theme.progressBackground}
justifyContent="flex-end"
borderRadius="$4"
left={4}
bottom={20}
height="100%"
>
<Animated.View
style={[
animatedStyle,
{
width: "100%",
backgroundColor: theme.progressFilled.val,
borderBottomRightRadius: 44,
borderBottomLeftRadius: 44,
},
]}
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,176 @@
import type {
HandlerStateChangeEvent,
PanGestureHandlerGestureEvent,
TapGestureHandlerEventPayload,
} from "react-native-gesture-handler";
import React, { useEffect, useRef } from "react";
import { Dimensions } from "react-native";
import {
PanGestureHandler,
State,
TapGestureHandler,
} from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useTheme, View } from "tamagui";
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 theme = useTheme();
const tapRef = useRef<TapGestureHandler>(null);
const panRef = useRef<PanGestureHandler>(null);
const status = usePlayerStore((state) => state.status);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const width = Dimensions.get("screen").width - 120;
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 isDragging = useSharedValue(false);
useEffect(() => {
if (!isDragging.value) {
translateX.value = clamp(valueX, 0, width - knobSize_);
}
}, [valueX, isDragging.value, translateX, width]);
const _onSlidingComplete = (xValue: number) => {
"worklet";
if (onSlidingComplete) runOnJS(onSlidingComplete)(xToValue(xValue));
};
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 = (
event: HandlerStateChangeEvent<TapGestureHandlerEventPayload>,
) => {
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 (
<TapGestureHandler
ref={tapRef}
onHandlerStateChange={onTapEvent}
simultaneousHandlers={panRef}
>
<View
style={[
{
alignItems: "center",
justifyContent: "center",
height: knobSize_,
width,
},
]}
>
<View
style={[
{
height: trackSize_,
borderRadius: trackSize_,
backgroundColor: theme.videoSlider.val,
width,
justifyContent: "center",
},
]}
>
<Animated.View
style={[
{
position: "absolute",
height: trackSize_,
backgroundColor: theme.videoSliderFilled.val,
borderRadius: trackSize_ / 2,
},
progressStyle,
]}
/>
<PanGestureHandler
ref={panRef}
onGestureEvent={onGestureEvent}
simultaneousHandlers={tapRef}
>
<Animated.View
style={[
{
justifyContent: "center",
alignItems: "center",
height: knobSize_,
width: knobSize_,
borderRadius: knobSize_ / 2,
backgroundColor: theme.videoSliderFilled.val,
},
scrollTranslationStyle,
]}
/>
</PanGestureHandler>
</View>
</View>
</TapGestureHandler>
);
};
export default VideoSlider;

View File

@@ -0,0 +1,157 @@
import type { SheetProps, ViewProps } from "tamagui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
ScrollView,
Separator,
Sheet,
Spinner,
styled,
Text,
View,
} from "tamagui";
const PlayerText = styled(Text, {
color: "$playerSettingsUnactiveText",
fontWeight: "bold",
fontSize: 18,
});
function SettingsSheet(props: SheetProps) {
return (
<Sheet
snapPoints={[90]}
dismissOnSnapToBottom
modal
animation="spring"
{...props}
>
{props.children}
</Sheet>
);
}
function SettingsSheetOverlay() {
return (
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.7)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
);
}
function SettingsSheetHandle() {
return <Sheet.Handle backgroundColor="$sheetHandle" />;
}
function SettingsSheetFrame({
children,
isLoading,
}: {
children: React.ReactNode;
isLoading?: boolean;
}) {
return (
<View style={{ flex: 1 }} backgroundColor="black">
<Sheet.Frame
backgroundColor="$playerSettingsBackground"
padding="$5"
gap="$4"
>
{isLoading && (
<Spinner
size="large"
color="$loadingIndicator"
style={{
position: "absolute",
}}
/>
)}
{!isLoading && children}
</Sheet.Frame>
</View>
);
}
function SettingsHeader({
icon,
title,
rightButton,
}: {
icon: React.ReactNode;
title: string;
rightButton?: React.ReactNode;
}) {
const insets = useSafeAreaInsets();
return (
<>
<View
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
flexDirection="row"
alignItems="center"
gap="$4"
>
{icon}
<PlayerText flexGrow={1}>{title}</PlayerText>
{rightButton}
</View>
<Separator />
</>
);
}
function SettingsContent({
isScroll = true,
children,
}: {
isScroll?: boolean;
children: React.ReactNode;
}) {
const ViewDisplay = isScroll ? ScrollView : View;
const insets = useSafeAreaInsets();
return (
<ViewDisplay
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
contentContainerStyle={{
gap: "$4",
}}
>
{children}
</ViewDisplay>
);
}
function SettingsItem({
iconLeft,
iconRight,
title,
...props
}: ViewProps & {
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
title: string;
}) {
return (
<View flexDirection="row" gap="$4" alignItems="center" {...props}>
{iconLeft}
<PlayerText flexGrow={1} fontSize={16} fontWeight="700">
{title}
</PlayerText>
{iconRight}
</View>
);
}
export const Settings = {
Sheet: SettingsSheet,
SheetOverlay: SettingsSheetOverlay,
SheetHandle: SettingsSheetHandle,
SheetFrame: SettingsSheetFrame,
Header: SettingsHeader,
Content: SettingsContent,
Text: PlayerText,
Item: SettingsItem,
};

View File

@@ -0,0 +1,25 @@
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;
};
export const mapSeasonAndEpisodeNumberToText = (
season: number,
episode: number,
) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};

View File

@@ -0,0 +1,28 @@
import { Button, styled } from "tamagui";
export const MWButton = styled(Button, {
variants: {
type: {
primary: {
backgroundColor: "$buttonPrimaryBackground",
color: "$buttonPrimaryText",
fontWeight: "bold",
},
secondary: {
backgroundColor: "$buttonSecondaryBackground",
color: "$buttonSecondaryText",
fontWeight: "bold",
},
purple: {
backgroundColor: "$buttonPurpleBackground",
color: "white",
fontWeight: "bold",
},
cancel: {
backgroundColor: "$buttonCancelBackground",
color: "white",
fontWeight: "bold",
},
},
} as const,
});

View File

@@ -0,0 +1,26 @@
import { Input, styled } from "tamagui";
export const MWInput = styled(Input, {
fontWeight: "$semibold",
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
placeholderTextColor: "$placeHolderText",
borderColor: "$inputBorder",
outlineStyle: "none",
},
search: {
backgroundColor: "$searchBackground",
borderColor: "$colorTransparent",
placeholderTextColor: "$searchPlaceholder",
outlineStyle: "none",
focusStyle: {
borderColor: "$colorTransparent",
},
},
},
},
});

View File

@@ -0,0 +1,14 @@
import { Progress, styled, withStaticProperties } from "tamagui";
const MWProgressFrame = styled(Progress, {
backgroundColor: "$progressBackground",
});
const MWProgressIndicator = styled(Progress.Indicator, {
backgroundColor: "$progressFilled",
animation: "bounce",
});
export const MWProgress = withStaticProperties(MWProgressFrame, {
Indicator: MWProgressIndicator,
});

View File

@@ -0,0 +1,52 @@
import type { Input } from "tamagui";
import { useEffect, useRef, useState } from "react";
import { FontAwesome5 } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import { useTheme, View } from "tamagui";
import { MWInput } from "./Input";
export function SearchBar({
onSearchChange,
}: {
onSearchChange: (text: string) => void;
}) {
const theme = useTheme();
const pageIsFocused = useIsFocused();
const [keyword, setKeyword] = useState("");
const inputRef = useRef<Input>(null);
useEffect(() => {
if (pageIsFocused) {
inputRef.current?.focus();
}
}, [pageIsFocused]);
const handleChange = (text: string) => {
setKeyword(text);
onSearchChange(text);
};
return (
<View
flexDirection="row"
alignItems="center"
borderRadius={999}
borderWidth={1}
backgroundColor={theme.searchBackground}
>
<View width={48} alignItems="center" justifyContent="center">
<FontAwesome5 name="search" size={18} color={theme.searchIcon.val} />
</View>
<MWInput
type="search"
value={keyword}
onChangeText={handleChange}
ref={inputRef}
placeholder="What are you looking for?"
width="75%"
backgroundColor={theme.searchBackground}
/>
</View>
);
}

View File

@@ -0,0 +1,38 @@
import { Select, styled, withStaticProperties } from "tamagui";
const MWSelectFrame = styled(Select, {
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
borderColor: "$inputBorder",
},
},
},
defaultVariants: {
type: "default",
},
});
const MWSelectTrigger = styled(Select.Trigger, {
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
placeholderTextColor: "$inputPlaceholderText",
borderColor: "$inputBorder",
},
},
},
defaultVariants: {
type: "default",
},
});
const MWSelect = withStaticProperties(MWSelectFrame, {
Trigger: MWSelectTrigger,
});
export { MWSelect };

View File

@@ -0,0 +1,14 @@
import { Separator, styled } from "tamagui";
export const MWSeparator = styled(Separator, {
variants: {
type: {
settings: {
borderColor: "$shade300",
},
},
},
defaultVariants: {
type: "settings",
},
});

View File

@@ -0,0 +1,27 @@
import type { SwitchProps, SwitchThumbProps } from "tamagui";
import { Switch, useTheme } from "tamagui";
const MWSwitch = (props: SwitchProps) => {
const theme = useTheme();
return (
<Switch
native
nativeProps={{
trackColor: {
true: theme.switchActiveTrackColor.val,
false: theme.switchInactiveTrackColor.val,
},
thumbColor: theme.switchThumbColor.val,
}}
{...props}
/>
);
};
const MWSwitchThumb = (props: SwitchThumbProps) => {
return <Switch.Thumb animation="bounce" {...props} />;
};
MWSwitch.Thumb = MWSwitchThumb;
export { MWSwitch };

View File

@@ -0,0 +1,36 @@
import type { TextProps } from "react-native";
import type { AnimatedProps } from "react-native-reanimated";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
export const FlashingText = (
props: AnimatedProps<TextProps> & {
isInProgress: boolean;
},
) => {
const opacity = useSharedValue(0);
useEffect(() => {
if (props.isInProgress) {
opacity.value = withRepeat(
withTiming(1, { duration: 1000, easing: Easing.ease }),
-1,
true,
);
} else {
opacity.value = 1;
}
}, [props.isInProgress, opacity]);
const style = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return <Animated.Text {...props} style={[props.style, style]} />;
};

View File

@@ -0,0 +1,2 @@
export const DISCORD_LINK = "https://movie-web.github.io/links/discord";
export const GITHUB_LINK = "https://github.com/movie-web";

View File

@@ -0,0 +1,88 @@
import type { Video } from "expo-av";
import { useCallback, useEffect } from "react";
import { Audio } from "expo-av";
import type { Stream } from "@movie-web/provider-utils";
import type { AudioTrack } from "~/components/player/AudioTrackSelector";
import { usePlayerStore } from "~/stores/player/store";
export const useAudioTrack = () => {
const videoRef = usePlayerStore((state) => state.videoRef);
const audioObject = usePlayerStore((state) => state.audioObject);
const currentAudioTrack = usePlayerStore((state) => state.currentAudioTrack);
const setAudioObject = usePlayerStore((state) => state.setAudioObject);
const setCurrentAudioTrack = usePlayerStore(
(state) => state.setCurrentAudioTrack,
);
const synchronizePlayback = useCallback(
async (selectedAudioTrack?: AudioTrack, stream?: Stream) => {
if (selectedAudioTrack && stream) {
if (audioObject) {
await audioObject.unloadAsync();
}
const createAudioAsyncWithTimeout = (uri: string, timeout = 5000) => {
return new Promise<Audio.Sound | undefined>((resolve, reject) => {
Audio.Sound.createAsync({
uri,
headers: {
...stream.headers,
...stream.preferredHeaders,
},
})
.then((value) => resolve(value.sound))
.catch(reject);
setTimeout(() => {
reject(new Error("Timeout: Audio loading took too long"));
}, timeout);
});
};
try {
const sound = await createAudioAsyncWithTimeout(
selectedAudioTrack.uri,
);
if (!sound) return;
setAudioObject(sound);
setCurrentAudioTrack(selectedAudioTrack);
} catch (error) {
console.error("Error loading audio track:", error);
}
} else {
if (audioObject) {
await audioObject.unloadAsync();
setAudioObject(null);
}
}
},
[audioObject, setAudioObject, setCurrentAudioTrack],
);
const synchronizeAudioWithVideo = async (
videoRef: Video | null,
audioObject: Audio.Sound | null,
selectedAudioTrack?: AudioTrack,
): Promise<void> => {
if (videoRef && audioObject) {
const videoStatus = await videoRef.getStatusAsync();
if (selectedAudioTrack && videoStatus.isLoaded) {
await videoRef.setIsMutedAsync(true);
await audioObject.playAsync();
await audioObject.setPositionAsync(videoStatus.positionMillis + 2000);
} else {
await videoRef.setIsMutedAsync(false);
}
}
};
useEffect(() => {
if (audioObject && currentAudioTrack) {
void synchronizeAudioWithVideo(videoRef, audioObject, currentAudioTrack);
}
}, [audioObject, videoRef, currentAudioTrack]);
return { synchronizePlayback };
};

View File

@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import { useSharedValue } from "react-native-reanimated";
import * as Brightness from "expo-brightness";
export const useBrightness = () => {
const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false);
const brightness = useSharedValue(0.5);
const handleBrightnessChange = useCallback(async (newValue: number) => {
try {
await Brightness.setBrightnessAsync(newValue);
} catch (error) {
console.error("Failed to set brightness:", error);
}
}, []);
return {
showBrightnessOverlay,
setShowBrightnessOverlay,
brightness,
handleBrightnessChange,
} as const;
};

View File

@@ -0,0 +1,63 @@
import { useCallback } from "react";
import { transformSearchResultToScrapeMedia } from "@movie-web/provider-utils";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { usePlayerStore } from "~/stores/player/store";
export const useMeta = () => {
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const convertIdToMeta = useCallback(
async (
id: string,
type: "movie" | "tv",
season?: number,
episode?: number,
) => {
const media = await fetchMediaDetails(id, type);
if (!media) return;
const scrapeMedia = transformSearchResultToScrapeMedia(
media.type,
media.result,
season ?? meta?.season?.number,
episode ?? meta?.episode?.number,
);
let seasonData = null;
if (scrapeMedia.type === "show") {
seasonData = await fetchSeasonDetails(
scrapeMedia.tmdbId,
scrapeMedia.season.number,
);
}
const m = {
...scrapeMedia,
poster: media.result.poster_path,
...("season" in scrapeMedia
? {
season: {
number: scrapeMedia.season.number,
tmdbId: scrapeMedia.tmdbId,
},
episode: {
number: scrapeMedia.episode.number,
tmdbId: scrapeMedia.episode.tmdbId,
},
episodes:
seasonData?.episodes.map((e) => ({
tmdbId: e.id.toString(),
number: e.episode_number,
name: e.name,
})) ?? [],
}
: {}),
};
setMeta(m);
return m;
},
[meta?.episode?.number, meta?.season?.number, setMeta],
);
return { convertIdToMeta };
};

Some files were not shown because too many files have changed in this diff Show More