Compare commits

..

2 Commits

Author SHA1 Message Date
lance chant
aa0eb0a655 fix: changing pip to use surface view (#1778)
Some checks are pending
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-30 09:58:25 +02:00
Gauvain
286a3cad47 refactor: migrate app.config and Expo config plugins to TypeScript (#1718) 2026-06-30 09:03:47 +02:00
23 changed files with 444 additions and 207 deletions

View File

@@ -11,7 +11,7 @@ on:
push: push:
branches: [develop, master] branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the # Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use # branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions # EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access. # run (artifacts + logs) without needing Expo access.

View File

@@ -1,9 +1,13 @@
const { execFileSync } = require("node:child_process"); // Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via // Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order: // expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. // EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args) => { const git = (args: string[]): string | null => {
try { try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString() .toString()
@@ -42,16 +46,16 @@ const buildMeta = {
builtAt: new Date().toISOString(), builtAt: new Date().toISOString(),
}; };
module.exports = ({ config }) => { export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task"); config.plugins?.push("expo-background-task");
config.plugins.push([ config.plugins?.push([
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
config.plugins.push([ config.plugins?.push([
"expo-camera", "expo-camera",
{ {
cameraPermission: cameraPermission:
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set
const androidConfig = {}; const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) { if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
} }
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
return { return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config, ...config,
}; } as ExpoConfig;
}; };

View File

@@ -71,8 +71,8 @@
], ],
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.js", "./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.js", "./plugins/withTVUserManagement.ts",
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -134,17 +134,17 @@
} }
], ],
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.js"], ["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.js"], ["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.js"], ["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.js"], ["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.js"], ["./plugins/withTVXcodeEnv.ts"],
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.ts",
{ {
"podName": "MPVKit", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -113,6 +113,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
}, },
@@ -294,6 +295,58 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="], "@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
@@ -908,6 +961,8 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1808,6 +1863,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],

99
components/TrackSheet.tsx Normal file
View File

@@ -0,0 +1,99 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
streamType?: string;
title: string;
}
export const TrackSheet: React.FC<Props> = ({
source,
onChange,
selected,
streamType,
title,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType],
);
const selectedSteam = useMemo(
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => {
const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index);
}
}}
/>
</View>
);
};

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files: Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) - [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) - [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) - [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) - [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.SurfaceHolder
import android.view.View import android.view.SurfaceView
import android.view.ViewGroup import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
@@ -30,15 +28,26 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null, val cacheEnabled: String? = null,
val cacheSeconds: Int? = null, val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null, val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null val demuxerMaxBackBytes: Int? = null,
) )
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * MpvPlayerView - ExpoView that hosts the MPV player.
* Uses TextureView for reliable Picture-in-Picture support. *
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/ */
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
@@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null private var pipController: PiPController? = null
@@ -63,31 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking // PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper()) private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support) // SurfaceView for video rendering. Routes the surface directly to
textureView = TextureView(context).apply { // SurfaceFlinger (the OS compositor), giving mpv a standalone
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
surfaceTextureListener = this@MpvPlayerView
} }
addView(textureView) surfaceView.holder.addCallback(this@MpvPlayerView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView) pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) { override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) { if (isInPiP) {
if (!isWaitingForPiPTransition) { // Post size syncs after the PiP layout settles. Two passes
isWaitingForPiPTransition = true // catch both the immediate surface re-attach and the
pipHandler.removeCallbacksAndMessages(null) // post-animation layout pass. Replaces the old TextureView
for (delay in longArrayOf(500, 1000, 1500, 2000)) { // measure/layout polling hack (forcePiPBufferSize).
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP() pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
} else {
// Restore from PiP: surface resized back to fullscreen.
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
} }
onPictureInPictureChange(mapOf("isActive" to isInPiP)) onPictureInPictureChange(mapOf("isActive" to isInPiP))
} }
@@ -126,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/** /**
* Start the renderer with the given VO driver. * Start the renderer with the given VO driver.
* Called lazily on first loadVideo so the voDriver setting is available. * Called lazily on first loadVideo so user settings are available.
*/ */
private fun ensureRendererStarted(voDriver: String?) { private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return if (rendererStarted) return
@@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
pendingSurface?.let { surface -> // If the surface is already alive (surfaceCreated fired before
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null syncSurfaceSizeToView()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -146,23 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
// MARK: - TextureView.SurfaceTextureListener // MARK: - SurfaceHolder.Callback
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { override fun surfaceCreated(holder: SurfaceHolder) {
this.surfaceTexture = surfaceTexture val surface = holder.surface
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
// Release the previous wrapper Surface before losing the only // The previous Surface reference is holder-owned; do NOT release
// reference to it. cleanup() only runs on detach, so without this // it (SurfaceView manages its lifecycle). Just track the new one.
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
} else { // Push the actual view dimensions immediately so mpv doesn't
pendingSurface = surface // render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
} }
// If we have a pending load, execute it now // If we have a pending load, execute it now
@@ -173,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height) if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
}
} }
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { override fun surfaceDestroyed(holder: SurfaceHolder) {
this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() renderer?.detachSurface()
return false // mpv manages the SurfaceTexture // Do NOT issue mpv "stop" here. Playback continues against the
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
} }
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { /**
// Called every frame — no action needed, mpv drives rendering directly * Read the actual SurfaceView width/height and push them to mpv.
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
} }
// MARK: - Video Loading // MARK: - Video Loading
@@ -275,7 +315,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Reset view-level state so a subsequent loadVideo() on the SAME view // Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live // instance re-creates the mpv handle and re-attaches the still-live
// TextureView surface. Without this, rendererStarted stays true and // SurfaceView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never // ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle. // called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load() // The next loadVideo() then runs loadVideoInternal() -> renderer.load()
@@ -286,13 +326,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// which call destroy() immediately before router.replace() to the // which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance, // same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount. // so the next source load happens on this view without a remount.
//
// SurfaceView note: the surface is owned by the holder and survives
// across destroy()/loadVideo() on the same view instance. The next
// ensureRendererStarted() reads it from surfaceView.holder.surface.
rendererStarted = false rendererStarted = false
currentUrl = null currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null activeSurface = null
} }
@@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture() pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
} }
fun stopPictureInPicture() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path. * off the JS path.
*/ */
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
renderer?.delegate = null renderer?.delegate = null
// Release the Surface that wraps the SurfaceTexture. These Surface // SurfaceView owns the Surface via its holder — do NOT release it.
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
activeSurface = null activeSurface = null
surfaceReady = false surfaceReady = false
currentUrl = null currentUrl = null

View File

@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0 private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
fun stopPictureInPicture() { fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false isInPiPMode = false
pipEntryNotified = false pipEntryNotified = false
unregisterLifecycleCallbacks() unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() val activity = getActivity() ?: return
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false) // Push minimal params with just auto-enter disabled. Do NOT call
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
} }
} }
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
} }
fun isCurrentlyInPiP(): Boolean = isInPiPMode fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) { if (rate > 0) {
registerLifecycleCallbacks() registerLifecycleCallbacks()
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions()) builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0) builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
} }
return builder.build() return builder.build()

