From 97b6a912e0dc27865a6400c5b4f6de35ee56c578 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Mon, 29 Jun 2026 19:48:32 +0200 Subject: [PATCH 1/3] refactor: migrate JS/MJS sources and scripts to TypeScript (#1717) --- .github/copilot-instructions.md | 2 +- .github/workflows/detect-duplicate.yml | 2 +- .gitignore | 4 - CLAUDE.md | 2 +- constants/{MediaTypes.js => MediaTypes.ts} | 8 +- index.js => index.ts | 0 package.json | 6 +- ...check-i18n-keys.mjs => check-i18n-keys.ts} | 79 +++++++++++-------- ...te-issue.mjs => detect-duplicate-issue.ts} | 24 +++--- scripts/symlink-native-dirs.js | 62 --------------- scripts/{typecheck.js => typecheck.ts} | 53 ++++++++----- utils/profiles/{download.js => download.ts} | 19 +++-- utils/profiles/native.ts | 3 +- utils/profiles/{subtitles.js => subtitles.ts} | 11 +-- utils/profiles/trackplayer.d.ts | 19 ----- .../{trackplayer.js => trackplayer.ts} | 33 ++++---- 16 files changed, 142 insertions(+), 185 deletions(-) rename constants/{MediaTypes.js => MediaTypes.ts} (67%) rename index.js => index.ts (100%) rename scripts/{check-i18n-keys.mjs => check-i18n-keys.ts} (80%) rename scripts/{detect-duplicate-issue.mjs => detect-duplicate-issue.ts} (93%) delete mode 100644 scripts/symlink-native-dirs.js rename scripts/{typecheck.js => typecheck.ts} (81%) rename utils/profiles/{download.js => download.ts} (86%) rename utils/profiles/{subtitles.js => subtitles.ts} (81%) delete mode 100644 utils/profiles/trackplayer.d.ts rename utils/profiles/{trackplayer.js => trackplayer.ts} (77%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2111dd2e..d3b750c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s ## Coding Standards -- Use TypeScript for ALL files (no .js files) +- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript) - Use descriptive English names for variables, functions, and components - Prefer functional React components with hooks - Use Jotai atoms for global state management diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml index ebf515d7..cab53d61 100644 --- a/.github/workflows/detect-duplicate.yml +++ b/.github/workflows/detect-duplicate.yml @@ -30,7 +30,7 @@ jobs: bun-version: "1.3.14" - name: 🔍 Detect duplicate issues - run: bun scripts/detect-duplicate-issue.mjs + run: bun scripts/detect-duplicate-issue.ts env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.gitignore b/.gitignore index d46c8a6f..92bdf316 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,6 @@ web-build/ # Platform-specific Build Directories /ios /android -/iostv -/iosmobile -/androidmobile -/androidtv # Gradle caches (top-level + per-module native projects) **/.gradle/ diff --git a/CLAUDE.md b/CLAUDE.md index eb2ae87e..6b3ef5e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; ## Coding Standards -- Use TypeScript for all files (no .js) +- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript) - Use functional React components with hooks - Use Jotai atoms for global state, React Query for server state - Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) diff --git a/constants/MediaTypes.js b/constants/MediaTypes.ts similarity index 67% rename from constants/MediaTypes.js rename to constants/MediaTypes.ts index 2cf275cc..77d08834 100644 --- a/constants/MediaTypes.js +++ b/constants/MediaTypes.ts @@ -3,9 +3,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -export default { +const MediaTypes = { Audio: "Audio", Video: "Video", Photo: "Photo", Book: "Book", -}; +} as const; + +export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes]; + +export default MediaTypes; diff --git a/index.js b/index.ts similarity index 100% rename from index.js rename to index.ts diff --git a/package.json b/package.json index 4e3ecd38..6cdba17b 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,13 @@ "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production", "prepare": "husky", - "typecheck": "node scripts/typecheck.js", + "typecheck": "bun scripts/typecheck.ts", "check": "biome check . --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000", "format": "biome format --write .", "doctor": "expo-doctor", - "i18n:check": "bun scripts/check-i18n-keys.mjs", - "i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", + "i18n:check": "bun scripts/check-i18n-keys.ts", + "i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor", "postinstall": "patch-package" }, diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.ts similarity index 80% rename from scripts/check-i18n-keys.mjs rename to scripts/check-i18n-keys.ts index 0e20e462..eb201b42 100644 --- a/scripts/check-i18n-keys.mjs +++ b/scripts/check-i18n-keys.ts @@ -18,11 +18,11 @@ * - Edge cases the static scan cannot see can be allow-listed in the config file. * * Usage: - * bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused - * bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning - * bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely - * bun scripts/check-i18n-keys.mjs --json # machine-readable output - * bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) + * bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused + * bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning + * bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely + * bun scripts/check-i18n-keys.ts --json # machine-readable output + * bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) */ import { @@ -34,9 +34,20 @@ import { } from "node:fs"; import { extname, join, relative } from "node:path"; +type LocaleTree = { [key: string]: LocaleTree | string }; + +interface I18nConfig { + localesDir: string; + sourceLocale: string; + srcDirs: string[]; + srcExtensions: string[]; + excludeDirs: string[]; + ignoreUnused: string[]; +} + const ROOT = process.cwd(); const args = process.argv.slice(2); -const flag = (name, def) => { +const flag = (name: string, def: string | boolean): string | boolean => { const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); if (!a) return def; const [, v] = a.split("="); @@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false); // ---- config ---- const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); -const DEFAULT_CONFIG = { +const DEFAULT_CONFIG: I18nConfig = { localesDir: "translations", sourceLocale: "en", // Scan the whole repo by default so keys referenced outside the obvious dirs @@ -69,29 +80,36 @@ const DEFAULT_CONFIG = { // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. ignoreUnused: [], }; -const config = existsSync(CONFIG_PATH) - ? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } +const config: I18nConfig = existsSync(CONFIG_PATH) + ? { + ...DEFAULT_CONFIG, + ...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial), + } : DEFAULT_CONFIG; // ---- helpers ---- -const flatten = (obj, prefix = "", out = {}) => { +const flatten = ( + obj: LocaleTree, + prefix = "", + out: Record = {}, +): Record => { for (const [k, v] of Object.entries(obj)) { const key = prefix ? `${prefix}.${k}` : k; if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); - else out[key] = v; + else out[key] = v as string; } return out; }; -const globMatch = (key, pattern) => { +const globMatch = (key: string, pattern: string): boolean => { if (pattern.endsWith(".*")) return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); return key === pattern; }; -const walk = (dir, files = []) => { - let entries; +const walk = (dir: string, files: string[] = []): string[] => { + let entries: string[]; try { entries = readdirSync(dir); } catch { @@ -99,7 +117,7 @@ const walk = (dir, files = []) => { } for (const name of entries) { const full = join(dir, name); - let st; + let st: ReturnType; try { st = statSync(full); } catch { @@ -118,7 +136,7 @@ const walk = (dir, files = []) => { // ---- load source keys ---- const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); const sourceKeys = Object.keys( - flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), + flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree), ); const sourceKeySet = new Set(sourceKeys); @@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b." const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y -const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection -const dynamicPrefixes = new Set(); -const fullyDynamic = []; // { file, line } +const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection +const dynamicPrefixes = new Set(); +const fullyDynamic: Array<{ file: string; line: number }> = []; let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals // Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as // usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers); // line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps // `://` inside string URLs intact. -const stripComments = (src) => +const stripComments = (src: string): string => src .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); @@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes]; // the code (covers t("k"), , and keys stored as bare string constants in // arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or // explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). -const literalUsed = (key) => +const literalUsed = (key: string): boolean => codeBlob.includes(`"${key}"`) || codeBlob.includes(`'${key}'`) || codeBlob.includes(`\`${key}\``); -const isUsed = (key) => +const isUsed = (key: string): boolean => literalUsed(key) || prefixList.some((p) => key.startsWith(p)) || config.ignoreUnused.some((g) => globMatch(key, g)); @@ -191,25 +209,22 @@ const missing = [...usedStatic] // keys are static literals in practice; revisit if dynamic key constants become common. // ---- optional fix: strip dead keys from the source locale (en.json) ---- -const removeKey = (obj, parts) => { +const removeKey = (obj: LocaleTree, parts: string[]): void => { const [head, ...rest] = parts; if (!(head in obj)) return; if (rest.length === 0) { delete obj[head]; return; } - removeKey(obj[head], rest); - if ( - obj[head] && - typeof obj[head] === "object" && - Object.keys(obj[head]).length === 0 - ) - delete obj[head]; + const child = obj[head]; + if (!child || typeof child !== "object") return; + removeKey(child, rest); + if (Object.keys(child).length === 0) delete obj[head]; }; if (FIX_UNUSED && unused.length) { // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes // the keys from them automatically on the next sync once they disappear from the source. - const data = JSON.parse(readFileSync(sourcePath, "utf8")); + const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree; for (const key of unused) removeKey(data, key.split(".")); writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); console.log( @@ -259,7 +274,7 @@ if (JSON_OUT) { ); for (const k of unused) console.log(` - ${k}`); console.log( - `\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, + `\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`, ); console.log( ` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, diff --git a/scripts/detect-duplicate-issue.mjs b/scripts/detect-duplicate-issue.ts similarity index 93% rename from scripts/detect-duplicate-issue.mjs rename to scripts/detect-duplicate-issue.ts index 26886b26..f4ea467c 100644 --- a/scripts/detect-duplicate-issue.mjs +++ b/scripts/detect-duplicate-issue.ts @@ -21,8 +21,14 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +interface Issue { + number: number; + title: string; + body: string | null; +} + // Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured. -const numEnv = (name, def) => { +const numEnv = (name: string, def: number): number => { const raw = process.env[name]; if (raw === undefined || raw === "") return def; const n = Number(raw); @@ -51,9 +57,9 @@ const STOP = new Set( ).split(/\s+/), ); -const stem = (w) => w.replace(/(ing|ed|es|s)$/, ""); +const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, ""); -const tokens = (s) => +const tokens = (s: string | null): string[] => (s || "") .toLowerCase() .replace(/```[\s\S]*?```/g, " ") // drop code blocks @@ -65,7 +71,7 @@ const tokens = (s) => .map(stem) .filter((w) => w.length > 2); -const jaccard = (a, b) => { +const jaccard = (a: string[], b: string[]): number => { const A = new Set(a); const B = new Set(b); if (!A.size || !B.size) return 0; @@ -76,14 +82,14 @@ const jaccard = (a, b) => { const newTitle = tokens(TITLE); const newBody = tokens(BODY); -const score = (o) => +const score = (o: Issue): number => 0.6 * jaccard(newTitle, tokens(o.title)) + 0.4 * jaccard(newBody, tokens(o.body)); // fetch open issues (excluding PRs and the new issue itself) -let issues; +let issues: Issue[]; if (process.env.DUP_FIXTURE) { - issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")); + issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[]; } else { const raw = execFileSync( "gh", @@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) { issues = raw .split("\n") .filter(Boolean) - .map((l) => JSON.parse(l)); + .map((l) => JSON.parse(l) as Issue); } const matches = issues @@ -123,7 +129,7 @@ if (!matches.length) { // Neutralise other issues' titles before echoing them back: break @mentions and // strip markdown/HTML control chars so a maliciously-named issue can't ping people // or inject formatting into our comment. GitHub linkifies "#123" on its own. -const safeTitle = (t) => +const safeTitle = (t: string): string => (t || "") .replace(/@/g, "@​") .replace(/[`<>|*_~[\]]/g, " ") diff --git a/scripts/symlink-native-dirs.js b/scripts/symlink-native-dirs.js deleted file mode 100644 index dd014c99..00000000 --- a/scripts/symlink-native-dirs.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node - -const _fs = require("node:fs"); -const path = require("node:path"); -const process = require("node:process"); -const { execSync } = require("node:child_process"); - -const root = process.cwd(); -// const tvosPath = path.join(root, 'iostv'); -// const iosPath = path.join(root, 'iosmobile'); -// const androidPath = path.join(root, 'androidmobile'); -// const androidTVPath = path.join(root, 'androidtv'); -// const device = process.argv[2]; -// const platform = process.argv[2]; -const isTV = process.env.EXPO_TV || false; - -const paths = new Map([ - ["tvos", path.join(root, "iostv")], - ["ios", path.join(root, "iosmobile")], - ["android", path.join(root, "androidmobile")], - ["androidtv", path.join(root, "androidtv")], -]); - -// const platformPath = paths.get(platform); - -if (isTV) { - stdout = execSync( - `mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`, - ); - console.log(stdout.toString()); - stdout = execSync( - `mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get( - "androidtv", - )} android`, - ); - console.log(stdout.toString()); -} else { - stdout = execSync( - `mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`, - ); - console.log(stdout.toString()); - stdout = execSync( - `mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`, - ); - console.log(stdout.toString()); -} - -// target = ""; -// switch (platform) { -// case "tvos": -// target = "ios"; -// break; -// case "ios": -// target = "ios"; -// break; -// case "android": -// target = "android"; -// break; -// case "androidtv": -// target = "android"; -// break; -// } diff --git a/scripts/typecheck.js b/scripts/typecheck.ts similarity index 81% rename from scripts/typecheck.js rename to scripts/typecheck.ts index 81a2c9bc..ba2a7cbc 100644 --- a/scripts/typecheck.js +++ b/scripts/typecheck.ts @@ -1,5 +1,8 @@ -const { execFileSync } = require("node:child_process"); -const process = require("node:process"); +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import process from "node:process"; + +const require = createRequire(import.meta.url); // Enhanced ANSI color codes and styles const colors = { @@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title; const useColor = process.stdout.isTTY && !process.env.NO_COLOR; -function log(message, color = "") { +function log(message: string, color = "") { if (useColor && color) { console.log(`${color}${message}${colors.reset}`); } else { @@ -40,7 +43,7 @@ function log(message, color = "") { } } -function formatError(errorLine) { +function formatError(errorLine: string): string { if (!useColor) return errorLine; // Color file paths in cyan @@ -70,12 +73,15 @@ function formatError(errorLine) { return formatted; } -function parseErrorsAndCreateSummary(errorOutput) { +function parseErrorsAndCreateSummary(errorOutput: string): { + formattedErrors: string[]; + errorsByFile: Map; +} { const lines = errorOutput.split("\n").filter((line) => line.trim()); - const errorsByFile = new Map(); - const formattedErrors = []; + const errorsByFile = new Map(); + const formattedErrors: string[] = []; - let currentError = []; + let currentError: string[] = []; for (const line of lines) { const trimmedLine = line.trim(); @@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) { if (!errorsByFile.has(filePath)) { errorsByFile.set(filePath, 0); } - errorsByFile.set(filePath, errorsByFile.get(filePath) + 1); + errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1); // Start new error currentError.push(formatError(line)); @@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) { return { formattedErrors, errorsByFile }; } -function createErrorSummaryTable(errorsByFile) { +function createErrorSummaryTable(errorsByFile: Map): string { if (errorsByFile.size === 0) return ""; const sortedFiles = Array.from(errorsByFile.entries()).sort( @@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) { return table; } -function runTypeCheck() { +function runTypeCheck(): { ok: boolean } { const extraArgs = process.argv.slice(2); // Prefer local TypeScript binary when available @@ -150,16 +156,13 @@ function runTypeCheck() { "false", ...extraArgs, ]; - let execArgs = null; + let execArgs: { cmd: string; args: string[] }; try { const tscBin = require.resolve("typescript/bin/tsc"); execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; } catch { - // fallback to PATH tsc - execArgs = { - cmd: "tsc", - args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs], - }; + // fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved) + execArgs = { cmd: "tsc", args: runnerArgs }; } try { @@ -183,7 +186,21 @@ function runTypeCheck() { ); return { ok: true }; } catch (error) { - const errorOutput = (error && (error.stderr || error.stdout)) || ""; + const execError = error as { stderr?: string; stdout?: string }; + const errorOutput = [execError.stdout, execError.stderr] + .filter((chunk): chunk is string => Boolean(chunk)) + .join("\n"); + + // No compiler output = tsc never ran (e.g. binary missing). Don't let a + // launch failure fall through to the "passed" branch and green-light CI. + if (!errorOutput) { + const message = error instanceof Error ? error.message : String(error); + log( + `❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`, + colors.red, + ); + return { ok: false }; + } // Filter out jellyseerr utils errors - this is a third-party git submodule // that generates a large volume of known type errors diff --git a/utils/profiles/download.js b/utils/profiles/download.ts similarity index 86% rename from utils/profiles/download.js rename to utils/profiles/download.ts index 18101b2c..c75d0f58 100644 --- a/utils/profiles/download.js +++ b/utils/profiles/download.ts @@ -3,17 +3,17 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { generateDeviceProfile } from "./native"; - -/** - * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType - */ +import type { + DeviceProfile, + SubtitleProfile, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { type AudioTranscodeModeType, generateDeviceProfile } from "./native"; /** * Download-specific subtitle profiles. * These are more permissive than streaming profiles since we can embed subtitles. */ -const downloadSubtitleProfiles = [ +const downloadSubtitleProfiles: SubtitleProfile[] = [ // Official formats { Format: "vtt", Method: "Encode" }, { Format: "webvtt", Method: "Encode" }, @@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [ /** * Generates a device profile optimized for downloads. * Uses the same audio codec logic as streaming but with download-specific bitrate limits. - * - * @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode - * @returns {Object} Jellyfin device profile for downloads */ -export const generateDownloadProfile = (audioMode = "auto") => { +export const generateDownloadProfile = ( + audioMode: AudioTranscodeModeType = "auto", +): DeviceProfile => { // Get the base profile with proper audio codec configuration const baseProfile = generateDeviceProfile({ audioMode }); diff --git a/utils/profiles/native.ts b/utils/profiles/native.ts index 9d7224ff..3a9336cd 100644 --- a/utils/profiles/native.ts +++ b/utils/profiles/native.ts @@ -3,6 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models"; import { Platform } from "react-native"; import MediaTypes from "../../constants/MediaTypes"; import { getSubtitleProfiles } from "./subtitles"; @@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => { }, ], SubtitleProfiles: getSubtitleProfiles(), - }; + } satisfies DeviceProfile; return profile; }; diff --git a/utils/profiles/subtitles.js b/utils/profiles/subtitles.ts similarity index 81% rename from utils/profiles/subtitles.js rename to utils/profiles/subtitles.ts index 99a9048a..f1d8af44 100644 --- a/utils/profiles/subtitles.js +++ b/utils/profiles/subtitles.ts @@ -3,6 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models"; // Image-based formats - these need to be burned in by Jellyfin (Encode method) // because MPV cannot load them externally over HTTP @@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [ "pgssub", "teletext", "vobsub", -]; +] as const; // Text-based formats - these can be loaded externally by MPV const TEXT_BASED_FORMATS = [ @@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [ "text", "vplayer", "xsub", -]; +] as const; -export const getSubtitleProfiles = () => { - const profiles = []; +export const getSubtitleProfiles = (): SubtitleProfile[] => { + const profiles: SubtitleProfile[] = []; // Image-based formats: Embed or Encode (burn-in), NOT External for (const format of IMAGE_BASED_FORMATS) { @@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => { }; // Export for use in player filtering -export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS; +export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS; diff --git a/utils/profiles/trackplayer.d.ts b/utils/profiles/trackplayer.d.ts deleted file mode 100644 index aa2f985f..00000000 --- a/utils/profiles/trackplayer.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -export type PlatformType = "ios" | "android"; - -export interface TrackPlayerProfileOptions { - /** Target platform */ - platform?: PlatformType; -} - -export function generateTrackPlayerProfile( - options?: TrackPlayerProfileOptions, -): any; - -declare const _default: any; -export default _default; diff --git a/utils/profiles/trackplayer.js b/utils/profiles/trackplayer.ts similarity index 77% rename from utils/profiles/trackplayer.js rename to utils/profiles/trackplayer.ts index 2cbfdf93..406c51ff 100644 --- a/utils/profiles/trackplayer.js +++ b/utils/profiles/trackplayer.ts @@ -3,23 +3,25 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import type { + CodecProfile, + DeviceProfile, + DirectPlayProfile, +} from "@jellyfin/sdk/lib/generated-client/models"; import { Platform } from "react-native"; import MediaTypes from "../../constants/MediaTypes"; +import type { PlatformType } from "./native"; -/** - * @typedef {"ios" | "android"} PlatformType - * - * @typedef {Object} TrackPlayerProfileOptions - * @property {PlatformType} [platform] - Target platform - */ +export interface TrackPlayerProfileOptions { + /** Target platform */ + platform?: PlatformType; +} /** * Audio direct play profiles for react-native-track-player. * iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support. - * - * @param {PlatformType} platform */ -const getDirectPlayProfile = (platform) => { +const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => { if (platform === "ios") { // iOS AVPlayer supported formats return { @@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => { /** * Audio codec profiles for react-native-track-player. - * - * @param {PlatformType} platform */ -const getCodecProfile = (platform) => { +const getCodecProfile = (platform: PlatformType): CodecProfile => { if (platform === "ios") { // iOS AVPlayer codec constraints return { @@ -64,12 +64,11 @@ const getCodecProfile = (platform) => { * This profile is specifically for standalone audio playback using: * - AVPlayer on iOS * - ExoPlayer on Android - * - * @param {TrackPlayerProfileOptions} [options] - Profile configuration options - * @returns {Object} Jellyfin device profile for track player */ -export const generateTrackPlayerProfile = (options = {}) => { - const platform = options.platform || Platform.OS; +export const generateTrackPlayerProfile = ( + options: TrackPlayerProfileOptions = {}, +): DeviceProfile => { + const platform = (options.platform || Platform.OS) as PlatformType; return { Name: "Track Player", From 286a3cad47c3fd1673428521aba89b76e6eb2b19 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 30 Jun 2026 09:03:47 +0200 Subject: [PATCH 2/3] refactor: migrate app.config and Expo config plugins to TypeScript (#1718) --- .github/workflows/build-apps.yml | 2 +- app.config.js => app.config.ts | 20 ++++--- app.json | 24 ++++---- bun.lock | 57 +++++++++++++++++++ docs/tv-discovery.md | 2 +- package.json | 1 + ...s.js => with-runtime-framework-headers.ts} | 8 ++- ...ertColors.js => withAndroidAlertColors.ts} | 24 +++++--- ...roidManifest.js => withAndroidManifest.ts} | 12 ++-- ... => withChangeNativeAndroidTextToWhite.ts} | 10 ++-- ...Media3Dash.js => withExcludeMedia3Dash.ts} | 6 +- plugins/{withGitPod.js => withGitPod.ts} | 14 ++++- ...eProperties.js => withGradleProperties.ts} | 19 +++++-- ...{withTVOSAppIcon.js => withTVOSAppIcon.ts} | 6 +- ...ithTVOSTopShelf.js => withTVOSTopShelf.ts} | 55 +++++++++++------- ...rManagement.js => withTVUserManagement.ts} | 6 +- .../{withTVXcodeEnv.js => withTVXcodeEnv.ts} | 14 ++--- ...stLocalCerts.js => withTrustLocalCerts.ts} | 23 ++++++-- utils/version.ts | 2 +- 19 files changed, 216 insertions(+), 89 deletions(-) rename app.config.js => app.config.ts (76%) rename plugins/{with-runtime-framework-headers.js => with-runtime-framework-headers.ts} (96%) rename plugins/{withAndroidAlertColors.js => withAndroidAlertColors.ts} (60%) rename plugins/{withAndroidManifest.js => withAndroidManifest.ts} (77%) rename plugins/{withChangeNativeAndroidTextToWhite.js => withChangeNativeAndroidTextToWhite.ts} (68%) rename plugins/{withExcludeMedia3Dash.js => withExcludeMedia3Dash.ts} (85%) rename plugins/{withGitPod.js => withGitPod.ts} (63%) rename plugins/{withGradleProperties.js => withGradleProperties.ts} (67%) rename plugins/{withTVOSAppIcon.js => withTVOSAppIcon.ts} (81%) rename plugins/{withTVOSTopShelf.js => withTVOSTopShelf.ts} (80%) rename plugins/{withTVUserManagement.js => withTVUserManagement.ts} (81%) rename plugins/{withTVXcodeEnv.js => withTVXcodeEnv.ts} (91%) rename plugins/{withTrustLocalCerts.js => withTrustLocalCerts.ts} (70%) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 15a7b03a..580dc46d 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -11,7 +11,7 @@ on: push: 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 # EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions # run (artifacts + logs) without needing Expo access. diff --git a/app.config.js b/app.config.ts similarity index 76% rename from app.config.js rename to app.config.ts index d29ddc32..ac2b433a 100644 --- a/app.config.js +++ b/app.config.ts @@ -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 // expo-constants (see utils/version.ts). Sources in priority order: // EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. -const git = (args) => { +const git = (args: string[]): string | null => { try { return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) .toString() @@ -42,16 +46,16 @@ const buildMeta = { builtAt: new Date().toISOString(), }; -module.exports = ({ config }) => { +export default ({ config }: ConfigContext): ExpoConfig => { 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", { useDefaultExpandedMediaControls: true }, ]); - config.plugins.push([ + config.plugins?.push([ "expo-camera", { cameraPermission: @@ -61,7 +65,7 @@ module.exports = ({ config }) => { } // Only override googleServicesFile if env var is set - const androidConfig = {}; + const androidConfig: { googleServicesFile?: string } = {}; if (process.env.GOOGLE_SERVICES_JSON) { androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; } @@ -71,5 +75,5 @@ module.exports = ({ config }) => { return { ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...config, - }; + } as ExpoConfig; }; diff --git a/app.json b/app.json index e7095490..42efc22c 100644 --- a/app.json +++ b/app.json @@ -71,8 +71,8 @@ ], "expo-router", "expo-font", - "./plugins/withExcludeMedia3Dash.js", - "./plugins/withTVUserManagement.js", + "./plugins/withExcludeMedia3Dash.ts", + "./plugins/withTVUserManagement.ts", [ "expo-build-properties", { @@ -134,17 +134,17 @@ } ], "expo-web-browser", - ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], - ["./plugins/withAndroidAlertColors.js"], - ["./plugins/withAndroidManifest.js"], - ["./plugins/withTrustLocalCerts.js"], - ["./plugins/withGradleProperties.js"], - ["./plugins/withTVOSAppIcon.js"], - ["./plugins/withTVOSTopShelf.js"], - ["./plugins/withTVXcodeEnv.js"], + ["./plugins/with-runtime-framework-headers.ts"], + ["./plugins/withChangeNativeAndroidTextToWhite.ts"], + ["./plugins/withAndroidAlertColors.ts"], + ["./plugins/withAndroidManifest.ts"], + ["./plugins/withTrustLocalCerts.ts"], + ["./plugins/withGradleProperties.ts"], + ["./plugins/withTVOSAppIcon.ts"], + ["./plugins/withTVOSTopShelf.ts"], + ["./plugins/withTVXcodeEnv.ts"], [ - "./plugins/withGitPod.js", + "./plugins/withGitPod.ts", { "podName": "MPVKit", "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" diff --git a/bun.lock b/bun.lock index 112ab885..86fff885 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", + "tsx": "^4.22.4", "typescript": "6.0.3", }, }, @@ -294,6 +295,58 @@ "@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/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=="], + "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=="], "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=="], + "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-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=="], diff --git a/docs/tv-discovery.md b/docs/tv-discovery.md index b1a55165..c2bc67eb 100644 --- a/docs/tv-discovery.md +++ b/docs/tv-discovery.md @@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process. Relevant files: -- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) +- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts) - [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) - [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) - [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) diff --git a/package.json b/package.json index 6cdba17b..e4e65831 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", + "tsx": "^4.22.4", "typescript": "6.0.3" }, "expo": { diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.ts similarity index 96% rename from plugins/with-runtime-framework-headers.js rename to plugins/with-runtime-framework-headers.ts index 8405239b..43e34046 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.ts @@ -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_END = "## <<< runtime-framework headers"; @@ -13,7 +13,7 @@ const EXTRA_HDRS = [ `\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`, ]; -function buildPatch() { +function buildPatch(): string { return [ PATCH_START, " extra_hdrs = [", @@ -91,7 +91,7 @@ function buildPatch() { ].join("\n"); } -module.exports = function withRuntimeFrameworkHeaders(config) { +const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => { return withPodfile(config, (config) => { let podfile = config.modResults.contents; @@ -125,3 +125,5 @@ end return config; }); }; + +export default withRuntimeFrameworkHeaders; diff --git a/plugins/withAndroidAlertColors.js b/plugins/withAndroidAlertColors.ts similarity index 60% rename from plugins/withAndroidAlertColors.js rename to plugins/withAndroidAlertColors.ts index c0570047..829b9a54 100644 --- a/plugins/withAndroidAlertColors.js +++ b/plugins/withAndroidAlertColors.ts @@ -1,10 +1,20 @@ -const { +import { + type ConfigPlugin, withAndroidColors, withAndroidColorsNight, -} = require("expo/config-plugins"); +} from "expo/config-plugins"; -const withAndroidAlertColors = (config) => { - const setColor = (colorsList, name, value) => { +interface ColorResourceItem { + $: { name: string }; + _: string; +} + +const withAndroidAlertColors: ConfigPlugin = (config) => { + const setColor = ( + colorsList: ColorResourceItem[], + name: string, + value: string, + ) => { const existingColor = colorsList.find( (item) => item.$ && item.$.name === name, ); @@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => { config = withAndroidColors(config, (config) => { const colors = config.modResults; - const colorsList = colors.resources.color || []; + const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; setColor(colorsList, "colorPrimary", "#000000"); colors.resources.color = colorsList; return config; @@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => { config = withAndroidColorsNight(config, (config) => { const colors = config.modResults; - const colorsList = colors.resources.color || []; + const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; setColor(colorsList, "colorPrimary", "#FFFFFF"); colors.resources.color = colorsList; return config; @@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => { return config; }; -module.exports = withAndroidAlertColors; +export default withAndroidAlertColors; diff --git a/plugins/withAndroidManifest.js b/plugins/withAndroidManifest.ts similarity index 77% rename from plugins/withAndroidManifest.js rename to plugins/withAndroidManifest.ts index 883869fb..7d5a9e62 100644 --- a/plugins/withAndroidManifest.js +++ b/plugins/withAndroidManifest.ts @@ -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) => { - 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 if (!mainApplication.activity) { @@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) => return mod; }); -module.exports = _withGoogleCastAndroidManifest; +export default withGoogleCastAndroidManifest; diff --git a/plugins/withChangeNativeAndroidTextToWhite.js b/plugins/withChangeNativeAndroidTextToWhite.ts similarity index 68% rename from plugins/withChangeNativeAndroidTextToWhite.js rename to plugins/withChangeNativeAndroidTextToWhite.ts index efdb782b..1ae4108a 100644 --- a/plugins/withChangeNativeAndroidTextToWhite.js +++ b/plugins/withChangeNativeAndroidTextToWhite.ts @@ -1,8 +1,8 @@ -const { readFileSync, writeFileSync } = require("node:fs"); -const { join } = require("node:path"); -const { withDangerousMod } = require("expo/config-plugins"); +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; -const withChangeNativeAndroidTextToWhite = (expoConfig) => +const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) => withDangerousMod(expoConfig, [ "android", (modConfig) => { @@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) => }, ]); -module.exports = withChangeNativeAndroidTextToWhite; +export default withChangeNativeAndroidTextToWhite; diff --git a/plugins/withExcludeMedia3Dash.js b/plugins/withExcludeMedia3Dash.ts similarity index 85% rename from plugins/withExcludeMedia3Dash.js rename to plugins/withExcludeMedia3Dash.ts index 8884cccf..e6b93656 100644 --- a/plugins/withExcludeMedia3Dash.js +++ b/plugins/withExcludeMedia3Dash.ts @@ -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) => { const contents = config.modResults.contents; @@ -32,3 +32,5 @@ configurations.all { return config; }); }; + +export default withExcludeMedia3Dash; diff --git a/plugins/withGitPod.js b/plugins/withGitPod.ts similarity index 63% rename from plugins/withGitPod.js rename to plugins/withGitPod.ts index dc046e8a..5d5aab73 100644 --- a/plugins/withGitPod.js +++ b/plugins/withGitPod.ts @@ -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 = ( + config, + { podName, podspecUrl }, +) => { return withPodfile(config, (config) => { const podfile = config.modResults.contents; @@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => { }); }; -module.exports = withGitPod; +export default withGitPod; diff --git a/plugins/withGradleProperties.js b/plugins/withGradleProperties.ts similarity index 67% rename from plugins/withGradleProperties.js rename to plugins/withGradleProperties.ts index 57c37be1..0cfa5a05 100644 --- a/plugins/withGradleProperties.js +++ b/plugins/withGradleProperties.ts @@ -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) => { const props = exportedConfig.modResults; const keyIdx = props.findIndex( (item) => item.type === "property" && item.key === key, ); - const property = { + const property: AndroidConfig.Properties.PropertiesItem = { type: "property", key, 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 // https://github.com/expo/expo/issues/32558 config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); @@ -38,3 +47,5 @@ module.exports = function withCustomPlugin(config) { ); return config; }; + +export default withCustomGradleProperties; diff --git a/plugins/withTVOSAppIcon.js b/plugins/withTVOSAppIcon.ts similarity index 81% rename from plugins/withTVOSAppIcon.js rename to plugins/withTVOSAppIcon.ts index 50114eb6..1fadbac2 100644 --- a/plugins/withTVOSAppIcon.js +++ b/plugins/withTVOSAppIcon.ts @@ -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 if (process.env.EXPO_TV !== "1") { return config; @@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => { }); }; -module.exports = withTVOSAppIcon; +export default withTVOSAppIcon; diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.ts similarity index 80% rename from plugins/withTVOSTopShelf.js rename to plugins/withTVOSTopShelf.ts index 56610fcf..5fcd1374 100644 --- a/plugins/withTVOSTopShelf.js +++ b/plugins/withTVOSTopShelf.ts @@ -1,8 +1,10 @@ -const { +import type { ExpoConfig } from "expo/config"; +import { + type ConfigPlugin, withEntitlementsPlist, withInfoPlist, withXcodeProject, -} = require("@expo/config-plugins"); +} from "expo/config-plugins"; const EXTENSION_TARGET_NAME = "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 = "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"; } -function getAppGroupIdentifier(config) { +function getAppGroupIdentifier(config: ExpoConfig): string { return `group.${getBundleIdentifier(config)}.tvtopshelf`; } -function getKeychainAccessGroupIdentifier(config) { +function getKeychainAccessGroupIdentifier(config: ExpoConfig): string { 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 = project.hash.project.objects.XCConfigurationList[configurationListId]; @@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) { const buildConfigurations = project.pbxXCBuildConfigurationSection(); return configurationList.buildConfigurations - .map((config) => buildConfigurations[config.value]) + .map((config: { value: string }) => buildConfigurations[config.value]) .filter(Boolean); } -function ensureAppGroup(value, appGroupIdentifier) { +function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] { const groups = Array.isArray(value) ? value : []; return groups.includes(appGroupIdentifier) ? groups : [...groups, appGroupIdentifier]; } -function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { +function ensureKeychainAccessGroup( + value: unknown, + keychainAccessGroupIdentifier: string, +): string[] { const groups = Array.isArray(value) ? value : []; return groups.includes(keychainAccessGroupIdentifier) ? groups @@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { } function ensureAppExtension( - appExtensions, - targetName, - bundleIdentifier, - appGroupIdentifier, - keychainAccessGroupIdentifier, -) { - const extensionConfig = { + appExtensions: unknown, + targetName: string, + bundleIdentifier: string, + appGroupIdentifier: string, + keychainAccessGroupIdentifier: string, +): AppExtensionConfig[] { + const extensionConfig: AppExtensionConfig = { targetName, bundleIdentifier, entitlements: { @@ -63,7 +78,9 @@ function ensureAppExtension( "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. const existingIndex = extensions.findIndex( (appExtension) => appExtension?.targetName === targetName, @@ -78,7 +95,7 @@ function ensureAppExtension( ); } -const withTVOSTopShelf = (config) => { +const withTVOSTopShelf: ConfigPlugin = (config) => { const appGroupIdentifier = getAppGroupIdentifier(config); const keychainAccessGroupIdentifier = getKeychainAccessGroupIdentifier(config); @@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => { }); }; -module.exports = withTVOSTopShelf; +export default withTVOSTopShelf; diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.ts similarity index 81% rename from plugins/withTVUserManagement.js rename to plugins/withTVUserManagement.ts index 651ee738..f69f47b1 100644 --- a/plugins/withTVUserManagement.js +++ b/plugins/withTVUserManagement.ts @@ -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 */ -const withTVUserManagement = (config) => { +const withTVUserManagement: ConfigPlugin = (config) => { // 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 // breaks signing ("Entitlement ... not found and could not be included in @@ -24,4 +24,4 @@ const withTVUserManagement = (config) => { }); }; -module.exports = withTVUserManagement; +export default withTVUserManagement; diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.ts similarity index 91% rename from plugins/withTVXcodeEnv.js rename to plugins/withTVXcodeEnv.ts index 86f36755..dd26e8f0 100644 --- a/plugins/withTVXcodeEnv.js +++ b/plugins/withTVXcodeEnv.ts @@ -1,7 +1,7 @@ -const { withDangerousMod } = require("@expo/config-plugins"); -const { execSync } = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "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. @@ -12,7 +12,7 @@ const path = require("node:path"); * * 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 if (process.env.EXPO_TV !== "1") { return config; @@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => { /** * Get the actual node binary path, handling nvm installations. */ -function getNodeBinaryPath() { +function getNodeBinaryPath(): string | null { try { // First try to get node path directly (works for non-nvm installs) const directPath = execSync("which node 2>/dev/null", { @@ -114,4 +114,4 @@ function getNodeBinaryPath() { return null; } -module.exports = withTVXcodeEnv; +export default withTVXcodeEnv; diff --git a/plugins/withTrustLocalCerts.js b/plugins/withTrustLocalCerts.ts similarity index 70% rename from plugins/withTrustLocalCerts.js rename to plugins/withTrustLocalCerts.ts index 20e902e4..c9e69c38 100644 --- a/plugins/withTrustLocalCerts.js +++ b/plugins/withTrustLocalCerts.ts @@ -1,18 +1,29 @@ -const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins"); -const path = require("node:path"); -const fs = require("node:fs"); +import fs from "node:fs"; +import path from "node:path"; +import { + AndroidConfig, + type ConfigPlugin, + type ExportedConfigWithProps, + withAndroidManifest, +} from "expo/config-plugins"; + const fsPromises = fs.promises; const { getMainApplicationOrThrow } = AndroidConfig.Manifest; -const withTrustLocalCerts = (config) => { +type AndroidManifest = AndroidConfig.Manifest.AndroidManifest; + +const withTrustLocalCerts: ConfigPlugin = (config) => { return withAndroidManifest(config, async (mod) => { mod.modResults = await setCustomConfigAsync(mod, mod.modResults); return mod; }); }; -async function setCustomConfigAsync(config, androidManifest) { +async function setCustomConfigAsync( + config: ExportedConfigWithProps, + androidManifest: AndroidManifest, +): Promise { const src_file_path = path.join(__dirname, "network_security_config.xml"); const res_file_path = path.join( await AndroidConfig.Paths.getResourceFolderAsync( @@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) { return androidManifest; } -module.exports = withTrustLocalCerts; +export default withTrustLocalCerts; diff --git a/utils/version.ts b/utils/version.ts index 812665c4..ade6025a 100644 --- a/utils/version.ts +++ b/utils/version.ts @@ -5,7 +5,7 @@ import Constants from "expo-constants"; * clientInfo auto-tracks the app version instead of a hardcoded string. */ 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 { commit?: string | null; branch?: string | null; From aa0eb0a6550274b12f1615a0c2b5b90aca632fed Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:58:25 +0200 Subject: [PATCH 3/3] fix: changing pip to use surface view (#1778) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- .../expo/modules/mpvplayer/MpvPlayerView.kt | 208 ++++++++---------- .../expo/modules/mpvplayer/PiPController.kt | 38 +++- 2 files changed, 128 insertions(+), 118 deletions(-) diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 7cb322d9..df74be18 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -2,14 +2,12 @@ package expo.modules.mpvplayer import android.content.Context import android.graphics.Color -import android.graphics.Rect -import android.graphics.SurfaceTexture import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface -import android.view.TextureView -import android.view.View +import android.view.SurfaceHolder +import android.view.SurfaceView import android.view.ViewGroup import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher @@ -30,15 +28,26 @@ data class VideoLoadConfig( val cacheEnabled: String? = null, val cacheSeconds: Int? = null, val demuxerMaxBytes: Int? = null, - val demuxerMaxBackBytes: Int? = null + val demuxerMaxBackBytes: Int? = null, ) /** * 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), - MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { + MPVLayerRenderer.Delegate, SurfaceHolder.Callback { companion object { private const val TAG = "MpvPlayerView" @@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context val onTracksReady by EventDispatcher() val onPictureInPictureChange by EventDispatcher() - private var textureView: TextureView + private var surfaceView: SurfaceView private var renderer: MPVLayerRenderer? = 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 pendingConfig: VideoLoadConfig? = null private var rendererStarted: Boolean = false - private var pendingSurface: Surface? = null private var activeSurface: Surface? = null - private var surfaceTexture: SurfaceTexture? = null // PiP state tracking - private var isWaitingForPiPTransition: Boolean = false - private var isPiPSurfaceForced: Boolean = false private val pipHandler = Handler(Looper.getMainLooper()) init { setBackgroundColor(Color.BLACK) - // Create TextureView for video rendering (composites into app window for PiP support) - textureView = TextureView(context).apply { + // SurfaceView for video rendering. Routes the surface directly to + // 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( 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 pipController = PiPController(context, appContext) - pipController?.setPlayerView(textureView) + pipController?.setPlayerView(surfaceView) pipController?.delegate = object : PiPController.Delegate { override fun onPlay() { play() @@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context override fun onPictureInPictureModeChanged(isInPiP: Boolean) { if (isInPiP) { - if (!isWaitingForPiPTransition) { - isWaitingForPiPTransition = true - pipHandler.removeCallbacksAndMessages(null) - for (delay in longArrayOf(500, 1000, 1500, 2000)) { - pipHandler.postDelayed({ forcePiPBufferSize() }, delay) - } - } - } else { - isWaitingForPiPTransition = false + // Post size syncs after the PiP layout settles. Two passes + // catch both the immediate surface re-attach and the + // post-animation layout pass. Replaces the old TextureView + // measure/layout polling hack (forcePiPBufferSize). 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)) } @@ -126,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context /** * 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?) { if (rendererStarted) return @@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context renderer?.start(voDriver ?: "gpu-next") 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 renderer?.attachSurface(surface) - pendingSurface = null + syncSurfaceSizeToView() } } catch (e: Exception) { 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) { - this.surfaceTexture = surfaceTexture - val surface = Surface(surfaceTexture) - surfaceTexture.setDefaultBufferSize(width, height) + override fun surfaceCreated(holder: SurfaceHolder) { + val surface = holder.surface surfaceReady = true if (rendererStarted) { - // Release the previous wrapper Surface before losing the only - // reference to it. cleanup() only runs on detach, so without this - // repeated PiP/background/resize cycles leak native surface objects. - activeSurface?.release() + // The previous Surface reference is holder-owned; do NOT release + // it (SurfaceView manages its lifecycle). Just track the new one. activeSurface = surface renderer?.attachSurface(surface) - } else { - pendingSurface = surface + // Push the actual view dimensions immediately so mpv doesn't + // render against stale full-screen geometry during PiP transitions. + syncSurfaceSizeToView() } // 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) { - surfaceTexture.setDefaultBufferSize(width, height) - renderer?.updateSurfaceSize(width, height) + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (width > 0 && height > 0) { + renderer?.updateSurfaceSize(width, height) + } } - override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - this.surfaceTexture = null + override fun surfaceDestroyed(holder: SurfaceHolder) { surfaceReady = false 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 @@ -275,7 +315,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // Reset view-level state so a subsequent loadVideo() on the SAME view // 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 // called again — but stop() already nulled the renderer's mpv handle. // 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 // same route — Expo Router reuses the same MpvPlayerView instance, // 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 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 } @@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - Picture in Picture fun startPictureInPicture() { - isWaitingForPiPTransition = true 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() { - isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() } @@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context * off the JS path. */ fun cleanup() { - isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() renderer?.stop() renderer?.delegate = null - // Release the Surface that wraps the SurfaceTexture. These Surface - // 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() + // SurfaceView owns the Surface via its holder — do NOT release it. activeSurface = null surfaceReady = false currentUrl = null diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt index 2a24440b..c86d57e3 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon private var currentPosition: Double = 0.0 private var currentDuration: Double = 0.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 videoHeight: Int = 0 @@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon } 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 pipEntryNotified = false unregisterLifecycleCallbacks() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val activity = getActivity() - if (activity?.isInPictureInPictureMode == true) { - activity.moveTaskToBack(false) + + val activity = getActivity() ?: return + + // 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 @@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon fun setPlaybackRate(rate: Double) { playbackRate = rate + autoEnterEnabled = rate > 0 if (rate > 0) { registerLifecycleCallbacks() @@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon builder.setActions(buildPiPActions()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(forEntering || playbackRate > 0) + builder.setAutoEnterEnabled(forEntering || autoEnterEnabled) } return builder.build()