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