View File

@@ -136,6 +136,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },
"expo": { "expo": {

View File

@@ -1,4 +1,4 @@
const { withPodfile } = require("expo/config-plugins"); import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const PATCH_START = "## >>> runtime-framework headers"; const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers"; const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`, `\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
]; ];
function buildPatch() { function buildPatch(): string {
return [ return [
PATCH_START, PATCH_START,
" extra_hdrs = [", " extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch() {
].join("\n"); ].join("\n");
} }
module.exports = function withRuntimeFrameworkHeaders(config) { const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
return withPodfile(config, (config) => { return withPodfile(config, (config) => {
let podfile = config.modResults.contents; let podfile = config.modResults.contents;
@@ -125,3 +125,5 @@ end
return config; return config;
}); });
}; };
export default withRuntimeFrameworkHeaders;

View File

@@ -1,10 +1,20 @@
const { import {
type ConfigPlugin,
withAndroidColors, withAndroidColors,
withAndroidColorsNight, withAndroidColorsNight,
} = require("expo/config-plugins"); } from "expo/config-plugins";
const withAndroidAlertColors = (config) => { interface ColorResourceItem {
const setColor = (colorsList, name, value) => { $: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const existingColor = colorsList.find( const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name, (item) => item.$ && item.$.name === name,
); );
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColors(config, (config) => { config = withAndroidColors(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = colors.resources.color || []; const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#000000"); setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
config = withAndroidColorsNight(config, (config) => { config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = colors.resources.color || []; const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
setColor(colorsList, "colorPrimary", "#FFFFFF"); setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
return config; return config;
}; };
module.exports = withAndroidAlertColors; export default withAndroidAlertColors;

View File

@@ -1,8 +1,12 @@
const { withAndroidManifest } = require("expo/config-plugins"); import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const _withGoogleCastAndroidManifest = (config) => const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
withAndroidManifest(config, async (mod) => { withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application[0]; const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
// Initialize activity array if it doesn't exist // Initialize activity array if it doesn't exist
if (!mainApplication.activity) { if (!mainApplication.activity) {
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
return mod; return mod;
}); });
module.exports = _withGoogleCastAndroidManifest; export default withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
const { readFileSync, writeFileSync } = require("node:fs"); import { readFileSync, writeFileSync } from "node:fs";
const { join } = require("node:path"); import { join } from "node:path";
const { withDangerousMod } = require("expo/config-plugins"); import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const withChangeNativeAndroidTextToWhite = (expoConfig) => const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
withDangerousMod(expoConfig, [ withDangerousMod(expoConfig, [
"android", "android",
(modConfig) => { (modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
}, },
]); ]);
module.exports = withChangeNativeAndroidTextToWhite; export default withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
const { withAppBuildGradle } = require("expo/config-plugins"); import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
module.exports = function withExcludeMedia3Dash(config) { const withExcludeMedia3Dash: ConfigPlugin = (config) => {
return withAppBuildGradle(config, (config) => { return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents; const contents = config.modResults.contents;
@@ -32,3 +32,5 @@ configurations.all {
return config; return config;
}); });
}; };
export default withExcludeMedia3Dash;

View File

@@ -1,6 +1,14 @@
const { withPodfile } = require("@expo/config-plugins"); import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const withGitPod = (config, { podName, podspecUrl }) => { interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
return withPodfile(config, (config) => { return withPodfile(config, (config) => {
const podfile = config.modResults.contents; const podfile = config.modResults.contents;
@@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => {
}); });
}; };
module.exports = withGitPod; export default withGitPod;

View File

@@ -1,12 +1,21 @@
const { withGradleProperties } = require("expo/config-plugins"); import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
function setGradlePropertiesValue(config, key, value) { function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
return withGradleProperties(config, (exportedConfig) => { return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults; const props = exportedConfig.modResults;
const keyIdx = props.findIndex( const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key, (item) => item.type === "property" && item.key === key,
); );
const property = { const property: AndroidConfig.Properties.PropertiesItem = {
type: "property", type: "property",
key, key,
value, value,
@@ -22,7 +31,7 @@ function setGradlePropertiesValue(config, key, value) {
}); });
} }
module.exports = function withCustomPlugin(config) { const withCustomGradleProperties: ConfigPlugin = (config) => {
// Expo 52 is not setting this // Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558 // https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -38,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
); );
return config; return config;
}; };
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
const { withXcodeProject } = require("@expo/config-plugins"); import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const withTVOSAppIcon = (config) => { const withTVOSAppIcon: ConfigPlugin = (config) => {
// Only apply for TV builds // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => {
}); });
}; };
module.exports = withTVOSAppIcon; export default withTVOSAppIcon;

