From 97b6a912e0dc27865a6400c5b4f6de35ee56c578 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Mon, 29 Jun 2026 19:48:32 +0200 Subject: [PATCH 01/11] 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 2111dd2e6..d3b750c28 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 ebf515d7d..cab53d615 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 d46c8a6f8..92bdf316d 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 eb2ae87e5..6b3ef5e52 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 2cf275cc2..77d08834a 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 4e3ecd383..6cdba17bd 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 0e20e462e..eb201b42e 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 26886b265..f4ea467c3 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 dd014c996..000000000 --- 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 81a2c9bc2..ba2a7cbc1 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 18101b2c0..c75d0f580 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 9d7224ff9..3a9336cd7 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 99a9048a0..f1d8af44e 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 aa2f985fc..000000000 --- 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 2cbfdf934..406c51ff4 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 02/11] 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 15a7b03ad..580dc46d8 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 d29ddc324..ac2b433ab 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 e70954900..42efc22c9 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 112ab885d..86fff885e 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 b1a551656..c2bc67eb5 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 6cdba17bd..e4e65831f 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 8405239b8..43e340469 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 c0570047b..829b9a54b 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 883869fb5..7d5a9e62d 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 efdb782b0..1ae4108ad 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 8884cccf8..e6b936561 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 dc046e8ac..5d5aab732 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 57c37be16..0cfa5a058 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 50114eb6b..1fadbac22 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 56610fcf7..5fcd13742 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 651ee7385..f69f47b16 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 86f367552..dd26e8f04 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 20e902e45..c9e69c38a 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 812665c40..ade6025a7 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 03/11] 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 7cb322d9f..df74be18e 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 2a24440bf..c86d57e3d 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() From 28a75a2b8c3fe214787bffb7f5bd4907d9d85499 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 30 Jun 2026 11:57:51 +0200 Subject: [PATCH 04/11] fix(tv): "See All" opens library and Back returns to library list (#1782) --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 45 +++++++++++++++++-- .../InfiniteScrollingCollectionList.tv.tsx | 13 +++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 9a2239f64..cdb1a181f 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -12,11 +12,16 @@ import { import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { + useFocusEffect, + useLocalSearchParams, + useNavigation, +} from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { + BackHandler, FlatList, Platform, ScrollView, @@ -80,8 +85,9 @@ const Page = () => { sortBy?: string; sortOrder?: string; filterBy?: string; + fromSeeAll?: string; }; - const { libraryId } = searchParams; + const { libraryId, fromSeeAll } = searchParams; const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -112,6 +118,22 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + + // When this library detail was opened from the home "See All" button, its + // libraries stack is just [detail], so the default TV Back would exit to home. + // Intercept Back (scoped to while this screen is focused via useFocusEffect) and + // route to the library list instead, so the user can switch libraries. Normal + // entries from the list keep their native pop-to-list behavior. + useFocusEffect( + useCallback(() => { + if (!Platform.isTV || fromSeeAll !== "true") return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + router.replace("/(auth)/(tabs)/(libraries)"); + return true; + }); + return () => sub.remove(); + }, [fromSeeAll, router]), + ); const { showItemActions } = useTVItemActionModal(); // TV Filter queries @@ -269,6 +291,23 @@ const Page = () => { }); }, [library]); + // If this See-All detail was deep-linked on top of the libraries index, collapse + // the libraries stack to just this screen. Otherwise the stack is [index, detail], + // which the native bottom tab reliably auto-pops back to the index (the detail + // "bounces" to the library list ~0.5s after opening). With [detail] alone it stays + // put, and Back is handled explicitly by the fromSeeAll interceptor above. + const didCollapseRef = useRef(false); + useEffect(() => { + if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current) + return; + const state = navigation.getState(); + if (state?.routes && state.routes.length > 1) { + didCollapseRef.current = true; + const top = state.routes[state.routes.length - 1]; + navigation.reset({ index: 0, routes: [top] } as any); + } + }, [navigation, fromSeeAll]); + const fetchItems = useCallback( async ({ pageParam, diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 23dd977f1..1db314f2b 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const handleSeeAllPress = useCallback(() => { if (!parentId) return; + // Navigate into the library detail (lives in the libraries tab) sorted by most + // recently added. The `fromSeeAll` flag tells the detail page to (a) collapse + // the libraries stack so the native tab can't auto-pop it back to the list, and + // (b) intercept Back to route to the library list so the user can switch + // libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx. router.push({ - pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + pathname: "/[libraryId]", params: { libraryId: parentId, sortBy: SortByOption.DateCreated, sortOrder: SortOrderOption.Descending, + fromSeeAll: "true", }, } as any); }, [router, parentId]); @@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} // contentContainerStyle={{ paddingVertical: SCALE_PADDING }} ListFooterComponent={ + // No fixed width: the footer must size to the "See All" card so the + // FlatList's scrollable content extends to fully reveal it. A fixed + // (narrow) width clipped the card at the right edge. Trailing space is + // provided by contentContainerStyle.paddingRight. {isFetchingNextPage && ( From b8790267452ac1604285b6d6df23f1e31c3b2150 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:57:06 +0200 Subject: [PATCH 05/11] chore(deps): Update dependency expo-doctor to v1.20.0 (#1792) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 86fff885e..03789f894 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.19.9", + "expo-doctor": "1.20.0", "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", @@ -1015,7 +1015,7 @@ "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], - "expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="], + "expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="], "expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="], diff --git a/package.json b/package.json index e4e65831f..86e7eeb55 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.19.9", + "expo-doctor": "1.20.0", "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", From ed8744389bf8baa5d03cdea8b6cbdb2695e46488 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:33:41 +0200 Subject: [PATCH 06/11] chore(deps): Update dependency xcode to v26.6 (#1793) --- .github/workflows/build-apps.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 580dc46d8..4c1bbb916 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -263,7 +263,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.5" + xcode-version: "26.6" - name: ๐Ÿ—๏ธ Setup EAS uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main @@ -335,7 +335,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.5" + xcode-version: "26.6" - name: ๐Ÿš€ Build iOS app env: @@ -402,7 +402,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.5" + xcode-version: "26.6" - name: ๐Ÿ—๏ธ Setup EAS uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main @@ -472,7 +472,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.5" + xcode-version: "26.6" - name: ๐Ÿš€ Build iOS app env: From 680f930a476c9250091a1d69c17c7720bc25aaaa Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 7 Jul 2026 11:46:35 +0200 Subject: [PATCH 07/11] fix(tv): re-negotiate the stream when changing audio track while transcoding (#1791) --- app/(auth)/player/direct-player.tsx | 30 ++++++++++++++++++- app/(auth)/tv-option-modal.tsx | 15 ++++++++++ .../video-player/controls/Controls.tv.tsx | 3 ++ hooks/useTVOptionModal.ts | 2 ++ utils/atoms/tvOptionModal.ts | 9 ++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 877dd1637..515e39d15 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -893,6 +893,27 @@ export default function DirectPlayerPage() { // Check if we're transcoding const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl); + // A transcoded stream only carries the audio track the server encoded + // into it โ€” switching requires re-negotiating the stream with the new + // index (like the mobile menu's replacePlayer), not an mpv aid change. + if (isTranscoding) { + const queryParams = new URLSearchParams({ + itemId: item?.Id ?? "", + audioIndex: String(index), + subtitleIndex: String(currentSubtitleIndex), + mediaSourceId: stream?.mediaSource?.Id ?? "", + bitrateValue: bitrateValue?.toString() ?? "", + playbackPosition: msToTicks(progress.get()).toString(), + }).toString(); + // Destroy the current mpv instance BEFORE navigating, same rationale as + // goToNextItem/goToPreviousItem: Expo Router briefly holds two players + // during the transition, and two simultaneous decoders OOM-kill low-RAM + // devices. Resume is preserved via the playbackPosition param. + videoRef.current?.destroy().catch(() => {}); + router.replace(`player/direct-player?${queryParams}` as any); + return; + } + // Convert Jellyfin index to MPV track ID const mpvTrackId = getMpvAudioId( stream?.mediaSource, @@ -904,7 +925,14 @@ export default function DirectPlayerPage() { await videoRef.current?.setAudioTrack?.(mpvTrackId); } }, - [stream?.mediaSource], + [ + stream?.mediaSource, + item?.Id, + currentSubtitleIndex, + bitrateValue, + router, + progress, + ], ); // TV subtitle track change handler diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx index 180228e36..442a29ec5 100644 --- a/app/(auth)/tv-option-modal.tsx +++ b/app/(auth)/tv-option-modal.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Animated, Easing, + InteractionManager, ScrollView, StyleSheet, TVFocusGuideView, @@ -75,6 +76,20 @@ export default function TVOptionModal() { }, [isReady]); const handleSelect = (value: any) => { + if (modalState?.deferApplyUntilDismissed) { + // onSelect navigates (the transcode audio switch replacing the player); + // a router.replace fired while this modal is the active route would be + // swallowed. Close FIRST, apply after dismissal. + const onSelect = modalState.onSelect; + store.set(tvOptionModalAtom, null); + router.back(); + InteractionManager.runAfterInteractions(() => onSelect?.(value)); + return; + } + // State-only callers (detail page, library filters, settings): run before + // closing so the re-render happens while the modal is up. Deferring it until + // after dismissal re-renders the page after focus returns and yanks TV + // focus, leaving navigation stuck. modalState?.onSelect(value); store.set(tvOptionModalAtom, null); router.back(); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index a85f62154..29e5d1f03 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -564,6 +564,9 @@ export const Controls: FC = ({ title: t("item_card.audio"), options: audioOptions, onSelect: handleAudioChange, + // In-player audio selection navigates (replacePlayer while transcoding); + // apply it after the modal is dismissed so it isn't swallowed. + deferApplyUntilDismissed: true, }); controlsInteractionRef.current(); }, [showOptions, t, audioOptions, handleAudioChange]); diff --git a/hooks/useTVOptionModal.ts b/hooks/useTVOptionModal.ts index c6acffe83..68db4de8d 100644 --- a/hooks/useTVOptionModal.ts +++ b/hooks/useTVOptionModal.ts @@ -12,6 +12,7 @@ interface ShowOptionsParams { onSelect: (value: T) => void; cardWidth?: number; cardHeight?: number; + deferApplyUntilDismissed?: boolean; } export const useTVOptionModal = () => { @@ -26,6 +27,7 @@ export const useTVOptionModal = () => { onSelect: params.onSelect, cardWidth: params.cardWidth, cardHeight: params.cardHeight, + deferApplyUntilDismissed: params.deferApplyUntilDismissed, }); router.push("/(auth)/tv-option-modal"); }, diff --git a/utils/atoms/tvOptionModal.ts b/utils/atoms/tvOptionModal.ts index 74bc2ab54..c6fe230b2 100644 --- a/utils/atoms/tvOptionModal.ts +++ b/utils/atoms/tvOptionModal.ts @@ -13,6 +13,15 @@ export type TVOptionModalState = { onSelect: (value: any) => void; cardWidth?: number; cardHeight?: number; + /** + * Run onSelect AFTER the modal route is dismissed. Needed only when onSelect + * navigates (the in-player audio switch replacing the player while + * transcoding), which the still-active modal route would otherwise swallow. + * Default (false) runs onSelect before closing, so state-only callers (detail + * page, library filters, settings) don't re-render after focus returns and + * lose TV focus. + */ + deferApplyUntilDismissed?: boolean; } | null; export const tvOptionModalAtom = atom(null); From 77156630d6de5bf678bde7515962170f67ae17e6 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 7 Jul 2026 23:26:52 +0200 Subject: [PATCH 08/11] ci: run independent workflow steps in parallel (#1788) --- .github/workflows/build-apps.yml | 376 ++++++++++++------------ .github/workflows/check-lockfile.yml | 24 +- .github/workflows/detect-duplicate.yml | 15 +- .github/workflows/linting.yml | 98 +++--- .github/workflows/pr-title-comment.yml | 76 +++++ .github/workflows/pr-title.yml | 42 +++ .github/workflows/release.yml | 39 +-- .github/workflows/update-issue-form.yml | 25 +- 8 files changed, 404 insertions(+), 291 deletions(-) create mode 100644 .github/workflows/pr-title-comment.yml create mode 100644 .github/workflows/pr-title.yml diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 4c1bbb916..0be3084f6 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -30,64 +30,66 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ—‘๏ธ Free Disk Space - uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - with: - tool-cache: false - mandb: true - android: false - dotnet: true - haskell: true - large-packages: false - docker-images: true - swap-storage: false + - parallel: + - name: ๐Ÿ—‘๏ธ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun- + - name: โ˜• Set up JDK 17 + # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build + # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure + # fail). Pin Temurin 17 for a deterministic Android build. + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: temurin + java-version: "17" + + - parallel: + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/.gradle/caches/modules-2 + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-gradle- - name: ๐Ÿ“ฆ Install dependencies and reload submodules run: | bun install --frozen-lockfile bun run submodule-reload - - name: โ˜• Set up JDK 17 - # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build - # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure - # fail). Pin Temurin 17 for a deterministic Android build. - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 - with: - distribution: temurin - java-version: "17" - - - name: ๐Ÿ’พ Cache Gradle global - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: | - ~/.gradle/caches/modules-2 - ~/.gradle/wrapper - key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-gradle- - - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild @@ -130,64 +132,66 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ—‘๏ธ Free Disk Space - uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - with: - tool-cache: false - mandb: true - android: false - dotnet: true - haskell: true - large-packages: false - docker-images: true - swap-storage: false + - parallel: + - name: ๐Ÿ—‘๏ธ Free Disk Space + uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 + with: + tool-cache: false + mandb: true + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - - name: ๐Ÿ’พ Cache Bun dependencies - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-bun- + - name: โ˜• Set up JDK 17 + # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build + # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure + # fail). Pin Temurin 17 for a deterministic Android build. + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + with: + distribution: temurin + java-version: "17" + + - parallel: + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: ๐Ÿ’พ Cache Gradle global + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/.gradle/caches/modules-2 + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-gradle- - name: ๐Ÿ“ฆ Install dependencies and reload submodules run: | bun install --frozen-lockfile bun run submodule-reload - - name: โ˜• Set up JDK 17 - # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build - # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure - # fail). Pin Temurin 17 for a deterministic Android build. - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 - with: - distribution: temurin - java-version: "17" - - - name: ๐Ÿ’พ Cache Gradle global - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: | - ~/.gradle/caches/modules-2 - ~/.gradle/wrapper - key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-gradle- - - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild:tv @@ -229,19 +233,33 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ”ง Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: ๐Ÿ—๏ธ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -259,19 +277,6 @@ jobs: - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild - - name: ๐Ÿ”ง Setup Xcode - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.6" - - - name: ๐Ÿ—๏ธ Setup EAS - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - - name: ๐Ÿš€ Build iOS app env: EXPO_TV: 0 @@ -301,19 +306,26 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ”ง Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -331,12 +343,6 @@ jobs: - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild - - name: ๐Ÿ”ง Setup Xcode - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.6" - - name: ๐Ÿš€ Build iOS app env: EXPO_TV: 0 @@ -368,19 +374,33 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ”ง Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" + + - name: ๐Ÿ—๏ธ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -398,19 +418,6 @@ jobs: - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild:tv - - name: ๐Ÿ”ง Setup Xcode - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.6" - - - name: ๐Ÿ—๏ธ Setup EAS - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - - name: ๐Ÿš€ Build iOS app env: EXPO_TV: 1 @@ -438,19 +445,26 @@ jobs: actions: write # dispatch artifact-comment.yml to refresh the PR comment steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ”ง Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + # renovate: datasource=custom.xcode depName=xcode versioning=loose + xcode-version: "26.6" - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -468,12 +482,6 @@ jobs: - name: ๐Ÿ› ๏ธ Generate project files run: bun run prebuild:tv - - name: ๐Ÿ”ง Setup Xcode - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.6" - - name: ๐Ÿš€ Build iOS app env: EXPO_TV: 1 diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index d4165055d..d06699306 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -18,19 +18,19 @@ jobs: contents: read steps: - - name: ๐Ÿ“ฅ Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - show-progress: false - submodules: recursive - fetch-depth: 0 + - parallel: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + show-progress: false + submodules: recursive - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml index cab53d615..a70b6ffb3 100644 --- a/.github/workflows/detect-duplicate.yml +++ b/.github/workflows/detect-duplicate.yml @@ -20,14 +20,15 @@ jobs: issues: write contents: read steps: - - name: ๐Ÿ“ฅ Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - parallel: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - name: ๐Ÿ” Detect duplicate issues run: bun scripts/detect-duplicate-issue.ts diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f8799f261..992455862 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -2,7 +2,7 @@ name: ๐Ÿšฆ Security & Quality Gate on: pull_request: - types: [opened, edited, synchronize, reopened] + types: [opened, synchronize, reopened] branches: [develop, master] workflow_dispatch: push: @@ -11,39 +11,11 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - validate_pr_title: - name: "๐Ÿ“ Validate PR Title" - if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 - permissions: - pull-requests: write - contents: read - steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - id: lint_pr_title - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 - if: always() && (steps.lint_pr_title.outputs.error_message != null) - with: - header: pr-title-lint-error - message: | - Hey there and thank you for opening this pull request! ๐Ÿ‘‹๐Ÿผ - We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). - - **Error details:** - ``` - ${{ steps.lint_pr_title.outputs.error_message }} - ``` - - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 - with: - header: pr-title-lint-error - delete: true - dependency-review: name: ๐Ÿ” Vulnerable Dependencies runs-on: ubuntu-26.04 @@ -67,18 +39,26 @@ jobs: name: ๐Ÿš‘ Expo Doctor Check runs-on: ubuntu-26.04 steps: - - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: recursive - fetch-depth: 0 + - parallel: + - name: ๐Ÿ›’ Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- - name: ๐Ÿ“ฆ Install dependencies (bun) run: bun install --frozen-lockfile @@ -103,24 +83,26 @@ jobs: - "i18n:check" steps: - - name: "๐Ÿ“ฅ Checkout PR code" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: recursive - fetch-depth: 0 + - parallel: + - name: "๐Ÿ“ฅ Checkout PR code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive - - name: "๐ŸŸข Setup Node.js" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - # renovate: datasource=node-version depName=node versioning=node - node-version: "24.18.0" + - name: "๐Ÿž Setup Bun" + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - - name: "๐Ÿž Setup Bun" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: ๐Ÿ’พ Cache Bun dependencies + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- - name: "๐Ÿ“ฆ Install dependencies" run: bun install --frozen-lockfile diff --git a/.github/workflows/pr-title-comment.yml b/.github/workflows/pr-title-comment.yml new file mode 100644 index 000000000..a3117f269 --- /dev/null +++ b/.github/workflows/pr-title-comment.yml @@ -0,0 +1,76 @@ +name: ๐Ÿ“ PR Title Comment + +# Posts / updates / deletes the PR-title lint sticky comment with a write token, +# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a +# read-only token on forks). workflow_run runs from the base branch with the base +# repo's token. This is comment-only โ€” the required check stays pr-title.yml. +on: + workflow_run: + workflows: ["๐Ÿ“ PR Title"] + types: [completed] + +permissions: + contents: read + actions: read # download the artifact from the triggering run + pull-requests: write # post/update/delete the sticky comment + +concurrency: + group: pr-title-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-26.04 + steps: + - name: โฌ‡๏ธ Download lint result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "${{ github.event.workflow_run.id }}" \ + --repo "${{ github.repository }}" \ + --name pr-title-result \ + --dir result + + - name: ๐Ÿ”Ž Read result + id: result + run: | + echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT" + if [ -s result/error ]; then + echo "has_error=true" >> "$GITHUB_OUTPUT" + else + echo "has_error=false" >> "$GITHUB_OUTPUT" + fi + + - name: ๐Ÿ“ฅ Load error message + if: steps.result.outputs.has_error == 'true' + run: | + { + echo "LINT_ERROR<> "$GITHUB_ENV" + + - name: ๐Ÿ’ฌ Post title error + if: steps.result.outputs.has_error == 'true' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + number: ${{ steps.result.outputs.number }} + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! ๐Ÿ‘‹๐Ÿผ + We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). + + **Error details:** + ``` + ${{ env.LINT_ERROR }} + ``` + + - name: ๐Ÿงน Clear title error + if: steps.result.outputs.has_error == 'false' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + number: ${{ steps.result.outputs.number }} + header: pr-title-lint-error + delete: true diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..68c503d67 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,42 @@ +name: ๐Ÿ“ PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: [develop, master] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate_pr_title: + name: "๐Ÿ“ Validate PR Title" + runs-on: ubuntu-26.04 + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + id: lint_pr_title + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment + # is posted from the privileged pr-title-comment.yml (workflow_run). Hand + # off the result (PR number + lint error) as an artifact for it to read. + - if: always() + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }} + run: | + mkdir -p pr-title-result + printf '%s' "$PR_NUMBER" > pr-title-result/number + printf '%s' "$LINT_ERROR" > pr-title-result/error + + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-title-result + path: pr-title-result/ + retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 027eab0de..9860d6f53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ name: ๐Ÿš€ Release (EAS build + submit) concurrency: group: release-${{ github.ref }} cancel-in-progress: false + # Queue successive releases in order instead of dropping the extra pending run. + queue: max on: push: @@ -63,18 +65,26 @@ jobs: artifact_name: streamyfin-android-tv-apk steps: - - name: ๐Ÿ“ฅ Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - submodules: recursive - show-progress: false + - parallel: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + submodules: recursive + show-progress: false - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" + + - name: ๐Ÿ—๏ธ Setup EAS + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true - name: ๐Ÿ’พ Cache Bun dependencies uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 @@ -89,13 +99,6 @@ jobs: bun install --frozen-lockfile bun run submodule-reload - - name: ๐Ÿ—๏ธ Setup EAS - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - eas-cache: true - # tvOS uses credentialsSource: local โ€” restore the gitignored # credentials.json + cert + provisioning profiles from secrets. - name: ๐Ÿ” Restore tvOS signing credentials diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 7f1ace97f..db111d560 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -25,19 +25,20 @@ jobs: contents: write pull-requests: write steps: - - name: ๐Ÿ“ฅ Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # On `release` events GITHUB_SHA is the tagged commit โ€” without this the - # script would regenerate the form from the tag's (stale) copy and the bot - # PR would revert any form edits made on develop since that release. - ref: develop + - parallel: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # On `release` events GITHUB_SHA is the tagged commit โ€” without this the + # script would regenerate the form from the tag's (stale) copy and the bot + # PR would revert any form edits made on develop since that release. + ref: develop - - name: ๐Ÿž Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - # renovate: datasource=npm depName=bun - bun-version: "1.3.14" + - name: ๐Ÿž Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + # renovate: datasource=npm depName=bun + bun-version: "1.3.14" - name: ๐Ÿ”ข Populate version dropdown from GitHub releases id: populate From 95f4d15e98bdafdcd172430a7b591bb70f78c887 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 14 Jul 2026 18:23:54 +0200 Subject: [PATCH 09/11] chore: remove unused TrackSheet and TVSubtitleSheet component (#1780) --- components/TrackSheet.tsx | 99 ---- .../video-player/controls/TVSubtitleSheet.tsx | 560 ------------------ translations/en.json | 2 - 3 files changed, 661 deletions(-) delete mode 100644 components/TrackSheet.tsx delete mode 100644 components/video-player/controls/TVSubtitleSheet.tsx diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx deleted file mode 100644 index 52735f6fe..000000000 --- a/components/TrackSheet.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 { - source?: MediaSourceInfo; - onChange: (value: number) => void; - selected?: number | undefined; - streamType?: string; - title: string; -} - -export const TrackSheet: React.FC = ({ - 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 ( - - - {title} - setOpen(true)} - > - - {selected === -1 && streamType === "Subtitle" - ? t("common.none") - : selectedSteam?.DisplayTitle || t("common.select")} - - - - { - const label = (item as any).DisplayTitle || ""; - return label.toLowerCase().includes(query.toLowerCase()); - }} - renderItemLabel={(item) => ( - {(item as any).DisplayTitle || ""} - )} - set={(vals) => { - const chosen = vals[0] as any; - if (chosen && chosen.Index !== null && chosen.Index !== undefined) { - onChange(chosen.Index); - } - }} - /> - - ); -}; diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx deleted file mode 100644 index 6284b5660..000000000 --- a/components/video-player/controls/TVSubtitleSheet.tsx +++ /dev/null @@ -1,560 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { - BaseItemDto, - MediaStream, -} from "@jellyfin/sdk/lib/generated-client"; -import { BlurView } from "expo-blur"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Animated, - Easing, - ScrollView, - StyleSheet, - TVFocusGuideView, - View, -} from "react-native"; -import { Text } from "@/components/common/Text"; -import { - TVCancelButton, - TVLanguageCard, - TVSubtitleResultCard, - TVTabButton, - TVTrackCard, -} from "@/components/tv"; -import { - type SubtitleSearchResult, - useRemoteSubtitles, -} from "@/hooks/useRemoteSubtitles"; -import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; - -interface TVSubtitleSheetProps { - visible: boolean; - item: BaseItemDto; - mediaSourceId?: string | null; - subtitleTracks: MediaStream[]; - currentSubtitleIndex: number; - onSubtitleIndexChange: (index: number) => void; - onClose: () => void; - onServerSubtitleDownloaded?: () => void; - onLocalSubtitleDownloaded?: (path: string) => void; -} - -type TabType = "tracks" | "download"; - -export const TVSubtitleSheet: React.FC = ({ - visible, - item, - mediaSourceId, - subtitleTracks, - currentSubtitleIndex, - onSubtitleIndexChange, - onClose, - onServerSubtitleDownloaded, - onLocalSubtitleDownloaded, -}) => { - const { t } = useTranslation(); - - console.log( - "[TVSubtitleSheet] visible:", - visible, - "tracks:", - subtitleTracks.length, - ); - const [activeTab, setActiveTab] = useState("tracks"); - const [selectedLanguage, setSelectedLanguage] = useState("eng"); - const [downloadingId, setDownloadingId] = useState(null); - const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false); - const [isReady, setIsReady] = useState(false); - const [isTabContentReady, setIsTabContentReady] = useState(false); - const firstTrackRef = useRef(null); - - const { - hasOpenSubtitlesApiKey, - isSearching, - searchError, - searchResults, - search, - downloadAsync, - reset, - } = useRemoteSubtitles({ - itemId: item.Id ?? "", - item, - mediaSourceId, - }); - - const resetRef = useRef(reset); - resetRef.current = reset; - - const overlayOpacity = useRef(new Animated.Value(0)).current; - const sheetTranslateY = useRef(new Animated.Value(300)).current; - - const initialSelectedTrackIndex = useMemo(() => { - if (currentSubtitleIndex === -1) return 0; - const trackIdx = subtitleTracks.findIndex( - (t) => t.Index === currentSubtitleIndex, - ); - return trackIdx >= 0 ? trackIdx + 1 : 0; - }, [subtitleTracks, currentSubtitleIndex]); - - useEffect(() => { - if (visible) { - overlayOpacity.setValue(0); - sheetTranslateY.setValue(300); - - Animated.parallel([ - Animated.timing(overlayOpacity, { - toValue: 1, - duration: 250, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(sheetTranslateY, { - toValue: 0, - duration: 300, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - ]).start(); - } - }, [visible, overlayOpacity, sheetTranslateY]); - - useEffect(() => { - if (!visible) { - setHasSearchedThisSession(false); - setActiveTab("tracks"); - resetRef.current(); - setIsReady(false); - } - }, [visible]); - - useEffect(() => { - if (visible) { - const timer = setTimeout(() => setIsReady(true), 100); - return () => clearTimeout(timer); - } - setIsReady(false); - }, [visible]); - - useEffect(() => { - if (visible && activeTab === "download" && !hasSearchedThisSession) { - search({ language: selectedLanguage }); - setHasSearchedThisSession(true); - } - }, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]); - - useEffect(() => { - if (isReady) { - setIsTabContentReady(false); - const timer = setTimeout(() => setIsTabContentReady(true), 50); - return () => clearTimeout(timer); - } - setIsTabContentReady(false); - }, [activeTab, isReady]); - - const handleLanguageSelect = useCallback( - (code: string) => { - setSelectedLanguage(code); - search({ language: code }); - }, - [search], - ); - - const handleTrackSelect = useCallback( - (index: number) => { - onSubtitleIndexChange(index); - onClose(); - }, - [onSubtitleIndexChange, onClose], - ); - - const handleDownload = useCallback( - async (result: SubtitleSearchResult) => { - setDownloadingId(result.id); - - try { - const downloadResult = await downloadAsync(result); - - if (downloadResult.type === "server") { - onServerSubtitleDownloaded?.(); - } else if (downloadResult.type === "local" && downloadResult.path) { - onLocalSubtitleDownloaded?.(downloadResult.path); - } - - onClose(); - } catch (error) { - console.error("Failed to download subtitle:", error); - } finally { - setDownloadingId(null); - } - }, - [ - downloadAsync, - onServerSubtitleDownloaded, - onLocalSubtitleDownloaded, - onClose, - ], - ); - - const displayLanguages = useMemo( - () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), - [], - ); - - const trackOptions = useMemo(() => { - const noneOption = { - label: t("item_card.subtitles.none"), - sublabel: undefined as string | undefined, - value: -1, - selected: currentSubtitleIndex === -1, - }; - const options = subtitleTracks.map((track) => ({ - label: - track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, - sublabel: track.Codec?.toUpperCase(), - value: track.Index!, - selected: track.Index === currentSubtitleIndex, - })); - return [noneOption, ...options]; - }, [subtitleTracks, currentSubtitleIndex, t]); - - if (!visible) return null; - - return ( - - - - - {/* Header with tabs */} - - - {t("item_card.subtitles.label") || "Subtitles"} - - - {/* Tab bar */} - - setActiveTab("tracks")} - /> - setActiveTab("download")} - /> - - - - {/* Tracks Tab Content */} - {activeTab === "tracks" && isTabContentReady && ( - - - {trackOptions.map((option, index) => ( - handleTrackSelect(option.value)} - /> - ))} - - - )} - - {/* Download Tab Content */} - {activeTab === "download" && isTabContentReady && ( - <> - {/* Language Selector */} - - - {t("player.language") || "Language"} - - - {displayLanguages.map((lang, index) => ( - handleLanguageSelect(lang.code)} - /> - ))} - - - - {/* Results Section */} - - - {t("player.results") || "Results"} - {searchResults && ` (${searchResults.length})`} - - - {/* Loading state */} - {isSearching && ( - - - - {t("player.searching") || "Searching..."} - - - )} - - {/* Error state */} - {searchError && !isSearching && ( - - - - {t("player.search_failed") || "Search failed"} - - - {!hasOpenSubtitlesApiKey - ? t("player.no_subtitle_provider") || - "No subtitle provider configured on server" - : String(searchError)} - - - )} - - {/* No results */} - {searchResults && - searchResults.length === 0 && - !isSearching && - !searchError && ( - - - - {t("player.no_subtitles_found") || - "No subtitles found"} - - - )} - - {/* Results list */} - {searchResults && - searchResults.length > 0 && - !isSearching && ( - - {searchResults.map((result, index) => ( - handleDownload(result)} - /> - ))} - - )} - - - {/* API Key hint if no fallback available */} - {!hasOpenSubtitlesApiKey && ( - - - - {t("player.add_opensubtitles_key_hint") || - "Add OpenSubtitles API key in settings for client-side fallback"} - - - )} - - )} - - {/* Cancel button */} - {isReady && ( - - - - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - overlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.6)", - justifyContent: "flex-end", - zIndex: 1000, - }, - sheetContainer: { - maxHeight: "70%", - }, - blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: "hidden", - }, - content: { - paddingTop: 24, - paddingBottom: 48, - }, - header: { - paddingHorizontal: 48, - marginBottom: 20, - }, - title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", - marginBottom: 16, - }, - tabRow: { - flexDirection: "row", - gap: 24, - }, - section: { - marginBottom: 20, - }, - sectionTitle: { - fontSize: 14, - fontWeight: "500", - color: "rgba(255,255,255,0.5)", - textTransform: "uppercase", - letterSpacing: 1, - marginBottom: 12, - paddingHorizontal: 48, - }, - tracksScroll: { - overflow: "visible", - }, - tracksScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 12, - }, - languageScroll: { - overflow: "visible", - }, - languageScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 10, - }, - resultsScroll: { - overflow: "visible", - }, - resultsScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 12, - }, - loadingContainer: { - paddingVertical: 40, - alignItems: "center", - }, - loadingText: { - color: "rgba(255,255,255,0.6)", - marginTop: 12, - fontSize: 14, - }, - errorContainer: { - paddingVertical: 40, - paddingHorizontal: 48, - alignItems: "center", - }, - errorText: { - color: "rgba(255,100,100,0.9)", - marginTop: 8, - fontSize: 16, - fontWeight: "500", - }, - errorHint: { - color: "rgba(255,255,255,0.5)", - marginTop: 4, - fontSize: 13, - textAlign: "center", - }, - emptyContainer: { - paddingVertical: 40, - alignItems: "center", - }, - emptyText: { - color: "rgba(255,255,255,0.5)", - marginTop: 8, - fontSize: 14, - }, - apiKeyHint: { - flexDirection: "row", - alignItems: "center", - gap: 8, - paddingHorizontal: 48, - paddingTop: 8, - }, - apiKeyHintText: { - color: "rgba(255,255,255,0.4)", - fontSize: 12, - }, - cancelButtonContainer: { - paddingHorizontal: 48, - paddingTop: 20, - alignItems: "flex-start", - }, -}); diff --git a/translations/en.json b/translations/en.json index 3c4271b1a..ba3ffcebf 100644 --- a/translations/en.json +++ b/translations/en.json @@ -484,7 +484,6 @@ }, "common": { "no_results": "No results", - "select": "Select", "no_trailer_available": "No trailer available", "video": "Video", "audio": "Audio", @@ -621,7 +620,6 @@ "using_jellyfin_server": "Using Jellyfin server", "language": "Language", "results": "Results", - "searching": "Searching...", "search_failed": "Search failed", "no_subtitle_provider": "No subtitle provider configured on server", "no_subtitles_found": "No subtitles found", From 8d54d65b63ec98bff4aed7217a9d1117e7e97101 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:04:15 +0200 Subject: [PATCH 10/11] feat: adding episode count indicator (#1787) Co-authored-by: Gauvain Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- components/WatchedIndicator.tsx | 130 ++++++++++++++++++++++------ components/posters/ItemPoster.tsx | 2 +- components/posters/SeriesPoster.tsx | 2 + components/tv/TVPosterCard.tsx | 13 ++- 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index 0d2999f53..145ee7e0e 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,43 +1,119 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import type React from "react"; -import { Platform, View } from "react-native"; +import React from "react"; +import { Platform, View, type ViewStyle } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; -export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { - if (Platform.isTV) { - // TV: Show white checkmark when watched - if ( - item.UserData?.Played && - (item.Type === "Movie" || item.Type === "Episode") - ) { +const isAggregateType = (item: BaseItemDto) => + item.Type === "Series" || item.Type === "BoxSet"; + +// TV sizes are scaled relative to a 1920ร—1080 reference (see scaleSize). +const tvBadgeBase: ViewStyle = { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + height: scaleSize(28), + borderRadius: scaleSize(14), + backgroundColor: "rgba(255,255,255,0.92)", + alignItems: "center", + justifyContent: "center", +}; + +// Mobile uses raw dp โ€” no scaling. +const mobileBadgeBase: ViewStyle = { + position: "absolute", + top: 4, + right: 4, + height: 20, + borderRadius: 10, + backgroundColor: "#9333ea", + alignItems: "center", + justifyContent: "center", +}; + +/** + * Renders the unplayed-episode count badge for Series/BoxSet items that still + * have episodes left to watch. Returns null for non-aggregate types, fully + * watched items, or items with no unplayed count, so it is safe to mount + * unconditionally as an overlay (e.g. on top of the tvOS glass poster, where + * the watched checkmark is drawn natively and only the count needs RN). + */ +export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo( + ({ item }) => { + if (!isAggregateType(item)) return null; + if (item.UserData?.Played) return null; + const unplayed = item.UserData?.UnplayedItemCount ?? 0; + if (unplayed <= 0) return null; + // Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+). + const label = unplayed >= 1000 ? "1k+" : String(unplayed); + + if (Platform.isTV) { return ( - + + {label} + ); } - return null; + + return ( + + + {label} + + + ); + }, +); + +export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode"; + const isAggregate = isAggregateType(item); + const isPlayed = item.UserData?.Played === true; + + if (Platform.isTV) { + // Fully watched โ†’ white checkmark badge (top-right) + if (isPlayed && (isMovieOrEpisode || isAggregate)) { + return ( + + + + ); + } + // Series/BoxSet with remaining episodes โ†’ count badge + return ; } - // Mobile: Show purple triangle for unwatched + // Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior) return ( <> - {item.UserData?.Played === false && - (item.Type === "Movie" || item.Type === "Episode") && ( - - )} + {/* Strict === false: items without UserData (unknown state) get no ribbon */} + {isMovieOrEpisode && item.UserData?.Played === false && ( + + )} + + {/* Fully watched Series/BoxSet โ†’ small purple checkmark */} + {isAggregate && isPlayed && ( + + + + )} + + {/* Series/BoxSet with remaining episodes โ†’ count badge */} + ); }; diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx index bcd857e79..9990727be 100644 --- a/components/posters/ItemPoster.tsx +++ b/components/posters/ItemPoster.tsx @@ -1,8 +1,8 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useState } from "react"; import { View, type ViewProps } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { ItemImage } from "../common/ItemImage"; -import { WatchedIndicator } from "../WatchedIndicator"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index 07f212d7f..82cf146c7 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => { width: "100%", }} /> + ); }; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6c20075fd..9a24a5034 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -12,7 +12,10 @@ import { } from "react-native"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { + UnplayedCountBadge, + WatchedIndicator, +} from "@/components/WatchedIndicator"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { @@ -427,6 +430,12 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} + {/* + The glass view draws the watched checkmark natively but cannot show + an unplayed-episode count, so render it as an RN overlay on top. + Returns null when not applicable (non-series / fully watched). + */} + {showWatchedIndicator && } ); } @@ -459,7 +468,7 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} - + {showWatchedIndicator && } ); From 2df160adb7eb0c8b02db2d0727a9f37bd63279c8 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 14 Jul 2026 19:45:42 +0200 Subject: [PATCH 11/11] ci: skip PR title comment when the lint run was cancelled (#1803) --- .github/workflows/pr-title-comment.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-title-comment.yml b/.github/workflows/pr-title-comment.yml index a3117f269..648eb1c44 100644 --- a/.github/workflows/pr-title-comment.yml +++ b/.github/workflows/pr-title-comment.yml @@ -20,7 +20,12 @@ concurrency: jobs: comment: - if: github.event.workflow_run.event == 'pull_request' + # Only run when the lint run actually produced a result artifact: a run + # cancelled by concurrency (rapid pushes to the same PR) completes with + # conclusion "cancelled" before uploading it, and would fail the download. + if: > + github.event.workflow_run.event == 'pull_request' && + contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-26.04 steps: - name: โฌ‡๏ธ Download lint result