diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index 10df0f4..d72f743 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -1,9 +1,16 @@
name: build mobile app
on:
- pull_request:
- branches:
- - "*"
+ workflow_dispatch: # Manual trigger
+ inputs:
+ target:
+ type: choice
+ description: "Choose the build target"
+ required: true
+ default: "android"
+ options:
+ - android
+ - ios
permissions:
contents: write
@@ -11,6 +18,7 @@ permissions:
jobs:
build-android:
+ if: ${{ inputs.target == 'android' }} # Runs only if 'android' is selected
runs-on: ubuntu-latest
steps:
@@ -47,6 +55,9 @@ jobs:
- name: Install dependencies
run: corepack enable && pnpm install
+ - name: Build dependencies
+ run: pnpm run build
+
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
@@ -60,6 +71,7 @@ jobs:
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
+ if: ${{ inputs.target == 'ios' }} # Runs only if 'ios' is selected
runs-on: macos-14
steps:
@@ -87,6 +99,9 @@ jobs:
- name: Install dependencies
run: pnpm install
+ - name: Build dependencies
+ run: pnpm run build
+
- name: Cache Pods
uses: actions/cache@v4
with:
diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml
index 6ce6d8d..4947020 100644
--- a/.github/workflows/release-mobile.yml
+++ b/.github/workflows/release-mobile.yml
@@ -3,7 +3,7 @@ name: release mobile app
on:
push:
branches:
- - main
+ - master
workflow_dispatch:
permissions:
diff --git a/.gitignore b/.gitignore
index 9e9a2e8..3b3865b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,10 +17,10 @@ coverage
dist/
expo-env.d.ts
apps/expo/.gitignore
-ios/
-android/
-!modules/*/ios/
-!modules/*/android/
+
+# Ignore top-level ios and android directories
+apps/expo/ios/
+apps/expo/android/
# production
build
diff --git a/.nvmrc b/.nvmrc
deleted file mode 100644
index a3d2332..0000000
--- a/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-20.11
\ No newline at end of file
diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts
index 7dbc050..1caf2e6 100644
--- a/apps/expo/app.config.ts
+++ b/apps/expo/app.config.ts
@@ -1,7 +1,6 @@
import type { ExpoConfig } from "expo/config";
import { version } from "./package.json";
-import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
const defineConfig = (): ExpoConfig => ({
name: "movie-web",
@@ -20,20 +19,19 @@ const defineConfig = (): ExpoConfig => ({
},
assetBundlePatterns: ["**/*"],
ios: {
- bundleIdentifier: "dev.movieweb.app",
+ newArchEnabled: true,
+ bundleIdentifier: "dev.movieweb.mobile",
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",
+ newArchEnabled: true,
+ package: "dev.movieweb.mobile",
permissions: ["WRITE_SETTINGS"],
},
web: {
@@ -46,7 +44,8 @@ const defineConfig = (): ExpoConfig => ({
},
plugins: [
"expo-router",
- [withRemoveiOSNotificationEntitlement as unknown as string],
+ "expo-video",
+ "expo-audio",
[
"expo-screen-orientation",
{
@@ -59,12 +58,8 @@ const defineConfig = (): ExpoConfig => ({
android: {
minSdkVersion: 24,
packagingOptions: {
- pickFirst: [
- "lib/x86/libcrypto.so",
- "lib/x86_64/libcrypto.so",
- "lib/armeabi-v7a/libcrypto.so",
- "lib/arm64-v8a/libcrypto.so",
- ],
+ pickFirst: ["**/libcrypto.so"],
+ excludes: ["**/libreactnative.so"],
},
},
},
diff --git a/apps/expo/babel.config.js b/apps/expo/babel.config.js
index 1967743..6c3425f 100644
--- a/apps/expo/babel.config.js
+++ b/apps/expo/babel.config.js
@@ -11,7 +11,7 @@ module.exports = function (api) {
{
alias: {
crypto: "react-native-quick-crypto",
- stream: "stream-browserify",
+ stream: "readable-stream",
buffer: "@craftzdog/react-native-buffer",
},
},
diff --git a/apps/expo/eas.json b/apps/expo/eas.json
deleted file mode 100644
index 607de32..0000000
--- a/apps/expo/eas.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "cli": {
- "version": ">= 4.1.2"
- },
- "build": {
- "base": {
- "node": "18.16.1",
- "ios": {
- "resourceClass": "m-medium"
- }
- },
- "development": {
- "extends": "base",
- "developmentClient": true,
- "distribution": "internal"
- },
- "preview": {
- "extends": "base",
- "distribution": "internal",
- "ios": {
- "simulator": true
- }
- },
- "production": {
- "extends": "base"
- }
- },
- "submit": {
- "production": {}
- }
-}
diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js
index 259061d..2e8b8a0 100644
--- a/apps/expo/metro.config.js
+++ b/apps/expo/metro.config.js
@@ -1,5 +1,6 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
+const { mergeConfig } = require("metro-config");
const { FileStore } = require("metro-cache");
const { withTamagui } = require("@tamagui/metro-plugin");
@@ -8,9 +9,20 @@ const path = require("path");
module.exports = withTurborepoManagedCache(
withMonorepoPaths(
withTamagui(
- getDefaultConfig(__dirname, {
- isCSSEnabled: true,
- }),
+ mergeConfig(
+ getDefaultConfig(__dirname, {
+ isCSSEnabled: true,
+ }),
+ {
+ transformer: {
+ getTransformOptions: async () => ({
+ transform: {
+ inlineRequires: true,
+ },
+ }),
+ },
+ },
+ ),
{
components: ["tamagui"],
config: "./tamagui.config.ts",
diff --git a/apps/expo/modules/check-ios-app-id/expo-module.config.json b/apps/expo/modules/check-ios-app-id/expo-module.config.json
new file mode 100644
index 0000000..8717dc5
--- /dev/null
+++ b/apps/expo/modules/check-ios-app-id/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["ios"],
+ "ios": {
+ "modules": ["CheckIosAppIdModule"]
+ }
+}
diff --git a/apps/expo/modules/check-ios-app-id/index.ts b/apps/expo/modules/check-ios-app-id/index.ts
new file mode 100644
index 0000000..666455b
--- /dev/null
+++ b/apps/expo/modules/check-ios-app-id/index.ts
@@ -0,0 +1,9 @@
+import CheckIosAppIdModule from "./src/CheckIosAppIdModule";
+
+export function isIncorrectAppId(): boolean {
+ return CheckIosAppIdModule.isIncorrectAppId();
+}
+
+export function getAppId(): string {
+ return CheckIosAppIdModule.getAppId();
+}
diff --git a/apps/expo/modules/check-ios-certificate/ios/CheckIosCertificate.podspec b/apps/expo/modules/check-ios-app-id/ios/CheckIosAppId.podspec
similarity index 71%
rename from apps/expo/modules/check-ios-certificate/ios/CheckIosCertificate.podspec
rename to apps/expo/modules/check-ios-app-id/ios/CheckIosAppId.podspec
index 597e515..71d1c58 100644
--- a/apps/expo/modules/check-ios-certificate/ios/CheckIosCertificate.podspec
+++ b/apps/expo/modules/check-ios-app-id/ios/CheckIosAppId.podspec
@@ -1,8 +1,8 @@
Pod::Spec.new do |s|
- s.name = 'CheckIosCertificate'
+ s.name = 'CheckIosAppId'
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.summary = 'Check if iOS App ID is explicit or wildcard.'
+ s.description = 'Check if iOS App ID is explicit or wildcard.'
s.author = 'castdrian'
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
diff --git a/apps/expo/modules/check-ios-app-id/ios/CheckIosAppIdModule.swift b/apps/expo/modules/check-ios-app-id/ios/CheckIosAppIdModule.swift
new file mode 100644
index 0000000..e0ef1e9
--- /dev/null
+++ b/apps/expo/modules/check-ios-app-id/ios/CheckIosAppIdModule.swift
@@ -0,0 +1,54 @@
+import ExpoModulesCore
+
+public class CheckIosAppIdModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("CheckIosAppId")
+
+ Function("isIncorrectAppId") { () -> Bool in
+ #if targetEnvironment(simulator)
+ return false
+ #else
+ guard let appId = self.extractAppId() else {
+ return false
+ }
+
+ return appId.hasSuffix(".*") || (Bundle.main.bundleIdentifier != nil && !appId.contains(Bundle.main.bundleIdentifier!))
+ #endif
+ }
+
+ // Function to get the App ID from the provisioning profile
+ Function("getAppId") { () -> String? in
+ #if targetEnvironment(simulator)
+ return nil
+ #else
+ return self.extractAppId()
+ #endif
+ }
+ }
+
+ // Helper function to extract the application-identifier value from the provisioning profile
+ private func extractAppId() -> String? {
+ guard let filePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else {
+ return nil
+ }
+
+ let fileURL = URL(fileURLWithPath: filePath)
+ do {
+ let data = try String(contentsOf: fileURL, encoding: .ascii)
+ let cleared = data.components(separatedBy: .whitespacesAndNewlines).joined()
+
+ // Search for the application-identifier key and extract its value
+ if let range = cleared.range(of: "application-identifier") {
+ let substring = cleared[range.upperBound...]
+ if let endRange = substring.range(of: "") {
+ let appId = String(substring[.. 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("get-task-allow")
- } catch {
- // Handling error if the file read fails
- print("Error reading provisioning profile: \(error)")
- return false
- }
- #endif
- }
- }
-}
diff --git a/apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.ts b/apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.ts
deleted file mode 100644
index 4b82603..0000000
--- a/apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-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");
diff --git a/apps/expo/modules/check-ios-marketplace/expo-module.config.json b/apps/expo/modules/check-ios-marketplace/expo-module.config.json
new file mode 100644
index 0000000..362d5be
--- /dev/null
+++ b/apps/expo/modules/check-ios-marketplace/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["ios"],
+ "ios": {
+ "modules": ["CheckIosMarketplaceModule"]
+ }
+}
diff --git a/apps/expo/modules/check-ios-marketplace/index.ts b/apps/expo/modules/check-ios-marketplace/index.ts
new file mode 100644
index 0000000..c6dd3e1
--- /dev/null
+++ b/apps/expo/modules/check-ios-marketplace/index.ts
@@ -0,0 +1,6 @@
+import type { MarketplaceSource } from "./src/CheckIosMarketplaceModule";
+import CheckIosMarketplaceModule from "./src/CheckIosMarketplaceModule";
+
+export async function getCurrentMarketplaceAsync(): Promise {
+ return CheckIosMarketplaceModule.getCurrentMarketplaceAsync();
+}
diff --git a/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplace.podspec b/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplace.podspec
new file mode 100644
index 0000000..8cce772
--- /dev/null
+++ b/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplace.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+ s.name = 'CheckIosMarketplace'
+ s.version = '1.0.0'
+ s.summary = 'Get current iOS Marketplace.'
+ s.description = 'Get current iOS Marketplace.'
+ 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
diff --git a/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplaceModule.swift b/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplaceModule.swift
new file mode 100644
index 0000000..85bb9ad
--- /dev/null
+++ b/apps/expo/modules/check-ios-marketplace/ios/CheckIosMarketplaceModule.swift
@@ -0,0 +1,39 @@
+import ExpoModulesCore
+
+#if canImport(MarketplaceKit)
+import MarketplaceKit
+#endif
+
+public class CheckIosMarketplaceModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("CheckIosMarketplace")
+
+ AsyncFunction("getCurrentMarketplaceAsync") { () -> Any in
+ #if canImport(MarketplaceKit)
+ if #available(iOS 17.4, *) {
+ do {
+ let currentDistributor = try await AppDistributor.current
+ switch currentDistributor {
+ case .appStore:
+ return "App Store"
+ case .testFlight:
+ return "TestFlight"
+ case .marketplace:
+ return "Alternative marketplace"
+ case .other:
+ return "Other"
+ @unknown default:
+ return "Unknown"
+ }
+ } catch {
+ return "Error"
+ }
+ } else {
+ return "Unavailable"
+ }
+ #else
+ return "Unavailable"
+ #endif
+ }
+ }
+}
diff --git a/apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.android.ts b/apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.android.ts
similarity index 52%
rename from apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.android.ts
rename to apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.android.ts
index fa041d2..35d00a2 100644
--- a/apps/expo/modules/check-ios-certificate/src/CheckIosCertificateModule.android.ts
+++ b/apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.android.ts
@@ -1,10 +1,10 @@
import { UnavailabilityError } from "expo-modules-core";
export default {
- isDevelopmentProvisioningProfile: () => {
+ getCurrentMarketplaceAsync: () => {
throw new UnavailabilityError(
- "CheckIosCertificate",
- "isDevelopmentProvisioningProfile",
+ "CheckIosMarketplace",
+ "getCurrentMarketplaceAsync",
);
},
};
diff --git a/apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.ts b/apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.ts
new file mode 100644
index 0000000..9f1906f
--- /dev/null
+++ b/apps/expo/modules/check-ios-marketplace/src/CheckIosMarketplaceModule.ts
@@ -0,0 +1,23 @@
+import { requireNativeModule } from "expo-modules-core";
+
+export enum MarketplaceSource {
+ AppStore = "App Store",
+ TestFlight = "TestFlight",
+ Marketplace = "Alternative marketplace",
+ Web = "Website",
+ Other = "Other",
+ Unknown = "Unknown",
+ Error = "Error",
+ Unavailable = "Unavailable",
+}
+
+interface CheckIosMarketplaceModule {
+ getCurrentMarketplaceAsync(): Promise;
+}
+
+// It loads the native module object from the JSI or falls back to
+// the bridge module (from NativeModulesProxy) if the remote debugger is on.
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
+export default requireNativeModule(
+ "CheckIosMarketplace",
+) as CheckIosMarketplaceModule;
diff --git a/apps/expo/package.json b/apps/expo/package.json
index 3a1a044..51394a5 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -11,88 +11,91 @@
"android": "expo run:android",
"ios": "expo run:ios",
"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": "expo prebuild --platform=ios && cd ios && xcodebuild 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"
},
+ "overrides": {
+ "browserify-sign": "4.2.2"
+ },
"dependencies": {
- "@expo/metro-config": "^0.17.3",
+ "@expo/metro-config": "~0.19.1",
"@movie-web/api": "workspace:*",
"@movie-web/colors": "workspace:*",
"@movie-web/provider-utils": "workspace:*",
"@movie-web/tmdb": "workspace:*",
"@octokit/rest": "^20.0.2",
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
- "@react-navigation/native": "^6.1.9",
+ "@react-navigation/native": "^7.0.0",
"@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",
+ "@tamagui/animations-moti": "1.116.15",
+ "@tamagui/babel-plugin": "1.116.15",
+ "@tamagui/config": "1.116.15",
+ "@tamagui/metro-plugin": "1.116.15",
+ "@tamagui/toast": "1.116.15",
+ "@tanstack/react-query": "^5.51.23",
+ "ajv": "^8.17.1",
"burnt": "^0.12.2",
"class-variance-authority": "^0.7.0",
- "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-clipboard": "^5.0.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-media-library": "~15.9.1",
- "expo-navigation-bar": "^2.8.1",
- "expo-network": "~5.8.0",
+ "expo": "~52.0.7",
+ "expo-alternate-app-icons": "^1.1.0",
+ "expo-application": "~6.0.1",
+ "expo-audio": "~0.3.0",
+ "expo-brightness": "~13.0.2",
+ "expo-build-properties": "~0.13.1",
+ "expo-clipboard": "~7.0.0",
+ "expo-constants": "~17.0.3",
+ "expo-file-system": "~18.0.3",
+ "expo-haptics": "~14.0.0",
+ "expo-keep-awake": "~14.0.1",
+ "expo-linear-gradient": "~14.0.1",
+ "expo-linking": "~7.0.2",
+ "expo-media-library": "~17.0.2",
+ "expo-navigation-bar": "~4.0.3",
+ "expo-network": "~7.0.0",
"expo-pod-pinner": "^1.0.1",
- "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",
+ "expo-router": "~4.0.6",
+ "expo-screen-orientation": "~8.0.0",
+ "expo-splash-screen": "~0.29.11",
+ "expo-status-bar": "~2.0.0",
+ "expo-system-ui": "~4.0.3",
+ "expo-video": "~2.0.0",
+ "expo-web-browser": "~14.0.1",
"ffmpeg-kit-react-native": "^6.0.2",
- "immer": "^10.0.3",
+ "immer": "^10.1.1",
"iso-639-1": "^3.1.2",
- "react": "18.2.0",
- "react-dom": "18.2.0",
- "react-native": "0.73.6",
- "react-native-context-menu-view": "^1.14.1",
- "react-native-gesture-handler": "~2.14.1",
+ "react": "~18.3.1",
+ "react-dom": "~18.3.1",
+ "react-native": "0.76.2",
+ "react-native-gesture-handler": "~2.20.2",
"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",
+ "react-native-mmkv": "^3.1.0",
+ "react-native-quick-crypto": "^0.7.6",
+ "react-native-reanimated": "~3.16.1",
+ "react-native-safe-area-context": "4.12.0",
+ "react-native-screens": "^4.1.0",
+ "react-native-svg": "15.8.0",
+ "react-native-web": "^0.19.13",
"subsrt-ts": "^2.1.2",
- "tamagui": "^1.94.0",
+ "tamagui": "1.116.15",
"text-encoding-polyfill": "^0.6.7",
- "zustand": "^4.4.7"
+ "zustand": "^4.5.4"
},
"devDependencies": {
- "@babel/core": "^7.23.9",
- "@babel/preset-env": "^7.23.9",
- "@babel/runtime": "^7.23.9",
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.3",
+ "@babel/runtime": "^7.25.0",
"@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "workspace:^0.1.0",
- "@tanstack/eslint-plugin-query": "^5.20.1",
+ "@tanstack/eslint-plugin-query": "^5.51.15",
"@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",
+ "@types/react": "~18.3.12",
+ "babel-plugin-module-resolver": "^5.0.2",
+ "eslint": "^8.57.0",
+ "prettier": "^3.2.5",
"typescript": "^5.4.3"
},
"eslintConfig": {
diff --git a/apps/expo/src/app/(tabs)/downloads.tsx b/apps/expo/src/app/(tabs)/downloads.tsx
index cc0997b..4be65bc 100644
--- a/apps/expo/src/app/(tabs)/downloads.tsx
+++ b/apps/expo/src/app/(tabs)/downloads.tsx
@@ -2,7 +2,7 @@ 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 { getAppId, isIncorrectAppId } from "modules/check-ios-app-id";
import { ScrollView, useTheme, YStack } from "tamagui";
import type { ScrapeMedia } from "@movie-web/provider-utils";
@@ -81,10 +81,11 @@ const DownloadsScreen: React.FC = () => {
useFocusEffect(
React.useCallback(() => {
- if (Platform.OS === "ios" && !isDevelopmentProvisioningProfile()) {
+ if (Platform.OS === "ios" && isIncorrectAppId()) {
+ const appId = getAppId();
Alert.alert(
- "Production Certificate",
- "Download functionality is not available when the application is signed with a distribution certificate.",
+ "Wildcard/Mismatching App ID",
+ `The application is signed with a wildcard or mismatching App ID (${appId}). Download functionality is not available when the application is signed with a wildcard or mismatching App ID.`,
[
{
text: "OK",
@@ -148,14 +149,10 @@ const DownloadsScreen: React.FC = () => {
onPress={() => handlePress(download.downloads[0]!.localPath)}
/>
);
- } else {
- return (
-
- );
}
+ return (
+
+ );
})}
diff --git a/apps/expo/src/app/(tabs)/search.tsx b/apps/expo/src/app/(tabs)/search.tsx
index 0f77f5f..aaf979f 100644
--- a/apps/expo/src/app/(tabs)/search.tsx
+++ b/apps/expo/src/app/(tabs)/search.tsx
@@ -105,24 +105,26 @@ export default function SearchScreen() {
scrollEnabled={searchResultsLoaded ? true : false}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
- contentContainerStyle={{ flexGrow: 1 }}
+ contentContainerStyle={{
+ flexGrow: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ }}
>
-
-
-
- {data?.map((item, index) => (
-
-
-
- ))}
-
-
-
+
+
+ {data?.map((item, index) => (
+
+
+
+ ))}
+
+
s.setTheme);
return (
-
-
}
>
-
+
-
+
);
}
diff --git a/apps/expo/src/app/_layout.tsx b/apps/expo/src/app/_layout.tsx
index a8cd809..3c2c7a4 100644
--- a/apps/expo/src/app/_layout.tsx
+++ b/apps/expo/src/app/_layout.tsx
@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import {
+ configureReanimatedLogger,
+ ReanimatedLogLevel,
+} from "react-native-reanimated";
import { useFonts } from "expo-font";
import { SplashScreen, Stack } from "expo-router";
import FontAwesome from "@expo/vector-icons/FontAwesome";
@@ -11,8 +15,6 @@ import { TamaguiProvider, Theme, useTheme } from "tamagui";
import tamaguiConfig from "tamagui.config";
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 +31,11 @@ SplashScreen.preventAutoHideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */
});
+configureReanimatedLogger({
+ level: ReanimatedLogLevel.warn,
+ strict: false,
+});
+
const queryClient = new QueryClient();
export default function RootLayout() {
diff --git a/apps/expo/src/app/sync/trust/[backendUrl].tsx b/apps/expo/src/app/sync/trust/[backendUrl].tsx
index 35fcda8..42a1ec1 100644
--- a/apps/expo/src/app/sync/trust/[backendUrl].tsx
+++ b/apps/expo/src/app/sync/trust/[backendUrl].tsx
@@ -108,7 +108,7 @@ export default function Page() {
state.resetVideo);
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
const { presentFullscreenPlayer } = usePlayer();
@@ -31,6 +34,13 @@ export default function VideoPlayerWrapper() {
void presentFullscreenPlayer();
+ useEffect(() => {
+ BackHandler.addEventListener("hardwareBackPress", () => {
+ resetVideo();
+ return false;
+ });
+ }, [resetVideo]);
+
if (download) {
return ;
}
diff --git a/apps/expo/src/components/DownloadItem.tsx b/apps/expo/src/components/DownloadItem.tsx
index b6e3006..db3c5b8 100644
--- a/apps/expo/src/components/DownloadItem.tsx
+++ b/apps/expo/src/components/DownloadItem.tsx
@@ -1,13 +1,13 @@
-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 React, { useState } from "react";
import { TouchableOpacity } from "react-native-gesture-handler";
+import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import { Image, Text, View, XStack, YStack } from "tamagui";
+import type { Action } from "./item/ContextMenu";
import type { Download, DownloadContent } from "~/hooks/useDownloadManager";
import { useDownloadManager } from "~/hooks/useDownloadManager";
+import { ContextMenuActions, SheetContextMenu } from "./item/ContextMenu";
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
import { MWProgress } from "./ui/Progress";
import { FlashingText } from "./ui/Text";
@@ -17,11 +17,6 @@ export interface DownloadItemProps {
onPress: (localPath?: string) => void;
}
-enum ContextMenuActions {
- Cancel = "Cancel",
- Remove = "Remove",
-}
-
const statusToTextMap: Record = {
downloading: "Downloading",
finished: "Finished",
@@ -41,30 +36,32 @@ const formatBytes = (bytes: number, decimals = 2) => {
};
export function DownloadItem(props: DownloadItemProps) {
- const percentage = props.item.progress * 100;
+ const percentage = Math.round(props.item.progress * 100);
const formattedFileSize = formatBytes(props.item.fileSize);
const formattedDownloaded = formatBytes(props.item.downloaded);
const { removeDownload, cancelDownload } = useDownloadManager();
+ const [menuOpen, setMenuOpen] = useState(false);
- const contextMenuActions = [
+ const handleLongPress = () => {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
+ setMenuOpen(true);
+ };
+
+ const contextMenuActions: Action[] = [
{
title: ContextMenuActions.Remove,
+ onPress: () => removeDownload(props.item),
},
...(props.item.status !== "finished"
- ? [{ title: ContextMenuActions.Cancel }]
+ ? [
+ {
+ title: ContextMenuActions.Cancel,
+ onPress: () => cancelDownload(props.item),
+ },
+ ]
: []),
];
- const onContextMenuPress = (
- e: NativeSyntheticEvent,
- ) => {
- 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" ||
@@ -72,80 +69,77 @@ export function DownloadItem(props: DownloadItemProps) {
);
return (
- props.onPress(props.item.localPath)}
+ onLongPress={handleLongPress}
+ activeOpacity={0.7}
>
- props.onPress(props.item.localPath)}
- onLongPress={() => {
- return;
- }}
- activeOpacity={0.7}
- >
-
-
-
-
-
-
-
- {props.item.media.type === "show" &&
- `${mapSeasonAndEpisodeNumberToText(
- props.item.media.season.number,
- props.item.media.episode.number,
- )} `}
- {props.item.media.title}
-
- {props.item.type !== "hls" && (
-
- {props.item.speed.toFixed(2)} MB/s
-
- )}
-
-
-
-
-
+
+
+
+
+
+
+
+ {props.item.media.type === "show" &&
+ `${mapSeasonAndEpisodeNumberToText(
+ props.item.media.season.number,
+ props.item.media.episode.number,
+ )} `}
+ {props.item.media.title}
+
+ {props.item.type !== "hls" && (
- {props.item.type === "hls"
- ? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
- : `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
+ {props.item.speed.toFixed(2)} MB/s
-
-
- {statusToTextMap[props.item.status]}
-
-
-
-
-
-
-
+ )}
+
+
+
+
+
+
+ {props.item.type === "hls"
+ ? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
+ : `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
+
+
+
+ {statusToTextMap[props.item.status]}
+
+
+
+
+
+ setMenuOpen(false)}
+ />
+
);
}
diff --git a/apps/expo/src/components/item/ContextMenu.tsx b/apps/expo/src/components/item/ContextMenu.tsx
new file mode 100644
index 0000000..9152505
--- /dev/null
+++ b/apps/expo/src/components/item/ContextMenu.tsx
@@ -0,0 +1,94 @@
+import type { ComponentProps } from "react";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import { Sheet, useTheme } from "tamagui";
+
+import { Settings } from "../player/settings/Sheet";
+
+export enum ContextMenuActions {
+ Bookmark = "Bookmark",
+ RemoveBookmark = "Remove Bookmark",
+ Download = "Download",
+ RemoveWatchHistoryItem = "Remove from Continue Watching",
+ Cancel = "Cancel",
+ Remove = "Remove",
+}
+
+type IconName = ComponentProps["name"];
+
+export interface Action {
+ title: ContextMenuActions;
+ onPress: () => void;
+}
+
+interface SheetContextMenuProps {
+ isOpen: boolean;
+ actions: Action[];
+ onClose: () => void;
+}
+
+export const SheetContextMenu: React.FC = ({
+ isOpen,
+ actions,
+ onClose,
+}) => {
+ const theme = useTheme();
+
+ const iconMap: Record = {
+ [ContextMenuActions.Remove]: "delete-outline",
+ [ContextMenuActions.Bookmark]: "bookmark-outline",
+ [ContextMenuActions.RemoveBookmark]: "bookmark-off-outline",
+ [ContextMenuActions.Download]: "download-outline",
+ [ContextMenuActions.RemoveWatchHistoryItem]: "clock-remove-outline",
+ };
+
+ return (
+
+
+
+
+ {actions.map((action, index) => (
+
+ }
+ onPress={() => {
+ action.onPress();
+ onClose();
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
diff --git a/apps/expo/src/components/item/item.tsx b/apps/expo/src/components/item/item.tsx
index badd332..9f6e6ff 100644
--- a/apps/expo/src/components/item/item.tsx
+++ b/apps/expo/src/components/item/item.tsx
@@ -1,14 +1,14 @@
-import type { NativeSyntheticEvent } from "react-native";
-import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { Keyboard, TouchableOpacity } from "react-native";
-import ContextMenu from "react-native-context-menu-view";
+import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import { Image, Text, View } from "tamagui";
+import type { Action } from "./ContextMenu";
import { useToast } from "~/hooks/useToast";
import { usePlayerStore } from "~/stores/player/store";
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
+import { ContextMenuActions, SheetContextMenu } from "./ContextMenu";
export interface ItemData {
id: string;
@@ -21,13 +21,6 @@ export interface ItemData {
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(),
@@ -69,70 +62,79 @@ export default function Item({ data }: { data: ItemData }) {
});
};
- const contextMenuActions = [
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ const handleLongPress = () => {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
+ setMenuOpen(true);
+ };
+
+ const contextMenuActions: Action[] = [
{
title: isBookmarked(data)
? ContextMenuActions.RemoveBookmark
: ContextMenuActions.Bookmark,
+ onPress: () => {
+ if (isBookmarked(data)) {
+ removeBookmark(data);
+ showToast("Removed from bookmarks", {
+ burntOptions: { preset: "done" },
+ });
+ } else {
+ addBookmark(data);
+ showToast("Added to bookmarks", { burntOptions: { preset: "done" } });
+ }
+ },
},
- ...(type === "movie" ? [{ title: ContextMenuActions.Download }] : []),
+ ...(data.type === "movie"
+ ? [
+ {
+ title: ContextMenuActions.Download,
+ onPress: () => {
+ router.push({
+ pathname: "/videoPlayer",
+ params: { data: JSON.stringify(data), download: "true" },
+ });
+ },
+ },
+ ]
+ : []),
...(hasWatchHistoryItem(data)
- ? [{ title: ContextMenuActions.RemoveWatchHistoryItem }]
+ ? [
+ {
+ title: ContextMenuActions.RemoveWatchHistoryItem,
+ onPress: () => {
+ removeFromWatchHistory(data);
+ showToast("Removed from Continue Watching", {
+ burntOptions: { preset: "done" },
+ });
+ },
+ },
+ ]
: []),
];
- const onContextMenuPress = (
- e: NativeSyntheticEvent,
- ) => {
- 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 (
{}}
+ onLongPress={handleLongPress}
style={{ width: "100%" }}
>
-
-
-
-
-
-
-
+
+
+
+
+
{title}
-
+
{type === "tv" ? "Show" : "Movie"}
@@ -147,6 +149,11 @@ export default function Item({ data }: { data: ItemData }) {
+ setMenuOpen(false)}
+ />
);
}
diff --git a/apps/expo/src/components/layout/Header.tsx b/apps/expo/src/components/layout/Header.tsx
index 07b6efc..8f20f68 100644
--- a/apps/expo/src/components/layout/Header.tsx
+++ b/apps/expo/src/components/layout/Header.tsx
@@ -1,9 +1,5 @@
-import { Linking } from "react-native";
-import * as Haptics from "expo-haptics";
-import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
-import { Circle, View } from "tamagui";
+import { View } from "tamagui";
-import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
import { BrandPill } from "../BrandPill";
export function Header() {
@@ -11,7 +7,7 @@ export function Header() {
-
-
+ */}
);
}
diff --git a/apps/expo/src/components/player/AudioTrackSelector.tsx b/apps/expo/src/components/player/AudioTrackSelector.tsx
index d05dcbd..280b719 100644
--- a/apps/expo/src/components/player/AudioTrackSelector.tsx
+++ b/apps/expo/src/components/player/AudioTrackSelector.tsx
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
+import { createAudioPlayer } from "expo-audio";
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";
@@ -22,14 +22,10 @@ export const AudioTrackSelector = () => {
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();
+ const player = usePlayerStore((state) => state.player);
+ const setAudioPlayer = usePlayerStore((state) => state.setAudioPlayer);
useEffect(() => {
if (tracks && selectedTrack) {
@@ -47,7 +43,7 @@ export const AudioTrackSelector = () => {
}
}, [selectedTrack, setAudioTracks, tracks]);
- if (!tracks?.length) return null;
+ if (!tracks?.length || !player) return null;
return (
<>
@@ -101,10 +97,10 @@ export const AudioTrackSelector = () => {
)
}
onPress={() => {
- setSelectedAudioTrack(track);
- if (stream) {
- void synchronizePlayback(track, stream);
- }
+ const newPlayer = createAudioPlayer(track.uri);
+ newPlayer.seekTo(player.currentTime).catch(console.error);
+ newPlayer.volume = player.volume;
+ setAudioPlayer(newPlayer);
}}
/>
))}
diff --git a/apps/expo/src/components/player/BackButton.tsx b/apps/expo/src/components/player/BackButton.tsx
index 26e3bfd..674e3fc 100644
--- a/apps/expo/src/components/player/BackButton.tsx
+++ b/apps/expo/src/components/player/BackButton.tsx
@@ -3,8 +3,10 @@ import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { usePlayer } from "~/hooks/player/usePlayer";
+import { usePlayerStore } from "~/stores/player/store";
export const BackButton = () => {
+ const resetVideo = usePlayerStore((state) => state.resetVideo);
const { dismissFullscreenPlayer } = usePlayer();
const router = useRouter();
@@ -12,6 +14,7 @@ export const BackButton = () => {
{
+ resetVideo();
dismissFullscreenPlayer()
.then(() => {
if (router.canGoBack()) {
diff --git a/apps/expo/src/components/player/BottomControls.tsx b/apps/expo/src/components/player/BottomControls.tsx
index 7642e49..7ab8f26 100644
--- a/apps/expo/src/components/player/BottomControls.tsx
+++ b/apps/expo/src/components/player/BottomControls.tsx
@@ -1,11 +1,11 @@
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform, TouchableOpacity } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
-import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
+import { isIncorrectAppId } from "modules/check-ios-app-id";
import { Text, View } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
@@ -18,40 +18,34 @@ import { ProgressBar } from "./ProgressBar";
import { SeasonSelector } from "./SeasonEpisodeSelector";
import { SettingsSelector } from "./SettingsSelector";
import { SourceSelector } from "./SourceSelector";
-import { mapMillisecondsToTime } from "./utils";
+import { mapSecondsToTime } from "./utils";
export const BottomControls = () => {
- const status = usePlayerStore((state) => state.status);
+ const player = usePlayerStore((state) => state.player);
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
const [showRemaining, setShowRemaining] = useState(false);
+ const [localDuration, setLocalDuration] = useState(0);
+ const [localCurrentTime, setLocalCurrentTime] = useState(0);
+
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: mapMillisecondsToTime(0),
- remainingTime: mapMillisecondsToTime(0),
- };
- }
- }, [status]);
+ const current = mapSecondsToTime(localCurrentTime);
+ const remaining = `-${mapSecondsToTime(
+ (localDuration ?? 0) - localCurrentTime,
+ )}`;
+ return { currentTime: current, remainingTime: remaining };
+ }, [localCurrentTime, localDuration]);
const durationTime = useMemo(() => {
- return mapMillisecondsToTime(
- status?.isLoaded ? status.durationMillis ?? 0 : 0,
- );
- }, [status]);
+ return mapSecondsToTime(localDuration ?? 0);
+ }, [localDuration]);
const translateY = useSharedValue(128);
@@ -65,6 +59,23 @@ export const BottomControls = () => {
};
});
+ useEffect(() => {
+ if (player?.duration) {
+ setLocalDuration(player.duration);
+ }
+
+ const subscription = player?.addListener("timeUpdate", (payload) => {
+ setLocalCurrentTime(payload.currentTime);
+ if (localDuration === 0) {
+ setLocalDuration(player.duration);
+ }
+ });
+
+ return () => {
+ subscription?.remove();
+ };
+ }, [player, localDuration]);
+
return (
{
{Platform.OS === "android" ||
- (Platform.OS === "ios" &&
- isDevelopmentProvisioningProfile()) ? (
+ (Platform.OS === "ios" && !isIncorrectAppId()) ? (
) : null}
>
diff --git a/apps/expo/src/components/player/CaptionRenderer.tsx b/apps/expo/src/components/player/CaptionRenderer.tsx
index f41b94c..e9bff94 100644
--- a/apps/expo/src/components/player/CaptionRenderer.tsx
+++ b/apps/expo/src/components/player/CaptionRenderer.tsx
@@ -8,7 +8,6 @@ import Animated, {
} 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";
@@ -27,10 +26,10 @@ export const captionIsVisible = (
};
export const CaptionRenderer = () => {
+ const player = usePlayerStore((state) => state.player);
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);
@@ -56,20 +55,12 @@ export const CaptionRenderer = () => {
const visibleCaptions = useMemo(
() =>
selectedCaption?.data.filter(({ start, end }) =>
- captionIsVisible(
- start,
- end,
- delay,
- status?.isLoaded
- ? convertMilliSecondsToSeconds(status.positionMillis)
- : 0,
- ),
+ captionIsVisible(start, end, delay, player ? player.currentTime : 0),
),
- [selectedCaption, delay, status],
+ [selectedCaption, player, delay],
);
- if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length)
- return null;
+ if (!player || !selectedCaption || !visibleCaptions?.length) return null;
return (
cue.type === "caption",
- ) as ContentCaption[],
+ data: parse(data).filter((cue) => cue.type === "caption"),
};
};
diff --git a/apps/expo/src/components/player/PlayButton.tsx b/apps/expo/src/components/player/PlayButton.tsx
index 56aba55..b7ad85d 100644
--- a/apps/expo/src/components/player/PlayButton.tsx
+++ b/apps/expo/src/components/player/PlayButton.tsx
@@ -1,42 +1,36 @@
+import { useEvent } from "expo";
import { FontAwesome } from "@expo/vector-icons";
import { Spinner } from "tamagui";
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);
+ const player = usePlayerStore((state) => state.player);
+ const audioPlayer = usePlayerStore((state) => state.audioPlayer);
- if (
- status?.isLoaded &&
- !status.isPlaying &&
- status.isBuffering &&
- status.positionMillis > status.playableDurationMillis!
- ) {
+ const { isPlaying } = useEvent(player!, "playingChange", {
+ isPlaying: player!.playing,
+ });
+ const { status } = useEvent(player!, "statusChange", {
+ status: player!.status,
+ });
+
+ if (!player) return null;
+
+ if (status === "loading") {
return ;
}
return (
{
- 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();
- }
- }
+ console.log("video player playing", player.playing);
+ console.log("audio player playing", audioPlayer?.playing);
+ player.playing ? player.pause() : player.play();
+ audioPlayer?.playing ? audioPlayer?.pause() : audioPlayer?.play();
}}
/>
);
diff --git a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx
index 955b608..ea9c5fa 100644
--- a/apps/expo/src/components/player/PlaybackSpeedSelector.tsx
+++ b/apps/expo/src/components/player/PlaybackSpeedSelector.tsx
@@ -44,11 +44,8 @@ export const PlaybackSpeedSelector = (props: SheetProps) => {
)
}
onPress={() => {
- changePlaybackSpeed(speed)
- .then(() => props.onOpenChange?.(false))
- .catch((err) => {
- console.log("error", err);
- });
+ changePlaybackSpeed(speed);
+ props.onOpenChange?.(false);
}}
/>
))}
diff --git a/apps/expo/src/components/player/ProgressBar.tsx b/apps/expo/src/components/player/ProgressBar.tsx
index c7fedfe..c98480f 100644
--- a/apps/expo/src/components/player/ProgressBar.tsx
+++ b/apps/expo/src/components/player/ProgressBar.tsx
@@ -5,19 +5,19 @@ 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 player = usePlayerStore((state) => state.player);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const updateProgress = useCallback(
(newProgress: number) => {
- videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => {
- console.error("Error updating progress");
- });
+ if (!player) return;
+ player.currentTime = newProgress;
},
- [videoRef],
+ [player],
);
+ if (!player) return null;
+
return (
{
paddingTop: 24,
}}
onPress={() => setIsIdle(false)}
- disabled={!status?.isLoaded}
+ disabled={player.status !== "readyToPlay"}
>
diff --git a/apps/expo/src/components/player/QualitySelector.tsx b/apps/expo/src/components/player/QualitySelector.tsx
index 5f766a9..8247f08 100644
--- a/apps/expo/src/components/player/QualitySelector.tsx
+++ b/apps/expo/src/components/player/QualitySelector.tsx
@@ -9,12 +9,13 @@ import { Settings } from "./settings/Sheet";
export const QualitySelector = (props: SheetProps) => {
const theme = useTheme();
- const videoRef = usePlayerStore((state) => state.videoRef);
+ const player = usePlayerStore((state) => state.player);
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;
+ if (!player || !videoSrc || typeof videoSrc === "number" || !stream)
+ return null;
let qualityMap: { quality: string; url: string }[];
let currentQuality: string | undefined;
@@ -32,7 +33,19 @@ export const QualitySelector = (props: SheetProps) => {
} else if (stream.type === "hls") {
if (!hlsTracks?.video) return null;
- qualityMap = hlsTracks.video.map((video) => ({
+ const hlsTracksWithoutDuplicatedQualities = hlsTracks.video.filter(
+ (video, index, self) => {
+ return (
+ index ===
+ self.findIndex(
+ (v) =>
+ v.properties[0]?.attributes.resolution ===
+ video.properties[0]?.attributes.resolution,
+ )
+ );
+ },
+ );
+ qualityMap = hlsTracksWithoutDuplicatedQualities.map((video) => ({
quality:
(video.properties[0]?.attributes.resolution as string) ?? "unknown",
url: constructFullUrl(stream.playlist, video.uri),
@@ -77,11 +90,10 @@ export const QualitySelector = (props: SheetProps) => {
)
}
onPress={() => {
- void videoRef.unloadAsync();
- void videoRef.loadAsync(
- { uri: quality.url, headers: stream.headers },
- { shouldPlay: true },
- );
+ player.replace({
+ uri: quality.url,
+ headers: stream.headers,
+ });
}}
/>
))}
diff --git a/apps/expo/src/components/player/ScraperProcess.tsx b/apps/expo/src/components/player/ScraperProcess.tsx
index 2a4bf81..6636a29 100644
--- a/apps/expo/src/components/player/ScraperProcess.tsx
+++ b/apps/expo/src/components/player/ScraperProcess.tsx
@@ -95,6 +95,7 @@ export const ScraperProcess = ({
...streamResult.stream.headers,
},
);
+ console.log("Tracks", tracks);
if (tracks) setHlsTracks(tracks);
diff --git a/apps/expo/src/components/player/SeekButton.tsx b/apps/expo/src/components/player/SeekButton.tsx
index 54be900..ae2b47d 100644
--- a/apps/expo/src/components/player/SeekButton.tsx
+++ b/apps/expo/src/components/player/SeekButton.tsx
@@ -7,11 +7,10 @@ interface SeekProps {
}
export const SeekButton = ({ type }: SeekProps) => {
- const videoRef = usePlayerStore((state) => state.videoRef);
- const status = usePlayerStore((state) => state.status);
- const setAudioPositionAsync = usePlayerStore(
- (state) => state.setAudioPositionAsync,
- );
+ const player = usePlayerStore((state) => state.player);
+ const audioPlayer = usePlayerStore((state) => state.audioPlayer);
+
+ if (!player) return null;
return (
{
size={36}
color="white"
onPress={() => {
- if (status?.isLoaded) {
- const position =
- type === "forward"
- ? status.positionMillis + 10000
- : status.positionMillis - 10000;
+ player.currentTime =
+ type === "forward"
+ ? player.currentTime + 10
+ : player.currentTime - 10;
- videoRef?.setPositionAsync(position).catch(() => {
- console.log("Error seeking backwards");
- });
- void setAudioPositionAsync(position);
- }
+ if (audioPlayer) audioPlayer.currentTime = player.currentTime;
}}
/>
);
diff --git a/apps/expo/src/components/player/VideoPlayer.tsx b/apps/expo/src/components/player/VideoPlayer.tsx
index 35a7db0..ebb41e6 100644
--- a/apps/expo/src/components/player/VideoPlayer.tsx
+++ b/apps/expo/src/components/player/VideoPlayer.tsx
@@ -1,28 +1,27 @@
-import type { AVPlaybackStatus } from "expo-av";
import type { SharedValue } from "react-native-reanimated";
-import { useEffect, useState } from "react";
+import React, { useCallback, useEffect, useMemo, 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 Animated, { runOnJS, useAnimatedStyle } 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 { createAudioPlayer } from "expo-audio";
+import { useKeepAwake } from "expo-keep-awake";
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 { createVideoPlayer, VideoView } from "expo-video";
import { Feather } from "@expo/vector-icons";
import { Spinner, useTheme, View } from "tamagui";
-import { findHLSQuality, findQuality } from "@movie-web/provider-utils";
+import {
+ extractTracksFromHLS,
+ filterAudioTracks,
+ 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 {
@@ -42,6 +41,8 @@ import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay";
export const VideoPlayer = () => {
+ useKeepAwake();
+
const {
brightness,
showBrightnessOverlay,
@@ -50,29 +51,21 @@ export const VideoPlayer = () => {
} = 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 videoSrc = usePlayerStore((state) => state.videoSrc);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
- const setStatus = usePlayerStore((state) => state.setStatus);
- const status = usePlayerStore((state) => state.status);
+ const setVideoPlayer = usePlayerStore((state) => state.setVideoPlayer);
+ const setAudioPlayer = usePlayerStore((state) => state.setAudioPlayer);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
- const toggleAudio = usePlayerStore((state) => state.toggleAudio);
const toggleState = usePlayerStore((state) => state.toggleState);
+ const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
+ const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
@@ -83,19 +76,47 @@ export const VideoPlayer = () => {
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 player = useMemo(() => createVideoPlayer(videoSrc ?? null), [videoSrc]);
+ // const audioPlayer = useMemo(
+ // () => createAudioPlayer(selectedAudioTrack?.uri ?? ""),
+ // [selectedAudioTrack],
+ // );
+ const audioPlayer = usePlayerStore((state) => state.audioPlayer);
+
+ useEffect(() => {
+ if (player) {
+ player.audioMixingMode = "mixWithOthers";
+ player.timeUpdateEventInterval = 1;
+ setVideoPlayer(player);
}
- });
+
+ // if (audioPlayer) {
+ // setAudioPlayer(audioPlayer);
+ // }
+
+ return () => {
+ console.log("releasing players");
+ // player?.release();
+ // audioPlayer?.release();
+ };
+ }, [audioPlayer, player, setAudioPlayer, setVideoPlayer]);
+
+ useEffect(() => {
+ if (meta && player?.status === "readyToPlay" && player.currentTime < 1) {
+ const media = convertMetaToScrapeMedia(meta);
+ const watchHistoryItem = getWatchHistoryItem(media);
+ if (watchHistoryItem) {
+ player.currentTime = watchHistoryItem.positionMillis / 1000;
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [player?.status]);
+
+ const toggleAudio = useCallback(() => {
+ if (audioPlayer) {
+ audioPlayer.playing ? audioPlayer.pause() : audioPlayer.play();
+ }
+ }, [audioPlayer]);
const doubleTapGesture = Gesture.Tap()
.enabled(gestureControls && isIdle)
@@ -142,11 +163,7 @@ export const VideoPlayer = () => {
}
});
- const composedGesture = Gesture.Race(
- panGesture,
- pinchGesture,
- doubleTapGesture,
- );
+ const composedGesture = Gesture.Race(panGesture, doubleTapGesture);
StatusBar.setStatusBarHidden(true);
@@ -154,9 +171,10 @@ export const VideoPlayer = () => {
void NavigationBar.setVisibilityAsync("hidden");
}
+ // TODO: Rerender with player.currentTime on this function call throws an error in expo-video
useEffect(() => {
const initializePlayer = async () => {
- if (videoSrc?.uri && isLocalFile) return;
+ if (isLocalFile) return;
if (!stream) {
await dismissFullscreenPlayer();
@@ -175,6 +193,16 @@ export const VideoPlayer = () => {
if (stream.type === "hls") {
url = await findHLSQuality(stream.playlist, stream.headers, highest);
+ const tracks = await extractTracksFromHLS(stream.playlist, {
+ ...stream.preferredHeaders,
+ ...stream.headers,
+ });
+
+ if (tracks) setHlsTracks(tracks);
+
+ if (tracks?.audio.length) {
+ setAudioTracks(filterAudioTracks(tracks, stream.playlist));
+ }
}
if (stream.type === "file") {
@@ -200,87 +228,63 @@ export const VideoPlayer = () => {
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,
- );
+ updateWatchHistory(item, scrapeMedia, player?.currentTime ?? 0);
}
- clearTimeout(timeout);
- void synchronizePlayback();
};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [
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);
+ useEffect(() => {
+ const playerStatusChange = player?.addListener("statusChange", (data) => {
+ const isFinished = player.duration - player.currentTime < 1;
+ if (
+ meta &&
+ data.status === "idle" &&
+ meta.type === "movie" &&
+ isFinished
+ ) {
+ const item = convertMetaToItemData(meta);
+ removeFromWatchHistory(item);
+ }
- if (watchHistoryItem) {
- void videoRef.setPositionAsync(watchHistoryItem.positionMillis);
- }
+ if (autoPlay && data.status === "idle" && meta?.type === "show") {
+ getNextEpisode(meta)
+ .then((nextEpisodeMeta) => {
+ if (!nextEpisodeMeta) return;
+ setMeta(nextEpisodeMeta);
+ const media = convertMetaToScrapeMedia(nextEpisodeMeta);
+
+ router.replace({
+ pathname: "/videoPlayer",
+ params: { media: JSON.stringify(media) },
+ });
+ })
+ .catch(console.error);
}
- }
- };
+ });
- 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 () => {
+ playerStatusChange?.remove();
+ };
+ }, [player, meta, removeFromWatchHistory, autoPlay, setMeta, router]);
+
+ console.log("videoPlayer", player.playing);
+ console.log("audioPlayer", audioPlayer?.playing);
return (
@@ -291,16 +295,8 @@ export const VideoPlayer = () => {
justifyContent="center"
backgroundColor="black"
>
-