View File

@@ -1,8 +1,10 @@
const { import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
withEntitlementsPlist, withEntitlementsPlist,
withInfoPlist, withInfoPlist,
withXcodeProject, withXcodeProject,
} = require("@expo/config-plugins"); } from "expo/config-plugins";
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf"; const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
@@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY = const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier"; "StreamyfinKeychainAccessGroupIdentifier";
function getBundleIdentifier(config) { interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin"; return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
} }
function getAppGroupIdentifier(config) { function getAppGroupIdentifier(config: ExpoConfig): string {
return `group.${getBundleIdentifier(config)}.tvtopshelf`; return `group.${getBundleIdentifier(config)}.tvtopshelf`;
} }
function getKeychainAccessGroupIdentifier(config) { function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
} }
function getBuildConfigurations(project, configurationListId) { // The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
const configurationList = const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId]; project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
const buildConfigurations = project.pbxXCBuildConfigurationSection(); const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations return configurationList.buildConfigurations
.map((config) => buildConfigurations[config.value]) .map((config: { value: string }) => buildConfigurations[config.value])
.filter(Boolean); .filter(Boolean);
} }
function ensureAppGroup(value, appGroupIdentifier) { function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier) return groups.includes(appGroupIdentifier)
? groups ? groups
: [...groups, appGroupIdentifier]; : [...groups, appGroupIdentifier];
} }
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier) return groups.includes(keychainAccessGroupIdentifier)
? groups ? groups
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
} }
function ensureAppExtension( function ensureAppExtension(
appExtensions, appExtensions: unknown,
targetName, targetName: string,
bundleIdentifier, bundleIdentifier: string,
appGroupIdentifier, appGroupIdentifier: string,
keychainAccessGroupIdentifier, keychainAccessGroupIdentifier: string,
) { ): AppExtensionConfig[] {
const extensionConfig = { const extensionConfig: AppExtensionConfig = {
targetName, targetName,
bundleIdentifier, bundleIdentifier,
entitlements: { entitlements: {
@@ -63,7 +78,9 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier], "keychain-access-groups": [keychainAccessGroupIdentifier],
}, },
}; };
const extensions = Array.isArray(appExtensions) ? appExtensions : []; const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
// Keep plugin runs idempotent and preserve unrelated app extension entries. // Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex( const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName, (appExtension) => appExtension?.targetName === targetName,
@@ -78,7 +95,7 @@ function ensureAppExtension(
); );
} }
const withTVOSTopShelf = (config) => { const withTVOSTopShelf: ConfigPlugin = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config); const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier = const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config); getKeychainAccessGroupIdentifier(config);
@@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => {
}); });
}; };
module.exports = withTVOSTopShelf; export default withTVOSTopShelf;

View File

@@ -1,9 +1,9 @@
const { withEntitlementsPlist } = require("expo/config-plugins"); import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
/** /**
* Expo config plugin to add User Management entitlement for tvOS profile linking * Expo config plugin to add User Management entitlement for tvOS profile linking
*/ */
const withTVUserManagement = (config) => { const withTVUserManagement: ConfigPlugin = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must // Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds // be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in // breaks signing ("Entitlement ... not found and could not be included in
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
}); });
}; };
module.exports = withTVUserManagement; export default withTVUserManagement;

View File

@@ -1,7 +1,7 @@
const { withDangerousMod } = require("@expo/config-plugins"); import { execSync } from "node:child_process";
const { execSync } = require("node:child_process"); import fs from "node:fs";
const fs = require("node:fs"); import path from "node:path";
const path = require("node:path"); import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
/** /**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds. * Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
@@ -12,7 +12,7 @@ const path = require("node:path");
* *
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions. * It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/ */
const withTVXcodeEnv = (config) => { const withTVXcodeEnv: ConfigPlugin = (config) => {
// Only apply for TV builds // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
/** /**
* Get the actual node binary path, handling nvm installations. * Get the actual node binary path, handling nvm installations.
*/ */
function getNodeBinaryPath() { function getNodeBinaryPath(): string | null {
try { try {
// First try to get node path directly (works for non-nvm installs) // First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", { const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
return null; return null;
} }
module.exports = withTVXcodeEnv; export default withTVXcodeEnv;

View File

@@ -1,18 +1,29 @@
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins"); import fs from "node:fs";
const path = require("node:path"); import path from "node:path";
const fs = require("node:fs"); import {
AndroidConfig,
type ConfigPlugin,
type ExportedConfigWithProps,
withAndroidManifest,
} from "expo/config-plugins";
const fsPromises = fs.promises; const fsPromises = fs.promises;
const { getMainApplicationOrThrow } = AndroidConfig.Manifest; const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
const withTrustLocalCerts = (config) => { type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
const withTrustLocalCerts: ConfigPlugin = (config) => {
return withAndroidManifest(config, async (mod) => { return withAndroidManifest(config, async (mod) => {
mod.modResults = await setCustomConfigAsync(mod, mod.modResults); mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
return mod; return mod;
}); });
}; };
async function setCustomConfigAsync(config, androidManifest) { async function setCustomConfigAsync(
config: ExportedConfigWithProps<AndroidManifest>,
androidManifest: AndroidManifest,
): Promise<AndroidManifest> {
const src_file_path = path.join(__dirname, "network_security_config.xml"); const src_file_path = path.join(__dirname, "network_security_config.xml");
const res_file_path = path.join( const res_file_path = path.join(
await AndroidConfig.Paths.getResourceFolderAsync( await AndroidConfig.Paths.getResourceFolderAsync(
@@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) {
return androidManifest; return androidManifest;
} }
module.exports = withTrustLocalCerts; export default withTrustLocalCerts;

View File

@@ -484,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No results", "no_results": "No results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",

View File

@@ -5,7 +5,7 @@ import Constants from "expo-constants";
* clientInfo auto-tracks the app version instead of a hardcoded string. */ * clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown"; export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */ /** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
export interface BuildMeta { export interface BuildMeta {
commit?: string | null; commit?: string | null;
branch?: string | null; branch?: string | null;