mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 17:42:51 +01:00
Compare commits
24 Commits
autoskip
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fcb303c6 | ||
|
|
28a75a2b8c | ||
|
|
90e9084949 | ||
|
|
aa0eb0a655 | ||
|
|
115c163aeb | ||
|
|
286a3cad47 | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97b6a912e0 | ||
|
|
cc0007926d | ||
|
|
9e29305e28 | ||
|
|
ae9c05637b | ||
|
|
f820bedf6e | ||
|
|
47c5d61f28 | ||
|
|
517bc7bbb5 | ||
|
|
b256e99fc8 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Coding Standards
|
## 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
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
22
.github/workflows/build-apps.yml
vendored
22
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
# run (artifacts + logs) without needing Expo access.
|
# run (artifacts + logs) without needing Expo access.
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -157,7 +157,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -179,7 +179,7 @@ jobs:
|
|||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -244,7 +244,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -316,7 +316,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -383,7 +383,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -453,7 +453,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|||||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
|||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
# renovate: datasource=node-version depName=node versioning=node
|
||||||
node-version: "24.17.0"
|
node-version: "24.18.0"
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
|
||||||
/iosmobile
|
|
||||||
/androidmobile
|
|
||||||
/androidtv
|
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## 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 functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
// Registers the tsx require hook so the TypeScript config plugins referenced
|
||||||
|
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
||||||
|
import "tsx/cjs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
const git = (args) => {
|
const git = (args: string[]): string | null => {
|
||||||
try {
|
try {
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
@@ -42,16 +46,16 @@ const buildMeta = {
|
|||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ({ config }) => {
|
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins?.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
cameraPermission:
|
cameraPermission:
|
||||||
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
const androidConfig = {};
|
const androidConfig: { googleServicesFile?: string } = {};
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
|
|||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
};
|
} as ExpoConfig;
|
||||||
};
|
};
|
||||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.ts",
|
||||||
"./plugins/withTVUserManagement.js",
|
"./plugins/withTVUserManagement.ts",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -134,17 +134,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
["./plugins/with-runtime-framework-headers.ts"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||||
["./plugins/withAndroidAlertColors.js"],
|
["./plugins/withAndroidAlertColors.ts"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.ts"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.ts"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.ts"],
|
||||||
["./plugins/withTVOSAppIcon.js"],
|
["./plugins/withTVOSAppIcon.ts"],
|
||||||
["./plugins/withTVOSTopShelf.js"],
|
["./plugins/withTVOSTopShelf.ts"],
|
||||||
["./plugins/withTVXcodeEnv.js"],
|
["./plugins/withTVXcodeEnv.ts"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.ts",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
type SegmentSkipMode,
|
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
@@ -48,22 +47,6 @@ import {
|
|||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||||
|
|
||||||
const SEGMENT_SKIP_ROWS: {
|
|
||||||
key:
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview";
|
|
||||||
labelKey: string;
|
|
||||||
}[] = [
|
|
||||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
|
||||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
|
||||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
|
||||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
|
||||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -552,30 +535,6 @@ export default function SettingsTV() {
|
|||||||
);
|
);
|
||||||
}, [inactivityTimeoutOptions, t]);
|
}, [inactivityTimeoutOptions, t]);
|
||||||
|
|
||||||
// Segment skip: same auto/ask/none choice for every segment type.
|
|
||||||
const segmentSkipModeLabel = (mode: SegmentSkipMode) =>
|
|
||||||
t(`home.settings.other.segment_skip_${mode}`);
|
|
||||||
|
|
||||||
const buildSegmentSkipOptions = (
|
|
||||||
current: SegmentSkipMode,
|
|
||||||
): TVOptionItem<SegmentSkipMode>[] => [
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_auto"),
|
|
||||||
value: "auto",
|
|
||||||
selected: current === "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_ask"),
|
|
||||||
value: "ask",
|
|
||||||
selected: current === "ask",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_none"),
|
|
||||||
value: "none",
|
|
||||||
selected: current === "none",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -860,30 +819,6 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v} MB`}
|
formatValue={(v) => `${v} MB`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Segment Skip Section */}
|
|
||||||
<TVSectionHeader
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
/>
|
|
||||||
{SEGMENT_SKIP_ROWS.map((row, index) => {
|
|
||||||
const current = (settings[row.key] ?? "ask") as SegmentSkipMode;
|
|
||||||
const rowLabel = t(`home.settings.other.${row.labelKey}`);
|
|
||||||
return (
|
|
||||||
<TVSettingsOptionButton
|
|
||||||
key={row.key}
|
|
||||||
label={rowLabel}
|
|
||||||
value={segmentSkipModeLabel(current)}
|
|
||||||
isFirst={index === 0}
|
|
||||||
onPress={() =>
|
|
||||||
showOptions({
|
|
||||||
title: rowLabel,
|
|
||||||
options: buildSegmentSkipOptions(current),
|
|
||||||
onSelect: (value) => updateSettings({ [row.key]: value }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
type SkipSettingKey =
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview";
|
|
||||||
|
|
||||||
const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
|
|
||||||
{ key: "skipIntro", labelKey: "skip_intro" },
|
|
||||||
{ key: "skipOutro", labelKey: "skip_outro" },
|
|
||||||
{ key: "skipRecap", labelKey: "skip_recap" },
|
|
||||||
{ key: "skipCommercial", labelKey: "skip_commercial" },
|
|
||||||
{ key: "skipPreview", labelKey: "skip_preview" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SEGMENT_SKIP_OPTIONS = (
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
): Array<{ label: string; value: SegmentSkipMode }> => [
|
|
||||||
{ label: t("home.settings.other.segment_skip_auto"), value: "auto" },
|
|
||||||
{ label: t("home.settings.other.segment_skip_ask"), value: "ask" },
|
|
||||||
{ label: t("home.settings.other.segment_skip_none"), value: "none" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SegmentSkipPage() {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: t("home.settings.other.segment_skip_settings"),
|
|
||||||
});
|
|
||||||
}, [navigation, t]);
|
|
||||||
|
|
||||||
const options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
{SEGMENTS.map(({ key, labelKey }) => {
|
|
||||||
const current = settings[key];
|
|
||||||
const locked = pluginSettings?.[key]?.locked ?? false;
|
|
||||||
const groups = [
|
|
||||||
{
|
|
||||||
options: options.map((o) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: o.label,
|
|
||||||
value: o.value,
|
|
||||||
selected: o.value === current,
|
|
||||||
disabled: locked,
|
|
||||||
onPress: () => {
|
|
||||||
if (locked) return;
|
|
||||||
updateSettings({ [key]: o.value });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
key={key}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
subtitle={t(`home.settings.other.${labelKey}_description`)}
|
|
||||||
disabled={locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${current}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t(`home.settings.other.${labelKey}`)}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
BackHandler,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -80,8 +85,9 @@ const Page = () => {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
filterBy?: string;
|
filterBy?: string;
|
||||||
|
fromSeeAll?: string;
|
||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId, fromSeeAll } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -112,6 +118,22 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
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();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
@@ -269,6 +291,23 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
}, [library]);
|
}, [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(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
|
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
||||||
|
textColor: "#fff",
|
||||||
// Android: placeholder and icon color
|
// Android: placeholder and icon color
|
||||||
hintTextColor: "#fff",
|
hintTextColor: "#fff",
|
||||||
headerIconColor: "#fff",
|
headerIconColor: "#fff",
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
||||||
|
import { TVNavBar } from "@/components/tv/TVNavBar";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
isTabRoute,
|
||||||
|
useTVHomeBackHandler,
|
||||||
|
useTVTabRootBackHandler,
|
||||||
|
} from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
|
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
||||||
|
|
||||||
|
function TVTabLayout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const currentTab = segments.find(isTabRoute);
|
||||||
|
const lastSegment = segments[segments.length - 1] ?? "";
|
||||||
|
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
|
|
||||||
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "(home)", label: t("tabs.home") },
|
||||||
|
{ key: "(search)", label: t("tabs.search") },
|
||||||
|
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||||
|
? null
|
||||||
|
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||||
|
{ key: "(libraries)", label: t("tabs.library") },
|
||||||
|
!settings?.showCustomMenuLinks
|
||||||
|
? null
|
||||||
|
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||||
|
{ key: "(settings)", label: t("tabs.settings") },
|
||||||
|
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||||
|
[
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
settings?.hideWatchlistsTab,
|
||||||
|
settings?.showCustomMenuLinks,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTabKey = currentTab ?? "(home)";
|
||||||
|
|
||||||
|
const visibleKeys = useMemo(
|
||||||
|
() => new Set(tabs.map((tab) => tab.key)),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === currentTab) return;
|
||||||
|
|
||||||
|
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||||
|
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/${key}`);
|
||||||
|
},
|
||||||
|
[currentTab, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateHome = useCallback(() => {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}, [router]);
|
||||||
|
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||||
|
|
||||||
|
// If current tab is no longer visible (setting changed), navigate to home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}
|
||||||
|
}, [visibleKeys, activeTabKey, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<SystemBars hidden={false} style='light' />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{ headerShown: false, animation: "none" }}
|
||||||
|
initialRouteName='(home)'
|
||||||
|
>
|
||||||
|
<Stack.Screen name='index' redirect />
|
||||||
|
</Stack>
|
||||||
|
<TVNavBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabKey={activeTabKey}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Handle TV back button - prevent app exit when at root
|
// Must be called before any conditional return (rules of hooks)
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
|
if (IS_ANDROID_TV) {
|
||||||
|
return <TVTabLayout />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
getMpvSubtitleId,
|
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -456,10 +456,23 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||||
|
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
||||||
|
// episode's player) mounts while the old 4K decoder is still alive,
|
||||||
|
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
||||||
|
// later React unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
// Pre-libmpv-1.0 used `stop()`:
|
||||||
|
// videoRef.current?.stop();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
|
// Release the keep-awake wakelock acquired during playback so it
|
||||||
|
// doesn't follow us back to the home screen and block the TV
|
||||||
|
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
||||||
|
// and only released on the "paused" event; without this, navigating
|
||||||
|
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
||||||
|
deactivateKeepAwake();
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -626,12 +639,9 @@ export default function DirectPlayerPage() {
|
|||||||
).map((s) => s.DeliveryUrl!);
|
).map((s) => s.DeliveryUrl!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||||
const initialSubtitleId = getMpvSubtitleId(
|
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||||
mediaSource,
|
// list is known — see applySubtitleSelection / onTracksReady.
|
||||||
subtitleIndex,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -649,7 +659,6 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -697,7 +706,6 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -895,30 +903,41 @@ export default function DirectPlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
|
/**
|
||||||
|
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||||
|
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||||
|
* stays correct across external/embedded reordering and server-hidden embedded
|
||||||
|
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||||
|
* fired again after each external sub-add) and runtime changes.
|
||||||
|
*/
|
||||||
|
const applySubtitleSelection = useCallback(
|
||||||
|
async (jellyfinSubtitleIndex: number) => {
|
||||||
|
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
await applyMpvSubtitleSelection(videoRef.current, {
|
||||||
|
subtitleStreams,
|
||||||
|
jellyfinSubtitleIndex,
|
||||||
|
// The exact URL each external sub was loaded into MPV with — mirrors the
|
||||||
|
// externalSubtitles array built in videoSource (online: basePath +
|
||||||
|
// DeliveryUrl, offline: local DeliveryUrl).
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[stream?.mediaSource, offline, api?.basePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV/mobile subtitle track change handler
|
||||||
const handleSubtitleIndexChange = useCallback(
|
const handleSubtitleIndexChange = useCallback(
|
||||||
async (index: number) => {
|
async (index: number) => {
|
||||||
setCurrentSubtitleIndex(index);
|
setCurrentSubtitleIndex(index);
|
||||||
|
await applySubtitleSelection(index);
|
||||||
// Check if we're transcoding
|
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
// Disable subtitles
|
|
||||||
await videoRef.current?.disableSubtitles?.();
|
|
||||||
} else {
|
|
||||||
// Convert Jellyfin index to MPV track ID
|
|
||||||
const mpvTrackId = getMpvSubtitleId(
|
|
||||||
stream?.mediaSource,
|
|
||||||
index,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
|
||||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[applySubtitleSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1105,6 +1124,15 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
||||||
|
// decoder + surface buffers are freed before the new player screen
|
||||||
|
// mounts. Without this, Expo Router briefly holds two simultaneous
|
||||||
|
// mpv instances during the transition (~768 MB of surface buffers
|
||||||
|
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
||||||
|
// devices. Native stop() is idempotent so the subsequent React
|
||||||
|
// unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1115,6 +1143,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
|
videoRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1273,6 +1302,10 @@ export default function DirectPlayerPage() {
|
|||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
|
// Fired after embedded tracks enumerate and again after each
|
||||||
|
// external sub-add; re-resolve so the final fire (full track
|
||||||
|
// list) selects the right track by identity.
|
||||||
|
void applySubtitleSelection(currentSubtitleIndex);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hasPlaybackStarted && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
@@ -100,6 +101,22 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
||||||
|
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
||||||
|
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
||||||
|
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
||||||
|
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
||||||
|
try {
|
||||||
|
Image.configureCache({
|
||||||
|
maxMemoryCost: Platform.isTV
|
||||||
|
? 8 * 1024 * 1024 // ~8 MB on TV
|
||||||
|
: 128 * 1024 * 1024, // ~128 MB on mobile
|
||||||
|
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
||||||
|
}
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
61
bun.lock
61
bun.lock
@@ -111,8 +111,9 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.7",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -294,6 +295,58 @@
|
|||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||||
|
|
||||||
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
||||||
@@ -908,6 +961,8 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
@@ -1270,7 +1325,7 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
|
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
|
||||||
|
|
||||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||||
|
|
||||||
@@ -1808,6 +1863,8 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -232,12 +233,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||||
|
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ?? [];
|
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data
|
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||||
const streams =
|
// (embedded first, externals last) — same as the initial list.
|
||||||
mediaSource?.MediaStreams?.filter(
|
const streams = [
|
||||||
|
...(mediaSource?.MediaStreams?.filter(
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? [];
|
) ?? []),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
[
|
||||||
|
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
(x) => x.Type === "Subtitle",
|
(x) => x.Type === "Subtitle",
|
||||||
) || [],
|
) || []),
|
||||||
|
].sort(compareTracksForMenu),
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||||
|
// decoded ARGB) is too large to pin in the memory cache on every
|
||||||
|
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleSeeAllPress = useCallback(() => {
|
const handleSeeAllPress = useCallback(() => {
|
||||||
if (!parentId) return;
|
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({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
pathname: "/[libraryId]",
|
||||||
params: {
|
params: {
|
||||||
libraryId: parentId,
|
libraryId: parentId,
|
||||||
sortBy: SortByOption.DateCreated,
|
sortBy: SortByOption.DateCreated,
|
||||||
sortOrder: SortOrderOption.Descending,
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
fromSeeAll: "true",
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
@@ -326,9 +332,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={5}
|
initialNumToRender={4}
|
||||||
maxToRenderPerBatch={3}
|
maxToRenderPerBatch={2}
|
||||||
windowSize={5}
|
windowSize={3}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||||
ListFooterComponent={
|
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.
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: sizes.padding.horizontal,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|||||||
@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
|
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
||||||
|
// out of the memory cache avoids bloat when the user cycles through
|
||||||
|
// hero items quickly.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
@@ -379,7 +382,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
const tvosTopPadding = scaleSize(145);
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -253,15 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{/* Media Segment Skip Settings */}
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
|
||||||
onPress={() => router.push("/settings/segment-skip/page")}
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
155
components/tv/TVNavBar.tsx
Normal file
155
components/tv/TVNavBar.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { TVPadding } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
|
export interface TVNavBarTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVNavBarProps {
|
||||||
|
tabs: TVNavBarTab[];
|
||||||
|
activeTabKey: string;
|
||||||
|
onTabChange: (key: string) => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVNavBarTabItem: React.FC<{
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onLayout: (e: {
|
||||||
|
nativeEvent: { layout: { x: number; width: number } };
|
||||||
|
}) => void;
|
||||||
|
hasTVPreferredFocus: boolean;
|
||||||
|
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bg = focused
|
||||||
|
? "rgba(255, 255, 255, 0.95)"
|
||||||
|
: isActive
|
||||||
|
? "rgba(255, 255, 255, 0.15)"
|
||||||
|
: "transparent";
|
||||||
|
|
||||||
|
const textColor = focused
|
||||||
|
? "#000"
|
||||||
|
: isActive
|
||||||
|
? "#fff"
|
||||||
|
: "rgba(255, 255, 255, 0.7)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
onLayout={onLayout}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: scaleSize(24),
|
||||||
|
borderWidth: isActive && !focused ? 1 : 0,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
paddingHorizontal: scaleSize(28),
|
||||||
|
paddingVertical: scaleSize(14),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: isActive || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
||||||
|
tabs,
|
||||||
|
activeTabKey,
|
||||||
|
onTabChange,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const scrollRef = React.useRef<ScrollView>(null);
|
||||||
|
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleTabLayout = React.useCallback(
|
||||||
|
(key: string) =>
|
||||||
|
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
|
||||||
|
tabLayouts.current[key] = e.nativeEvent.layout;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = React.useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
onTabChange(key);
|
||||||
|
|
||||||
|
const layout = tabLayouts.current[key];
|
||||||
|
if (layout && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: scaleSize(12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TVNavBarTabItem
|
||||||
|
key={tab.key}
|
||||||
|
label={tab.label}
|
||||||
|
isActive={tab.key === activeTabKey}
|
||||||
|
onSelect={() => handleTabChange(tab.key)}
|
||||||
|
onLayout={handleTabLayout(tab.key)}
|
||||||
|
hasTVPreferredFocus={tab.key === activeTabKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -60,18 +60,6 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
paddingHorizontal: scaleSize(12),
|
paddingHorizontal: scaleSize(12),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
|
||||||
{/* Selected + unfocused: label and checkmark form a centered group.
|
|
||||||
The left padding offsets the checkmark's width so the label stays
|
|
||||||
optically centered. Without a checkmark, no offset → label centered. */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
// Offset checkmark width only when it's shown, else label sits right.
|
|
||||||
paddingLeft: selected && !focused ? scaleSize(10) : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -79,21 +67,11 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused || selected ? "600" : "400",
|
fontWeight: focused || selected ? "600" : "400",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
flexShrink: 1,
|
|
||||||
}}
|
}}
|
||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{selected && !focused && (
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={scaleSize(26)}
|
|
||||||
color='rgba(255,255,255,0.8)'
|
|
||||||
style={{ marginLeft: scaleSize(8), flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{sublabel && (
|
{sublabel && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -107,6 +85,21 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
{sublabel}
|
{sublabel}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{selected && !focused && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
placeholder={{ blurhash }}
|
placeholder={{ blurhash }}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
|
||||||
source={{ uri: imageUrl }}
|
source={{ uri: imageUrl }}
|
||||||
|
recyclingKey={item.Id}
|
||||||
cachePolicy='memory-disk'
|
cachePolicy='memory-disk'
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -19,27 +19,10 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export type TVSkipSegmentType =
|
|
||||||
| "intro"
|
|
||||||
| "credits"
|
|
||||||
| "outro"
|
|
||||||
| "recap"
|
|
||||||
| "commercial"
|
|
||||||
| "preview";
|
|
||||||
|
|
||||||
const SEGMENT_LABEL_KEY: Record<TVSkipSegmentType, string> = {
|
|
||||||
intro: "player.skip_intro",
|
|
||||||
credits: "player.skip_credits",
|
|
||||||
outro: "player.skip_outro",
|
|
||||||
recap: "player.skip_recap",
|
|
||||||
commercial: "player.skip_commercial",
|
|
||||||
preview: "player.skip_preview",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TVSkipSegmentCardProps {
|
export interface TVSkipSegmentCardProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
type: TVSkipSegmentType;
|
type: "intro" | "credits";
|
||||||
/** Whether controls are visible - affects card position */
|
/** Whether controls are visible - affects card position */
|
||||||
controlsVisible?: boolean;
|
controlsVisible?: boolean;
|
||||||
/** Callback ref setter for focus guide destination pattern */
|
/** Callback ref setter for focus guide destination pattern */
|
||||||
@@ -89,7 +72,8 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
|||||||
bottom: bottomPosition.value,
|
bottom: bottomPosition.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const labelText = t(SEGMENT_LABEL_KEY[type]);
|
const labelText =
|
||||||
|
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
|
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||||
|
export { TVNavBar } from "./TVNavBar";
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipSegmentButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipSegmentButtonText: string;
|
showSkipCreditButton: boolean;
|
||||||
showSkipOutroButton: boolean;
|
|
||||||
skipOutroButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
onSkipSegment: () => void;
|
skipIntro: () => void;
|
||||||
onSkipOutro: () => void;
|
skipCredit: () => void;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
@@ -88,13 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipSegmentButton,
|
showSkipButton,
|
||||||
skipSegmentButtonText,
|
showSkipCreditButton,
|
||||||
showSkipOutroButton,
|
|
||||||
skipOutroButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
onSkipSegment,
|
skipIntro,
|
||||||
onSkipOutro,
|
skipCredit,
|
||||||
nextItem,
|
nextItem,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
@@ -185,18 +181,19 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipSegmentButton}
|
showButton={showSkipButton}
|
||||||
onPress={onSkipSegment}
|
onPress={skipIntro}
|
||||||
buttonText={skipSegmentButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Outro button defers to "Next Episode" when credits run to the
|
{/* Smart Skip Credits behavior:
|
||||||
video end and a next episode exists. */}
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
|
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={
|
showButton={
|
||||||
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={onSkipOutro}
|
onPress={skipCredit}
|
||||||
buttonText={skipOutroButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
@@ -207,7 +204,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
!nextItem
|
!nextItem
|
||||||
? false
|
? false
|
||||||
: // Show during credits if no content after, OR near end of video
|
: // Show during credits if no content after, OR near end of video
|
||||||
(showSkipOutroButton && !hasContentAfterCredits) ||
|
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||||
remainingTime < 10000
|
remainingTime < 10000
|
||||||
}
|
}
|
||||||
onFinish={handleNextEpisodeAutoPlay}
|
onFinish={handleNextEpisodeAutoPlay}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { type FC, useCallback, useEffect, useState } from "react";
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -17,8 +16,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
@@ -26,7 +26,6 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
@@ -317,38 +316,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
|
||||||
offline,
|
|
||||||
downloadedFiles,
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unified segment orchestration (identical mechanism on mobile and TV):
|
|
||||||
// overlap priority + a single auto-skip driver live in the shared hook.
|
|
||||||
const {
|
|
||||||
activeSegment,
|
|
||||||
skipActiveSegment: onSkipSegment,
|
|
||||||
showSkipButton: showSkipSegmentButton,
|
|
||||||
isOutroActive: showSkipOutroButton,
|
|
||||||
skipOutro: onSkipOutro,
|
|
||||||
hasContentAfterCredits,
|
|
||||||
} = useMediaSegments({
|
|
||||||
segments,
|
|
||||||
currentTime,
|
currentTime,
|
||||||
maxMs,
|
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isPlaying,
|
offline,
|
||||||
isBuffering,
|
api,
|
||||||
});
|
downloadedFiles,
|
||||||
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const skipSegmentButtonText = activeSegment
|
useCreditSkipper(
|
||||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
item.Id!,
|
||||||
: t("player.skip_intro");
|
currentTime,
|
||||||
const skipOutroButtonText = t("player.skip_outro");
|
seek,
|
||||||
|
play,
|
||||||
|
offline,
|
||||||
|
api,
|
||||||
|
downloadedFiles,
|
||||||
|
maxMs,
|
||||||
|
);
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -582,13 +570,11 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipSegmentButton={showSkipSegmentButton}
|
showSkipButton={showSkipButton}
|
||||||
skipSegmentButtonText={skipSegmentButtonText}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
showSkipOutroButton={showSkipOutroButton}
|
|
||||||
skipOutroButtonText={skipOutroButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
onSkipSegment={onSkipSegment}
|
skipIntro={skipIntro}
|
||||||
onSkipOutro={onSkipOutro}
|
skipCredit={skipCredit}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ import {
|
|||||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useMediaSegments } from "@/hooks/useMediaSegments";
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import type { SegmentType } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
@@ -51,7 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
@@ -200,7 +200,6 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
progress,
|
progress,
|
||||||
cacheProgress,
|
cacheProgress,
|
||||||
isBuffering,
|
|
||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
@@ -319,8 +318,10 @@ export const Controls: FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||||
// Skip streams without a real index: `?? -1` would alias them to the
|
// Skip streams without a real index: `?? -1` would alias them to the
|
||||||
// "disable subtitles" sentinel and mis-route selection.
|
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||||
return streams
|
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return [...streams]
|
||||||
|
.sort(compareTracksForMenu)
|
||||||
.filter((stream) => typeof stream.Index === "number")
|
.filter((stream) => typeof stream.Index === "number")
|
||||||
.map((stream) => {
|
.map((stream) => {
|
||||||
const index = stream.Index as number;
|
const index = stream.Index as number;
|
||||||
@@ -429,42 +430,30 @@ export const Controls: FC<Props> = ({
|
|||||||
seek,
|
seek,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment skipping (intro + outro/credits) via the unified hook.
|
// Skip intro/credits hooks
|
||||||
|
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
_play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unified segment orchestration (identical mechanism on mobile and TV):
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
// overlap priority + a single auto-skip driver live in the shared hook.
|
useCreditSkipper(
|
||||||
const {
|
item.Id!,
|
||||||
activeSegment,
|
|
||||||
skipActiveSegment,
|
|
||||||
showSkipButton,
|
|
||||||
isOutroActive,
|
|
||||||
skipOutro: skipCredit,
|
|
||||||
hasContentAfterCredits,
|
|
||||||
} = useMediaSegments({
|
|
||||||
segments,
|
|
||||||
currentTime,
|
currentTime,
|
||||||
maxMs,
|
|
||||||
seek,
|
seek,
|
||||||
play: _play,
|
_play,
|
||||||
isPlaying,
|
offline,
|
||||||
isBuffering,
|
api,
|
||||||
});
|
downloadedFiles,
|
||||||
|
max.value,
|
||||||
// The outro keeps its dedicated card (it composes with the Next Episode
|
);
|
||||||
// countdown); the other four share the generic skip card.
|
|
||||||
const showSkipCreditButton = isOutroActive;
|
|
||||||
const activeSegmentType =
|
|
||||||
isOutroActive || !activeSegment
|
|
||||||
? "intro"
|
|
||||||
: (activeSegment.type.toLowerCase() as Lowercase<SegmentType>);
|
|
||||||
|
|
||||||
// Countdown logic
|
// Countdown logic
|
||||||
const isCountdownActive = useMemo(() => {
|
const isCountdownActive = useMemo(() => {
|
||||||
@@ -1140,11 +1129,11 @@ export const Controls: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generic skip card (intro / recap / commercial / preview) */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={skipActiveSegment}
|
onPress={skipIntro}
|
||||||
type={activeSegmentType}
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
hasTVPreferredFocus={!showControls}
|
hasTVPreferredFocus={!showControls}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type SubtitleSearchResult,
|
type SubtitleSearchResult,
|
||||||
useRemoteSubtitles,
|
useRemoteSubtitles,
|
||||||
} from "@/hooks/useRemoteSubtitles";
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
|
||||||
interface TVSubtitleSheetProps {
|
interface TVSubtitleSheetProps {
|
||||||
@@ -96,13 +97,19 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
const sortedTracks = useMemo(
|
||||||
|
() => [...subtitleTracks].sort(compareTracksForMenu),
|
||||||
|
[subtitleTracks],
|
||||||
|
);
|
||||||
|
|
||||||
const initialSelectedTrackIndex = useMemo(() => {
|
const initialSelectedTrackIndex = useMemo(() => {
|
||||||
if (currentSubtitleIndex === -1) return 0;
|
if (currentSubtitleIndex === -1) return 0;
|
||||||
const trackIdx = subtitleTracks.findIndex(
|
const trackIdx = sortedTracks.findIndex(
|
||||||
(t) => t.Index === currentSubtitleIndex,
|
(t) => t.Index === currentSubtitleIndex,
|
||||||
);
|
);
|
||||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||||
}, [subtitleTracks, currentSubtitleIndex]);
|
}, [sortedTracks, currentSubtitleIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -215,7 +222,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
value: -1,
|
value: -1,
|
||||||
selected: currentSubtitleIndex === -1,
|
selected: currentSubtitleIndex === -1,
|
||||||
};
|
};
|
||||||
const options = subtitleTracks.map((track) => ({
|
const options = sortedTracks.map((track) => ({
|
||||||
label:
|
label:
|
||||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
sublabel: track.Codec?.toUpperCase(),
|
sublabel: track.Codec?.toUpperCase(),
|
||||||
@@ -223,7 +230,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
selected: track.Index === currentSubtitleIndex,
|
selected: track.Index === currentSubtitleIndex,
|
||||||
}));
|
}));
|
||||||
return [noneOption, ...options];
|
return [noneOption, ...options];
|
||||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
}, [sortedTracks, currentSubtitleIndex, t]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
|
{info?.demuxerMaxBytes !== undefined
|
||||||
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
|
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
||||||
|
")"
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{info?.estimatedVfFps !== undefined && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||||
|
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
Dropped: {info.droppedFrames} frames
|
||||||
|
|||||||
@@ -23,32 +23,29 @@
|
|||||||
* - Used to report playback state to Jellyfin server
|
* - Used to report playback state to Jellyfin server
|
||||||
* - Value of -1 means disabled/none
|
* - Value of -1 means disabled/none
|
||||||
*
|
*
|
||||||
* 2. MPV INDEX (track.mpvIndex)
|
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||||
* - MPV's internal track ID
|
* - Selection resolves the server Index against MPV's REAL track list via
|
||||||
* - MPV orders tracks as: [all embedded, then all external]
|
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||||
|
* reordered or the server hid embedded subs (#954 et al.).
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SUBTITLE HANDLING
|
* SUBTITLE HANDLING
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Embedded (DeliveryMethod.Embed):
|
* Embedded & External:
|
||||||
* - Already in MPV's track list
|
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||||
* - Select via setSubtitleTrack(mpvId)
|
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||||
*
|
* embedded first, externals last, forced/default float up).
|
||||||
* External (DeliveryMethod.External):
|
|
||||||
* - Loaded into MPV on video start
|
|
||||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
|
||||||
*
|
*
|
||||||
* Image-based during transcoding:
|
* Image-based during transcoding:
|
||||||
* - Burned into video by Jellyfin, not in MPV
|
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||||
* - Requires replacePlayer() to change
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -61,9 +58,14 @@ import {
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import type { MpvAudioTrack } from "@/modules";
|
import type { MpvAudioTrack } from "@/modules";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
|
compareTracksForMenu,
|
||||||
|
isImageBasedSubtitle,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||||
const playerControls = usePlayerControls();
|
const playerControls = usePlayerControls();
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
@@ -141,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracksReady) return;
|
if (!tracksReady) return;
|
||||||
|
|
||||||
|
// Guard every state commit against stale runs: api?.basePath /
|
||||||
|
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
|
||||||
|
// earlier async run (which captured an old `api`) must not finish later and
|
||||||
|
// overwrite the fresh track list with callbacks bound to stale closures.
|
||||||
|
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
|
||||||
|
let cancelled = false;
|
||||||
|
const commitSubtitleTracks = (next: Track[]) => {
|
||||||
|
if (!cancelled) setSubtitleTracks(next);
|
||||||
|
};
|
||||||
|
const commitAudioTracks = (next: Track[]) => {
|
||||||
|
if (!cancelled) setAudioTracks(next);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
// Check if this is offline transcoded content
|
// Check if this is offline transcoded content
|
||||||
// For transcoded offline content, only ONE audio track exists in the file
|
// For transcoded offline content, only ONE audio track exists in the file
|
||||||
@@ -166,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setAudioTracks(audio);
|
commitAudioTracks(audio);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: show no audio tracks if the stored track wasn't found
|
// Fallback: show no audio tracks if the stored track wasn't found
|
||||||
setAudioTracks([]);
|
commitAudioTracks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subtitles in transcoded offline content:
|
// For subtitles in transcoded offline content:
|
||||||
@@ -179,6 +195,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
downloadedItem.userData.subtitleStreamIndex;
|
downloadedItem.userData.subtitleStreamIndex;
|
||||||
const subs: Track[] = [];
|
const subs: Track[] = [];
|
||||||
|
|
||||||
|
// If an IMAGE subtitle was burned into the transcoded download it's in the
|
||||||
|
// video pixels — it can't be turned off or swapped. Show only that entry
|
||||||
|
// instead of advertising "Disable"/text controls that can't affect it.
|
||||||
|
const burnedInSub = allSubs.find(
|
||||||
|
(s) => s.Index === downloadedSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
|
||||||
|
commitSubtitleTracks([
|
||||||
|
{
|
||||||
|
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
|
||||||
|
index: burnedInSub.Index ?? -1,
|
||||||
|
mpvIndex: -1,
|
||||||
|
setTrack: () => {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add "Disable" option
|
// Add "Disable" option
|
||||||
subs.push({
|
subs.push({
|
||||||
name: "Disable",
|
name: "Disable",
|
||||||
@@ -190,123 +224,82 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// For text-based subs, they should still be available in the file
|
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||||
let subIdx = 1;
|
// identity against MPV's real track list (same as online). Order matches web.
|
||||||
for (const sub of allSubs) {
|
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||||
if (sub.IsTextSubtitleStream) {
|
// above), so skip them here.
|
||||||
subs.push({
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
name: sub.DisplayTitle || "Unknown",
|
if (!isImageBasedSubtitle(sub)) {
|
||||||
index: sub.Index ?? -1,
|
|
||||||
mpvIndex: subIdx,
|
|
||||||
setTrack: () => {
|
|
||||||
playerControls.setSubtitleTrack(subIdx);
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
subIdx++;
|
|
||||||
} else if (sub.Index === downloadedSubtitleIndex) {
|
|
||||||
// This image-based sub was burned in - show it but indicate it's active
|
|
||||||
subs.push({
|
|
||||||
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
|
||||||
index: sub.Index ?? -1,
|
|
||||||
mpvIndex: -1, // Can't be changed
|
|
||||||
setTrack: () => {
|
|
||||||
// Already burned in, just update params
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MPV track handling
|
|
||||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
|
||||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
|
||||||
|
|
||||||
// Separate embedded vs external subtitles from Jellyfin's list
|
|
||||||
// MPV orders tracks as: [all embedded, then all external]
|
|
||||||
const embeddedSubs = allSubs.filter(
|
|
||||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
|
||||||
);
|
|
||||||
const externalSubs = allSubs.filter(
|
|
||||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count embedded subs that will be in MPV
|
|
||||||
// (excludes image-based subs during transcoding as they're burned in)
|
|
||||||
const embeddedInPlayer = embeddedSubs.filter(
|
|
||||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subs: Track[] = [];
|
|
||||||
|
|
||||||
// Process all Jellyfin subtitles
|
|
||||||
for (const sub of allSubs) {
|
|
||||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
|
||||||
const isExternal =
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
|
||||||
|
|
||||||
// For image-based subs during transcoding, need to refresh player
|
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: -1,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath
|
||||||
|
? `${api.basePath}${s.DeliveryUrl}`
|
||||||
|
: undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
continue;
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate MPV track ID based on type
|
commitSubtitleTracks(subs);
|
||||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
return;
|
||||||
let mpvId = -1;
|
}
|
||||||
|
|
||||||
if (isEmbedded) {
|
// MPV track handling
|
||||||
// Find position among embedded subs that are in player
|
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
if (cancelled) return;
|
||||||
(s) => s.Index === sub.Index,
|
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||||
);
|
|
||||||
if (embeddedPosition !== -1) {
|
const subs: Track[] = [];
|
||||||
mpvId = embeddedPosition + 1; // 1-based ID
|
|
||||||
}
|
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||||
} else if (isExternal) {
|
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||||
// Find position among external subs, offset by embedded count
|
// index math, which mis-selects across external/embedded reordering and
|
||||||
const externalPosition = externalSubs.findIndex(
|
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||||
(s) => s.Index === sub.Index,
|
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
);
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
if (externalPosition !== -1) {
|
// Image-based subs during transcoding are burned into the video by the
|
||||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
// server; both switching TO one and switching AWAY from a currently
|
||||||
}
|
// active one require a player refresh (re-transcode), not a track change.
|
||||||
}
|
const needsReplace =
|
||||||
|
isTranscoding &&
|
||||||
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||||
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: mpvId,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
// Transcoding + switching to/from image-based sub
|
if (needsReplace) {
|
||||||
if (
|
|
||||||
isTranscoding &&
|
|
||||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
|
||||||
) {
|
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct switch in player
|
|
||||||
if (mpvId !== -1) {
|
|
||||||
playerControls.setSubtitleTrack(mpvId);
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
}
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
// Fallback - refresh player
|
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||||
|
// external-filename lines up.
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath
|
||||||
|
? `${api.basePath}${s.DeliveryUrl}`
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,12 +367,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||||
setAudioTracks(audio);
|
// at the front (unshifted), local downloaded subs at the end.
|
||||||
|
commitSubtitleTracks(subs);
|
||||||
|
commitAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
||||||
|
// API is ready so online externals don't resolve with undefined.
|
||||||
|
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
||||||
|
// decision — rebuild when it flips so we refresh the stream when we should.
|
||||||
|
}, [
|
||||||
|
tracksReady,
|
||||||
|
mediaSource,
|
||||||
|
offline,
|
||||||
|
downloadedItem,
|
||||||
|
itemId,
|
||||||
|
api?.basePath,
|
||||||
|
isCurrentSubImageBased,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
export default {
|
const MediaTypes = {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||||
|
|
||||||
|
export default MediaTypes;
|
||||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
|||||||
|
|
||||||
Relevant files:
|
Relevant files:
|
||||||
|
|
||||||
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
||||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||||
|
|||||||
109
hooks/useCreditSkipper.ts
Normal file
109
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping credits in a media player.
|
||||||
|
* The player reports time values in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useCreditSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
totalDuration?: number,
|
||||||
|
) => {
|
||||||
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
|
||||||
|
const totalDurationInSeconds =
|
||||||
|
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||||
|
|
||||||
|
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const creditTimestamps = segments?.creditSegments?.[0];
|
||||||
|
|
||||||
|
// Determine if there's content after credits (credits don't extend to video end)
|
||||||
|
// Use a 5-second buffer to account for timing discrepancies
|
||||||
|
const hasContentAfterCredits = (() => {
|
||||||
|
if (
|
||||||
|
!creditTimestamps ||
|
||||||
|
totalDurationInSeconds == null ||
|
||||||
|
!Number.isFinite(totalDurationInSeconds)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const creditsEndToVideoEnd =
|
||||||
|
totalDurationInSeconds - creditTimestamps.endTime;
|
||||||
|
// If credits end more than 5 seconds before video ends, there's content after
|
||||||
|
return creditsEndToVideoEnd > 5;
|
||||||
|
})();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creditTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > creditTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < creditTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipCreditButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
// Reset button state when no credit timestamps exist
|
||||||
|
if (showSkipCreditButton) {
|
||||||
|
setShowSkipCreditButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||||
|
|
||||||
|
const skipCredit = useCallback(() => {
|
||||||
|
if (!creditTimestamps) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Calculate the target seek position
|
||||||
|
let seekTarget = creditTimestamps.endTime;
|
||||||
|
|
||||||
|
// If we have total duration, ensure we don't seek past the end of the video.
|
||||||
|
// Some media sources report credit end times that exceed the actual video duration,
|
||||||
|
// which causes the player to pause/stop when seeking past the end.
|
||||||
|
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||||
|
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||||
|
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||||
|
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedSeek(seekTarget);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
creditTimestamps,
|
||||||
|
lightHapticFeedback,
|
||||||
|
wrappedSeek,
|
||||||
|
play,
|
||||||
|
totalDurationInSeconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||||
|
};
|
||||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
|
*
|
||||||
|
* @param {number} currentTime - The current playback time in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useIntroSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
) => {
|
||||||
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > introTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < introTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
if (showSkipButton) {
|
||||||
|
setShowSkipButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||||
|
|
||||||
|
const skipIntro = useCallback(() => {
|
||||||
|
if (!introTimestamps) return;
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
wrappedSeek(introTimestamps.endTime);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
|
return { showSkipButton, skipIntro };
|
||||||
|
};
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import type { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
||||||
import type { SegmentSkipMode } from "@/utils/atoms/settings";
|
|
||||||
import type { SegmentBuckets } from "@/utils/segments";
|
|
||||||
import { type SegmentType, useSegmentSkipper } from "./useSegmentSkipper";
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
// Delay the FIRST auto-skip until playback has been stable this long. Seeking a
|
|
||||||
// transcoded stream the instant the first frame appears (e.g. a 0:00 intro)
|
|
||||||
// asks the transcode for a segment it hasn't produced yet and stalls at 0:00;
|
|
||||||
// direct-play is always seekable so the delay is invisible there.
|
|
||||||
const AUTO_SKIP_ARM_DELAY_MS = 1500;
|
|
||||||
|
|
||||||
export interface ActiveSegment {
|
|
||||||
type: SegmentType;
|
|
||||||
currentSegment: MediaTimeSegment;
|
|
||||||
skipSegment: (useHaptics?: boolean) => void;
|
|
||||||
skipMode: SegmentSkipMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseMediaSegmentsProps {
|
|
||||||
segments: SegmentBuckets | undefined;
|
|
||||||
/** Current playback position, in ms. */
|
|
||||||
currentTime: number;
|
|
||||||
/** Total media duration, in ms. */
|
|
||||||
maxMs?: number;
|
|
||||||
/** Player seek, expects ms. */
|
|
||||||
seek: (ms: number) => void;
|
|
||||||
/** Player resume. */
|
|
||||||
play: () => void;
|
|
||||||
isPlaying: boolean;
|
|
||||||
/** True while the player is (re)buffering; auto-skip waits for this to clear. */
|
|
||||||
isBuffering?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseMediaSegmentsReturn {
|
|
||||||
/** Highest-priority segment under the playhead (excludes 'none' types), or null. */
|
|
||||||
activeSegment: ActiveSegment | null;
|
|
||||||
/** Skip the active segment (no-op when there is none). */
|
|
||||||
skipActiveSegment: (useHaptics?: boolean) => void;
|
|
||||||
/** Show the generic skip button: an active segment that is not the outro. */
|
|
||||||
showSkipButton: boolean;
|
|
||||||
/** The active segment is the outro/credits (it gets its own button/card). */
|
|
||||||
isOutroActive: boolean;
|
|
||||||
/** Skip the outro, independent of which button the priority shows. */
|
|
||||||
skipOutro: (useHaptics?: boolean) => void;
|
|
||||||
/** The outro ends before the media end, i.e. there is content after credits. */
|
|
||||||
hasContentAfterCredits: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified media-segment orchestration shared by the mobile and TV player controls.
|
|
||||||
* Owns the per-type skippers, the seek-with-delayed-play workaround, the overlap
|
|
||||||
* priority (Commercial > Recap > Intro > Preview > Outro) and a SINGLE auto-skip
|
|
||||||
* driver, so overlapping auto-enabled segments can't fire competing seeks and both
|
|
||||||
* platforms behave identically.
|
|
||||||
*/
|
|
||||||
export const useMediaSegments = ({
|
|
||||||
segments,
|
|
||||||
currentTime,
|
|
||||||
maxMs,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
isPlaying,
|
|
||||||
isBuffering = false,
|
|
||||||
}: UseMediaSegmentsProps): UseMediaSegmentsReturn => {
|
|
||||||
// Keep sub-second precision: segment boundaries are fractional seconds, so
|
|
||||||
// flooring currentTime would detect segments up to ~1s late / end them early.
|
|
||||||
const currentTimeSeconds = currentTime / 1000;
|
|
||||||
const maxSeconds = maxMs ? maxMs / 1000 : undefined;
|
|
||||||
|
|
||||||
// Seek-with-delayed-play workaround: some seeks otherwise resume from the
|
|
||||||
// pre-seek position. playingRef avoids a stale closure on isPlaying.
|
|
||||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const playingRef = useRef(isPlaying);
|
|
||||||
useEffect(() => {
|
|
||||||
playingRef.current = isPlaying;
|
|
||||||
}, [isPlaying]);
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const seekSeconds = useCallback(
|
|
||||||
(timeInSeconds: number) => {
|
|
||||||
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
|
|
||||||
seek(timeInSeconds * 1000);
|
|
||||||
playTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (playingRef.current) play();
|
|
||||||
playTimeoutRef.current = null;
|
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[seek, play],
|
|
||||||
);
|
|
||||||
|
|
||||||
const introSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.introSegments ?? [],
|
|
||||||
segmentType: "Intro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
});
|
|
||||||
const outroSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.creditSegments ?? [],
|
|
||||||
segmentType: "Outro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
totalDuration: maxSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
});
|
|
||||||
const recapSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.recapSegments ?? [],
|
|
||||||
segmentType: "Recap",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
});
|
|
||||||
const commercialSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.commercialSegments ?? [],
|
|
||||||
segmentType: "Commercial",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
});
|
|
||||||
const previewSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.previewSegments ?? [],
|
|
||||||
segmentType: "Preview",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
|
|
||||||
const activeSegment = useMemo<ActiveSegment | null>(() => {
|
|
||||||
const byPriority: Array<[SegmentType, typeof introSkipper]> = [
|
|
||||||
["Commercial", commercialSkipper],
|
|
||||||
["Recap", recapSkipper],
|
|
||||||
["Intro", introSkipper],
|
|
||||||
["Preview", previewSkipper],
|
|
||||||
["Outro", outroSkipper],
|
|
||||||
];
|
|
||||||
for (const [type, skipper] of byPriority) {
|
|
||||||
if (skipper.currentSegment) {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
currentSegment: skipper.currentSegment,
|
|
||||||
skipSegment: skipper.skipSegment,
|
|
||||||
skipMode: skipper.skipMode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
commercialSkipper.currentSegment,
|
|
||||||
commercialSkipper.skipSegment,
|
|
||||||
commercialSkipper.skipMode,
|
|
||||||
recapSkipper.currentSegment,
|
|
||||||
recapSkipper.skipSegment,
|
|
||||||
recapSkipper.skipMode,
|
|
||||||
introSkipper.currentSegment,
|
|
||||||
introSkipper.skipSegment,
|
|
||||||
introSkipper.skipMode,
|
|
||||||
previewSkipper.currentSegment,
|
|
||||||
previewSkipper.skipSegment,
|
|
||||||
previewSkipper.skipMode,
|
|
||||||
outroSkipper.currentSegment,
|
|
||||||
outroSkipper.skipSegment,
|
|
||||||
outroSkipper.skipMode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Single auto-skip driver: only the priority-resolved active segment skips,
|
|
||||||
// so overlapping auto-enabled segments can't trigger competing seeks.
|
|
||||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
|
||||||
const [autoSkipArmed, setAutoSkipArmed] = useState(false);
|
|
||||||
|
|
||||||
// Reset per item (its segments change): re-allow skipping and re-arm so the
|
|
||||||
// next episode's transcode has time to become seekable. We do NOT reset the
|
|
||||||
// guard when the active segment momentarily disappears — seeking a transcoded
|
|
||||||
// stream makes the reported position bounce back into a 0:00 intro, and
|
|
||||||
// clearing the guard there caused an infinite seek loop that crashed mpv.
|
|
||||||
useEffect(() => {
|
|
||||||
autoSkipTriggeredRef.current = null;
|
|
||||||
setAutoSkipArmed(false);
|
|
||||||
}, [segments]);
|
|
||||||
|
|
||||||
// Arm auto-skip once playback has been genuinely stable (not buffering) for a
|
|
||||||
// short moment, so the first seek lands on an established (seekable) timeline.
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoSkipArmed || isBuffering || !isPlaying) return;
|
|
||||||
const id = setTimeout(() => setAutoSkipArmed(true), AUTO_SKIP_ARM_DELAY_MS);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [autoSkipArmed, isBuffering, isPlaying]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!autoSkipArmed ||
|
|
||||||
!activeSegment ||
|
|
||||||
!isPlaying ||
|
|
||||||
isBuffering ||
|
|
||||||
activeSegment.skipMode !== "auto"
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const { startTime, endTime } = activeSegment.currentSegment;
|
|
||||||
const segmentId = `${activeSegment.type}:${startTime}-${endTime}`;
|
|
||||||
if (autoSkipTriggeredRef.current === segmentId) return;
|
|
||||||
autoSkipTriggeredRef.current = segmentId;
|
|
||||||
activeSegment.skipSegment(false);
|
|
||||||
}, [activeSegment, isPlaying, isBuffering, autoSkipArmed]);
|
|
||||||
|
|
||||||
const isOutroActive = activeSegment?.type === "Outro";
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeSegment,
|
|
||||||
skipActiveSegment: activeSegment?.skipSegment ?? noop,
|
|
||||||
showSkipButton: !!activeSegment && !isOutroActive,
|
|
||||||
isOutroActive,
|
|
||||||
skipOutro: outroSkipper.skipSegment,
|
|
||||||
hasContentAfterCredits:
|
|
||||||
outroSkipper.currentSegment && maxSeconds
|
|
||||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
|
||||||
: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
||||||
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useHaptic } from "./useHaptic";
|
|
||||||
|
|
||||||
export type SegmentType =
|
|
||||||
| "Intro"
|
|
||||||
| "Outro"
|
|
||||||
| "Recap"
|
|
||||||
| "Commercial"
|
|
||||||
| "Preview";
|
|
||||||
|
|
||||||
const SEGMENT_TO_SETTING: Record<
|
|
||||||
SegmentType,
|
|
||||||
"skipIntro" | "skipOutro" | "skipRecap" | "skipCommercial" | "skipPreview"
|
|
||||||
> = {
|
|
||||||
Intro: "skipIntro",
|
|
||||||
Outro: "skipOutro",
|
|
||||||
Recap: "skipRecap",
|
|
||||||
Commercial: "skipCommercial",
|
|
||||||
Preview: "skipPreview",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseSegmentSkipperProps {
|
|
||||||
segments: MediaTimeSegment[];
|
|
||||||
segmentType: SegmentType;
|
|
||||||
currentTime: number;
|
|
||||||
totalDuration?: number;
|
|
||||||
seek: (time: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSegmentSkipperReturn {
|
|
||||||
currentSegment: MediaTimeSegment | null;
|
|
||||||
skipSegment: (useHaptics?: boolean) => void;
|
|
||||||
skipMode: SegmentSkipMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic hook for a single media segment type (intro, outro, recap, commercial, preview).
|
|
||||||
* Reports the segment currently under the playhead, its skip mode, and a skip action.
|
|
||||||
* Auto-skip is NOT performed here: the consumer drives it from the priority-resolved
|
|
||||||
* active segment so overlapping segments can't trigger competing seeks.
|
|
||||||
*/
|
|
||||||
export const useSegmentSkipper = ({
|
|
||||||
segments,
|
|
||||||
segmentType,
|
|
||||||
currentTime,
|
|
||||||
totalDuration,
|
|
||||||
seek,
|
|
||||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const haptic = useHaptic();
|
|
||||||
|
|
||||||
const skipMode: SegmentSkipMode =
|
|
||||||
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
|
|
||||||
|
|
||||||
const currentSegment = useMemo(
|
|
||||||
() =>
|
|
||||||
segments.find(
|
|
||||||
(s) => currentTime >= s.startTime && currentTime < s.endTime,
|
|
||||||
) ?? null,
|
|
||||||
[segments, currentTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refs keep skipSegment's identity stable across seek/haptic changes
|
|
||||||
// (haptic is unstable when disabled), so the consumer's auto-skip effect
|
|
||||||
// doesn't re-fire spuriously.
|
|
||||||
const seekRef = useRef(seek);
|
|
||||||
const hapticRef = useRef(haptic);
|
|
||||||
useEffect(() => {
|
|
||||||
seekRef.current = seek;
|
|
||||||
hapticRef.current = haptic;
|
|
||||||
});
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(useHaptics = true) => {
|
|
||||||
if (!currentSegment || skipMode === "none") return;
|
|
||||||
|
|
||||||
// Outro endTime sometimes exceeds the actual file duration. Keep a 2s
|
|
||||||
// buffer so the player's natural end-of-video flow (next-episode
|
|
||||||
// countdown, etc.) still fires instead of stalling at the exact end.
|
|
||||||
let target = currentSegment.endTime;
|
|
||||||
if (
|
|
||||||
segmentType === "Outro" &&
|
|
||||||
totalDuration != null &&
|
|
||||||
Number.isFinite(totalDuration) &&
|
|
||||||
target >= totalDuration
|
|
||||||
) {
|
|
||||||
target = Math.max(0, totalDuration - 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
seekRef.current(target);
|
|
||||||
|
|
||||||
if (useHaptics) hapticRef.current();
|
|
||||||
},
|
|
||||||
[currentSegment, segmentType, totalDuration, skipMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
|
||||||
skipSegment,
|
|
||||||
skipMode,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
|
|||||||
import {
|
import {
|
||||||
disableTVMenuKeyInterception,
|
disableTVMenuKeyInterception,
|
||||||
enableTVMenuKeyInterception,
|
enableTVMenuKeyInterception,
|
||||||
|
useTVBackPress,
|
||||||
} from "./useTVBackPress";
|
} from "./useTVBackPress";
|
||||||
|
|
||||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||||
|
|
||||||
|
/** All tab route names used in the bottom tab navigator. */
|
||||||
|
export const TAB_ROUTES = [
|
||||||
|
"(home)",
|
||||||
|
"(search)",
|
||||||
|
"(favorites)",
|
||||||
|
"(libraries)",
|
||||||
|
"(watchlists)",
|
||||||
|
"(custom-links)",
|
||||||
|
"(settings)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TabRoute = (typeof TAB_ROUTES)[number];
|
||||||
|
|
||||||
|
/** Check if a segment string is a tab route. */
|
||||||
|
export function isTabRoute(s: string): s is TabRoute {
|
||||||
|
return (TAB_ROUTES as readonly string[]).includes(s);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're at the root of a tab
|
* Check if we're at the root of a tab
|
||||||
*/
|
*/
|
||||||
function isAtTabRoot(segments: string[]): boolean {
|
function isAtTabRoot(segments: string[]): boolean {
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
const tabNames = [
|
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
"(home)",
|
|
||||||
"(search)",
|
|
||||||
"(favorites)",
|
|
||||||
"(libraries)",
|
|
||||||
"(watchlists)",
|
|
||||||
"(settings)",
|
|
||||||
"(custom-links)",
|
|
||||||
];
|
|
||||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current tab name from segments
|
* Get the current tab name from segments
|
||||||
*/
|
*/
|
||||||
function getCurrentTab(segments: string[]): string | undefined {
|
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||||
return segments.find(
|
return segments.find(isTabRoute);
|
||||||
(s) =>
|
|
||||||
s === "(home)" ||
|
|
||||||
s === "(search)" ||
|
|
||||||
s === "(favorites)" ||
|
|
||||||
s === "(libraries)" ||
|
|
||||||
s === "(watchlists)" ||
|
|
||||||
s === "(settings)" ||
|
|
||||||
s === "(custom-links)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
|
|||||||
export function useTVHomeBackHandler() {
|
export function useTVHomeBackHandler() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
// Get current state
|
|
||||||
const currentTab = getCurrentTab(segments);
|
const currentTab = getCurrentTab(segments);
|
||||||
const atTabRoot = isAtTabRoot(segments);
|
const atTabRoot = isAtTabRoot(segments);
|
||||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||||
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
|
|||||||
enableTVMenuKeyInterception();
|
enableTVMenuKeyInterception();
|
||||||
}, [isOnHomeRoot]);
|
}, [isOnHomeRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
||||||
|
*
|
||||||
|
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
||||||
|
* built-in tab-level back handling — pressing back at a tab root would pop the
|
||||||
|
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
||||||
|
* to Home instead.
|
||||||
|
*/
|
||||||
|
export function useTVTabRootBackHandler(
|
||||||
|
onNavigateHome: () => void,
|
||||||
|
isAtTabRoot: boolean,
|
||||||
|
currentTab: string | undefined,
|
||||||
|
) {
|
||||||
|
useTVBackPress(() => {
|
||||||
|
if (!Platform.isTV || Platform.OS !== "android") return false;
|
||||||
|
if (!isAtTabRoot || currentTab === "(home)") return false;
|
||||||
|
onNavigateHome();
|
||||||
|
return true;
|
||||||
|
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// libmpv from Maven Central
|
// libmpv from Maven Central
|
||||||
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -3,14 +3,14 @@ package expo.modules.mpvplayer
|
|||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.system.Os
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPV renderer that wraps libmpv for video playback.
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
@@ -76,7 +76,14 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
private var surface: Surface? = null
|
private var surface: Surface? = null
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var isStopping = false
|
|
||||||
|
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
||||||
|
// player screen gets a fresh mpv handle and drops the reference on stop.
|
||||||
|
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
||||||
|
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
||||||
|
// so we mirror Findroid and let the JVM GC + native finalization path
|
||||||
|
// reclaim resources. Only one player is alive at a time in this app.
|
||||||
|
private var mpv: MPVLib? = null
|
||||||
|
|
||||||
// Cached state
|
// Cached state
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
@@ -139,103 +146,105 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MPVLib.create(context)
|
// Per-instance handle — see class-level comment. Each player gets
|
||||||
MPVLib.addObserver(this)
|
// its own mpv; we drop the reference in stop().
|
||||||
|
val mpv = MPVLib.create(context)
|
||||||
|
this.mpv = mpv
|
||||||
|
mpv.addObserver(this)
|
||||||
|
|
||||||
/**
|
// Resolved once — TV gets the memory-pressure customizations
|
||||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||||
*
|
// audio-buffer) that would be counterproductive on higher-RAM
|
||||||
* Technical Background:
|
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||||
* ====================
|
// those come from user settings via load().
|
||||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
val isTV = isTvDevice()
|
||||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
|
||||||
* even when subtitle tracks are properly detected and loaded.
|
// mpv config directory — used by the config-dir option below and
|
||||||
*
|
// as XDG_CONFIG_HOME for fontconfig.
|
||||||
* Why This Is Necessary:
|
|
||||||
* =====================
|
|
||||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
|
||||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
|
||||||
*
|
|
||||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
|
||||||
* configured directory, mpv either:
|
|
||||||
* - Fails silently (subtitles don't appear)
|
|
||||||
* - Falls back to a default font that may not support the required character set
|
|
||||||
* - Crashes or produces rendering errors
|
|
||||||
*
|
|
||||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
|
||||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
|
||||||
*
|
|
||||||
* Reference:
|
|
||||||
* =========
|
|
||||||
* This workaround is documented in the mpv-android project:
|
|
||||||
* https://github.com/mpv-android/mpv-android/issues/96
|
|
||||||
*
|
|
||||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
|
||||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
|
||||||
*/
|
|
||||||
// Create mpv config directory and copy font files
|
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
|
||||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||||
// This needs to be named `subfont.ttf` else it won't work
|
|
||||||
arrayOf("subfont.ttf").forEach { fileName ->
|
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||||
val file = File(mpvDir, fileName)
|
// persists its font index across runs instead of re-walking
|
||||||
if (file.exists()) return@forEach
|
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||||
context.assets
|
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
// holds onto. Without this we see "No usable fontconfig
|
||||||
.copyTo(FileOutputStream(file))
|
// configuration file found, using fallback" on every re-init.
|
||||||
|
try {
|
||||||
|
val cacheDir = context.cacheDir.absolutePath
|
||||||
|
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
||||||
|
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
||||||
|
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
||||||
|
Os.setenv("HOME", configDir, true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
||||||
}
|
}
|
||||||
MPVLib.setOptionString("config", "yes")
|
|
||||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
mpv?.setOptionString("config", "yes")
|
||||||
|
mpv?.setOptionString("config-dir", mpvDir.path)
|
||||||
|
|
||||||
// Configure mpv options before initialization (based on Findroid)
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
this.voDriver = voDriver
|
this.voDriver = voDriver
|
||||||
MPVLib.setOptionString("vo", voDriver)
|
mpv?.setOptionString("vo", voDriver)
|
||||||
MPVLib.setOptionString("gpu-context", "android")
|
mpv?.setOptionString("gpu-context", "android")
|
||||||
MPVLib.setOptionString("opengl-es", "yes")
|
mpv?.setOptionString("opengl-es", "yes")
|
||||||
|
|
||||||
// Hardware decode path:
|
// Hardware decoder codecs (shared)
|
||||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices).
|
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||||
|
|
||||||
|
// Pause on initial cache fill (shared default). The actual
|
||||||
|
// cache mode, cache-secs, and demuxer cache sizes come from
|
||||||
|
// user preferences and are applied per-load in load().
|
||||||
|
mpv?.setOptionString("cache-pause-initial", "yes")
|
||||||
|
|
||||||
|
// Hardware decode path + TV-only memory options. Demuxer cache
|
||||||
|
// sizes and cache-secs are NOT set here — they come from user
|
||||||
|
// preferences via load().
|
||||||
|
// - Emulator: software decode. Its MediaCodec can't bind an
|
||||||
|
// output surface (surface 0x0); HEVC then fails cleanly and
|
||||||
|
// mpv auto-falls-back to software, but H.264 "opens"
|
||||||
|
// deceptively and wedges the core with no fallback (black
|
||||||
|
// video, then any command — seek/pause — deadlocks the UI
|
||||||
|
// thread → ANR). hwdec=no makes every codec render via the
|
||||||
|
// gpu-next VO. Real devices unaffected.
|
||||||
|
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
||||||
|
// low-power devices) + fast profile.
|
||||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||||
// - Emulator: software decode. Its MediaCodec can't bind an output surface
|
|
||||||
// (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software,
|
|
||||||
// but H.264 "opens" deceptively and wedges the core with no fallback (black
|
|
||||||
// video, then any command — seek/pause — deadlocks the UI thread → ANR).
|
|
||||||
// hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected.
|
|
||||||
when {
|
when {
|
||||||
isEmulator() -> MPVLib.setOptionString("hwdec", "no")
|
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||||
isTvDevice() -> {
|
isTV -> {
|
||||||
MPVLib.setOptionString("hwdec", "mediacodec")
|
mpv?.setOptionString("hwdec", "mediacodec")
|
||||||
MPVLib.setOptionString("profile", "fast")
|
mpv?.setOptionString("profile", "fast")
|
||||||
|
// Don't retain already-played content for backward
|
||||||
|
// seeking over a network source — Jellyfin can re-fetch
|
||||||
|
// on demand. Saves up to ~30 MiB on long seeks and
|
||||||
|
// reduces swap pressure.
|
||||||
|
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
||||||
|
// Larger audio buffer to absorb page-fault stalls
|
||||||
|
// (default ~0.2s). Cheap insurance against the audio
|
||||||
|
// underruns that happen when the kernel is swap-thrashing.
|
||||||
|
mpv?.setOptionString("audio-buffer", "0.5")
|
||||||
}
|
}
|
||||||
else -> MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
||||||
}
|
}
|
||||||
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
|
||||||
|
|
||||||
// Cache settings for better network streaming
|
|
||||||
MPVLib.setOptionString("cache", "yes")
|
|
||||||
MPVLib.setOptionString("cache-pause-initial", "yes")
|
|
||||||
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
|
||||||
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
|
||||||
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
|
||||||
|
|
||||||
// Seeking optimization - faster seeking at the cost of less precision
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
// Use keyframe seeking by default (much faster for network streams)
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
MPVLib.setOptionString("hr-seek", "no")
|
mpv?.setOptionString("hr-seek", "no")
|
||||||
// Drop frames during seeking for faster response
|
// Drop frames during seeking for faster response
|
||||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
// Subtitle settings
|
// Subtitle settings
|
||||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||||
MPVLib.setOptionString("sub-use-margins", "no")
|
mpv?.setOptionString("sub-use-margins", "no")
|
||||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||||
MPVLib.setOptionString("subs-fallback", "yes")
|
mpv?.setOptionString("subs-fallback", "yes")
|
||||||
|
|
||||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||||
MPVLib.setOptionString("force-window", "no")
|
mpv?.setOptionString("force-window", "no")
|
||||||
MPVLib.setOptionString("keep-open", "always")
|
mpv?.setOptionString("keep-open", "always")
|
||||||
|
|
||||||
MPVLib.initialize()
|
mpv.initialize()
|
||||||
|
|
||||||
// Observe properties
|
// Observe properties
|
||||||
observeProperties()
|
observeProperties()
|
||||||
@@ -249,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (isStopping) return
|
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
|
|
||||||
isStopping = true
|
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
try {
|
val m = mpv
|
||||||
MPVLib.removeObserver(this)
|
mpv = null
|
||||||
MPVLib.detachSurface()
|
|
||||||
MPVLib.destroy()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
isStopping = false
|
// Clear cached media state on the main thread so the next player
|
||||||
|
// screen doesn't observe stale position/duration values during the
|
||||||
|
// (async) teardown below.
|
||||||
|
currentUrl = null
|
||||||
|
currentHeaders = null
|
||||||
|
pendingExternalSubtitles = emptyList()
|
||||||
|
initialSubtitleId = null
|
||||||
|
initialAudioId = null
|
||||||
|
cachedPosition = 0.0
|
||||||
|
cachedDuration = 0.0
|
||||||
|
cachedCacheSeconds = 0.0
|
||||||
|
|
||||||
|
if (m == null) return
|
||||||
|
|
||||||
|
// Teardown runs on a background daemon thread. mpv's "stop" command
|
||||||
|
// flushes the demuxer queue and releases the MediaCodec hardware
|
||||||
|
// decoder — synchronous JNI work that can block for hundreds of ms
|
||||||
|
// on TV hardware. Running it on the main thread produced a visible
|
||||||
|
// delay/stutter between pressing "exit" and the confirm alert
|
||||||
|
// appearing. The local `m` keeps the MPVLib instance alive for the
|
||||||
|
// lifetime of this thread even though we've already nulled `mpv`.
|
||||||
|
Thread {
|
||||||
|
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
||||||
|
// force-window=yes, mpv tears down the decoder at stop time but
|
||||||
|
// tries to keep the VO alive — which fires an internal
|
||||||
|
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
||||||
|
// reconfig path crashes with "Missing surface pointer" because we
|
||||||
|
// detach the Surface below before mpv's worker reaches the
|
||||||
|
// reconfig step (command() is async). Setting force-window=no
|
||||||
|
// first makes mpv tear VO down cleanly instead of attempting a
|
||||||
|
// doomed re-init, eliminating the fatal VO error and the
|
||||||
|
// "playback won't restart" aftermath.
|
||||||
|
try {
|
||||||
|
m.setOptionString("force-window", "no")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Stop playback — flushes demuxer queue and signals MediaCodec
|
||||||
|
// to release its hardware decoders. This is the bulk of what
|
||||||
|
// we can reclaim without calling destroy().
|
||||||
|
m.command(arrayOf("stop"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
m.removeObserver(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
m.detachSurface()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
||||||
|
}
|
||||||
|
}.also { it.isDaemon = true }.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = surface
|
this.surface = surface
|
||||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.attachSurface(surface)
|
mpv?.attachSurface(surface)
|
||||||
MPVLib.setOptionString("force-window", "yes")
|
mpv?.setOptionString("force-window", "yes")
|
||||||
// Read back vo to confirm it's still active
|
// Read back vo to confirm it's still active
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = null
|
this.surface = null
|
||||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.detachSurface()
|
mpv?.detachSurface()
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
@@ -329,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
val pos = cachedPosition
|
val pos = cachedPosition
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
MPVLib.command(arrayOf("frame-step"))
|
mpv?.command(arrayOf("frame-step"))
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +397,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
startPosition: Double? = null,
|
startPosition: Double? = null,
|
||||||
externalSubtitles: List<String>? = null,
|
externalSubtitles: List<String>? = null,
|
||||||
initialSubtitleId: Int? = null,
|
initialSubtitleId: Int? = null,
|
||||||
initialAudioId: Int? = null
|
initialAudioId: Int? = null,
|
||||||
|
cacheEnabled: String? = null,
|
||||||
|
cacheSeconds: Int? = null,
|
||||||
|
demuxerMaxBytes: Int? = null,
|
||||||
|
demuxerMaxBackBytes: Int? = null
|
||||||
) {
|
) {
|
||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
@@ -354,16 +414,26 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
|
||||||
// Stop previous playback
|
// Stop previous playback
|
||||||
MPVLib.command(arrayOf("stop"))
|
mpv?.command(arrayOf("stop"))
|
||||||
|
|
||||||
// Set HTTP headers if provided
|
// Set HTTP headers if provided
|
||||||
updateHttpHeaders(headers)
|
updateHttpHeaders(headers)
|
||||||
|
|
||||||
// Set start position
|
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
||||||
|
// These override the conservative defaults applied in start() so the
|
||||||
|
// TV/mobile settings screen actually takes effect on Android.
|
||||||
|
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
||||||
|
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
||||||
|
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
||||||
|
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
||||||
|
|
||||||
|
// Set start position. mpv's time parser requires '.' as the decimal
|
||||||
|
// separator; use Locale.US so devices with other default locales
|
||||||
|
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
||||||
if (startPosition != null && startPosition > 0) {
|
if (startPosition != null && startPosition > 0) {
|
||||||
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||||
} else {
|
} else {
|
||||||
MPVLib.setPropertyString("start", "0")
|
mpv?.setPropertyString("start", "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial audio track if specified
|
// Set initial audio track if specified
|
||||||
@@ -383,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load the file
|
// Load the file
|
||||||
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadCurrentItem() {
|
fun reloadCurrentItem() {
|
||||||
@@ -399,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||||
MPVLib.setPropertyString("http-header-fields", headerString)
|
mpv?.setPropertyString("http-header-fields", headerString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeProperties() {
|
private fun observeProperties() {
|
||||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||||
// Video dimensions for PiP aspect ratio
|
// Video dimensions for PiP aspect ratio
|
||||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
MPVLib.setPropertyBoolean("pause", false)
|
mpv?.setPropertyBoolean("pause", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
MPVLib.setPropertyBoolean("pause", true)
|
mpv?.setPropertyBoolean("pause", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePause() {
|
fun togglePause() {
|
||||||
@@ -431,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun seekTo(seconds: Double) {
|
fun seekTo(seconds: Double) {
|
||||||
val clamped = maxOf(0.0, seconds)
|
val clamped = maxOf(0.0, seconds)
|
||||||
cachedPosition = clamped
|
cachedPosition = clamped
|
||||||
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(seconds: Double) {
|
fun seekBy(seconds: Double) {
|
||||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||||
cachedPosition = newPosition
|
cachedPosition = newPosition
|
||||||
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
_playbackSpeed = speed
|
_playbackSpeed = speed
|
||||||
MPVLib.setPropertyDouble("speed", speed)
|
mpv?.setPropertyDouble("speed", speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
@@ -454,19 +524,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "sub") continue
|
if (trackType != "sub") continue
|
||||||
|
|
||||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||||
|
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
|
||||||
|
track["external"] = external
|
||||||
|
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
|
||||||
|
track["externalFilename"] = it
|
||||||
|
}
|
||||||
|
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
|
||||||
|
|
||||||
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -478,61 +559,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||||
if (trackId < 0) {
|
if (trackId < 0) {
|
||||||
MPVLib.setPropertyString("sid", "no")
|
mpv?.setPropertyString("sid", "no")
|
||||||
} else {
|
} else {
|
||||||
MPVLib.setPropertyInt("sid", trackId)
|
mpv?.setPropertyInt("sid", trackId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
MPVLib.setPropertyString("sid", "no")
|
mpv?.setPropertyString("sid", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return MPVLib.getPropertyInt("sid") ?: 0
|
return mpv?.getPropertyInt("sid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
MPVLib.command(arrayOf("sub-add", url, flag))
|
mpv?.command(arrayOf("sub-add", url, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
MPVLib.setPropertyInt("sub-pos", position)
|
mpv?.setPropertyInt("sub-pos", position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
mpv?.setPropertyDouble("sub-scale", scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
MPVLib.setPropertyInt("sub-margin-y", margin)
|
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
MPVLib.setPropertyString("sub-align-x", alignment)
|
mpv?.setPropertyString("sub-align-x", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
MPVLib.setPropertyString("sub-align-y", alignment)
|
mpv?.setPropertyString("sub-align-y", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
MPVLib.setPropertyInt("sub-font-size", size)
|
mpv?.setPropertyInt("sub-font-size", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBorderStyle(style: String) {
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
MPVLib.setPropertyString("sub-border-style", style)
|
mpv?.setPropertyString("sub-border-style", style)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBackgroundColor(color: String) {
|
fun setSubtitleBackgroundColor(color: String) {
|
||||||
MPVLib.setPropertyString("sub-back-color", color)
|
mpv?.setPropertyString("sub-back-color", color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAssOverride(mode: String) {
|
fun setSubtitleAssOverride(mode: String) {
|
||||||
MPVLib.setPropertyString("sub-ass-override", mode)
|
mpv?.setPropertyString("sub-ass-override", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
@@ -540,25 +621,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "audio") continue
|
if (trackType != "audio") continue
|
||||||
|
|
||||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
||||||
if (channels != null && channels > 0) {
|
if (channels != null && channels > 0) {
|
||||||
track["channels"] = channels
|
track["channels"] = channels
|
||||||
}
|
}
|
||||||
|
|
||||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -569,11 +650,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||||
MPVLib.setPropertyInt("aid", trackId)
|
mpv?.setPropertyInt("aid", trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return MPVLib.getPropertyInt("aid") ?: 0
|
return mpv?.getPropertyInt("aid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Scaling
|
// MARK: - Video Scaling
|
||||||
@@ -582,7 +663,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Technical Info
|
// MARK: - Technical Info
|
||||||
@@ -591,58 +672,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
val info = mutableMapOf<String, Any>()
|
val info = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
// Video dimensions
|
// Video dimensions
|
||||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||||
info["videoWidth"] = it
|
info["videoWidth"] = it
|
||||||
}
|
}
|
||||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||||
info["videoHeight"] = it
|
info["videoHeight"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video codec
|
// Video codec
|
||||||
MPVLib.getPropertyString("video-format")?.let {
|
mpv?.getPropertyString("video-format")?.let {
|
||||||
info["videoCodec"] = it
|
info["videoCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio codec
|
// Audio codec
|
||||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||||
info["audioCodec"] = it
|
info["audioCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// FPS (container fps)
|
// FPS (container fps)
|
||||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||||
info["fps"] = it
|
info["fps"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video bitrate (bits per second)
|
// Video bitrate (bits per second)
|
||||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["videoBitrate"] = it
|
info["videoBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio bitrate (bits per second)
|
// Audio bitrate (bits per second)
|
||||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["audioBitrate"] = it
|
info["audioBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demuxer cache duration (seconds of video buffered)
|
// Demuxer cache duration (seconds of video buffered)
|
||||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||||
info["cacheSeconds"] = it
|
info["cacheSeconds"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured cache limits — read back from mpv to confirm user
|
||||||
|
// settings actually took effect. mpv stores byte sizes as int64
|
||||||
|
// (bytes); convert to MiB for display.
|
||||||
|
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
||||||
|
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
||||||
|
}
|
||||||
|
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
||||||
|
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
||||||
|
}
|
||||||
|
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
||||||
|
info["cacheSecsLimit"] = secs
|
||||||
|
}
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||||
info["droppedFrames"] = it
|
info["droppedFrames"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||||
MPVLib.getPropertyString("vo")?.let {
|
mpv?.getPropertyString("vo")?.let {
|
||||||
info["voDriver"] = it
|
info["voDriver"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active hardware decoder
|
// Active hardware decoder.
|
||||||
MPVLib.getPropertyString("hwdec-active")?.let {
|
// hwdec-current yields e.g. "mediacodec",
|
||||||
|
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||||
|
mpv?.getPropertyString("hwdec-current")?.let {
|
||||||
info["hwdec"] = it
|
info["hwdec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estimated video output fps (renderer-side, after filtering).
|
||||||
|
// Useful for diagnosing display/pipeline drops vs container fps.
|
||||||
|
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
||||||
|
info["estimatedVfFps"] = it
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +837,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||||
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
||||||
}
|
}
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import android.view.Surface
|
|
||||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||||
* This provides a consistent interface for the rest of the app.
|
*
|
||||||
|
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
||||||
|
* a fresh, independent handle. Each player creates its own MPVLib instance
|
||||||
|
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
||||||
|
* call `LibMPV.destroy()` — its native implementation has an internal
|
||||||
|
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
||||||
|
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
||||||
|
* handle lives in process-global state until exit) is strictly safer than
|
||||||
|
* crashing.
|
||||||
|
*
|
||||||
|
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
||||||
|
* stays allocated until the next player's allocation displaces it in scudo's
|
||||||
|
* arena. On a TV app where the player is the dominant memory consumer and
|
||||||
|
* only one player is alive at a time, this is acceptable.
|
||||||
*/
|
*/
|
||||||
object MPVLib {
|
class MPVLib private constructor(private val instance: LibMPV) {
|
||||||
private const val TAG = "MPVLib"
|
|
||||||
|
|
||||||
private var initialized = false
|
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||||
|
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||||
// Event observer interface
|
|
||||||
interface EventObserver {
|
interface EventObserver {
|
||||||
fun eventProperty(property: String)
|
fun eventProperty(property: String)
|
||||||
fun eventProperty(property: String, value: Long)
|
fun eventProperty(property: String, value: Long)
|
||||||
@@ -26,70 +35,117 @@ object MPVLib {
|
|||||||
|
|
||||||
private val observers = mutableListOf<EventObserver>()
|
private val observers = mutableListOf<EventObserver>()
|
||||||
|
|
||||||
// Library event observer that forwards to our observers
|
// Library event observer that forwards LibMPV callbacks to our observers.
|
||||||
private val libObserver = object : LibMPV.EventObserver {
|
private val libObserver = object : LibMPV.EventObserver {
|
||||||
override fun eventProperty(property: String) {
|
override fun eventProperty(property: String) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Long) {
|
override fun eventProperty(property: String, value: Long) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Boolean) {
|
override fun eventProperty(property: String, value: Boolean) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: String) {
|
override fun eventProperty(property: String, value: String) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Double) {
|
override fun eventProperty(property: String, value: Double) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun event(eventId: Int) {
|
override fun event(eventId: Int) =
|
||||||
|
dispatch { it.event(eventId) }
|
||||||
|
|
||||||
|
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||||
synchronized(observers) {
|
synchronized(observers) {
|
||||||
for (observer in observers) {
|
observers.forEach(block)
|
||||||
observer.event(eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addObserver(observer: EventObserver) {
|
fun addObserver(observer: EventObserver) {
|
||||||
synchronized(observers) {
|
synchronized(observers) { observers.add(observer) }
|
||||||
observers.add(observer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeObserver(observer: EventObserver) {
|
fun removeObserver(observer: EventObserver) {
|
||||||
synchronized(observers) {
|
synchronized(observers) { observers.remove(observer) }
|
||||||
observers.remove(observer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MPV Event IDs
|
fun initialize() {
|
||||||
|
instance.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachSurface(surface: android.view.Surface) {
|
||||||
|
instance.attachSurface(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachSurface() {
|
||||||
|
instance.detachSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun command(cmd: Array<String>) {
|
||||||
|
instance.command(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOptionString(name: String, value: String): Int {
|
||||||
|
return instance.setOptionString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyInt(name: String): Int? = try {
|
||||||
|
instance.getPropertyInt(name)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
fun getPropertyDouble(name: String): Double? = try {
|
||||||
|
instance.getPropertyDouble(name)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
fun getPropertyBoolean(name: String): Boolean? = try {
|
||||||
|
instance.getPropertyBoolean(name)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
fun getPropertyString(name: String): String? = try {
|
||||||
|
instance.getPropertyString(name)
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
|
||||||
|
fun setPropertyInt(name: String, value: Int) {
|
||||||
|
instance.setPropertyInt(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyDouble(name: String, value: Double) {
|
||||||
|
instance.setPropertyDouble(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||||
|
instance.setPropertyBoolean(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyString(name: String, value: String) {
|
||||||
|
instance.setPropertyString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeProperty(name: String, format: Int) {
|
||||||
|
instance.observeProperty(name, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Create a fresh mpv handle. Each call returns an independent instance —
|
||||||
|
* do not share across players. Attach exactly one [EventObserver] per
|
||||||
|
* player via [addObserver].
|
||||||
|
*/
|
||||||
|
fun create(context: Context): MPVLib {
|
||||||
|
val lib = LibMPV.create(context)
|
||||||
|
?: throw IllegalStateException("LibMPV.create returned null")
|
||||||
|
val wrapper = MPVLib(lib)
|
||||||
|
// The libObserver is attached for the lifetime of this MPVLib
|
||||||
|
// instance and forwards every LibMPV callback to our observers
|
||||||
|
// list. Player-specific observers are added/removed via
|
||||||
|
// addObserver/removeObserver.
|
||||||
|
lib.addObserver(wrapper.libObserver)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPV Event IDs (kept here so observers can reference them without
|
||||||
|
// holding a reference to an instance).
|
||||||
const val MPV_EVENT_NONE = 0
|
const val MPV_EVENT_NONE = 0
|
||||||
const val MPV_EVENT_SHUTDOWN = 1
|
const val MPV_EVENT_SHUTDOWN = 1
|
||||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||||
@@ -115,106 +171,5 @@ object MPVLib {
|
|||||||
const val MPV_END_FILE_REASON_QUIT = 3
|
const val MPV_END_FILE_REASON_QUIT = 3
|
||||||
const val MPV_END_FILE_REASON_ERROR = 4
|
const val MPV_END_FILE_REASON_ERROR = 4
|
||||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and initialize the MPV library
|
|
||||||
*/
|
|
||||||
fun create(context: Context, configDir: String? = null) {
|
|
||||||
if (initialized) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
LibMPV.create(context)
|
|
||||||
LibMPV.addObserver(libObserver)
|
|
||||||
initialized = true
|
|
||||||
Log.i(TAG, "libmpv created successfully")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
LibMPV.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
if (!initialized) return
|
|
||||||
try {
|
|
||||||
LibMPV.removeObserver(libObserver)
|
|
||||||
LibMPV.destroy()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
|
||||||
}
|
|
||||||
initialized = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isInitialized(): Boolean = initialized
|
|
||||||
|
|
||||||
fun attachSurface(surface: Surface) {
|
|
||||||
LibMPV.attachSurface(surface)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun detachSurface() {
|
|
||||||
LibMPV.detachSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun command(cmd: Array<String?>) {
|
|
||||||
LibMPV.command(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOptionString(name: String, value: String): Int {
|
|
||||||
return LibMPV.setOptionString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyInt(name: String): Int? {
|
|
||||||
return try {
|
|
||||||
LibMPV.getPropertyInt(name)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyDouble(name: String): Double? {
|
|
||||||
return try {
|
|
||||||
LibMPV.getPropertyDouble(name)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyBoolean(name: String): Boolean? {
|
|
||||||
return try {
|
|
||||||
LibMPV.getPropertyBoolean(name)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyString(name: String): String? {
|
|
||||||
return try {
|
|
||||||
LibMPV.getPropertyString(name)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyInt(name: String, value: Int) {
|
|
||||||
LibMPV.setPropertyInt(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyDouble(name: String, value: Double) {
|
|
||||||
LibMPV.setPropertyDouble(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
|
||||||
LibMPV.setPropertyBoolean(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyString(name: String, value: String) {
|
|
||||||
LibMPV.setPropertyString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeProperty(name: String, format: Int) {
|
|
||||||
LibMPV.observeProperty(name, format)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class MpvPlayerModule : Module() {
|
|||||||
|
|
||||||
val urlString = source["url"] as? String ?: return@Prop
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
// Parse cache config if provided (mirrors iOS)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val config = VideoLoadConfig(
|
val config = VideoLoadConfig(
|
||||||
url = urlString,
|
url = urlString,
|
||||||
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
|
|||||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
voDriver = source["voDriver"] as? String
|
voDriver = source["voDriver"] as? String,
|
||||||
|
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||||
|
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||||
)
|
)
|
||||||
|
|
||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop playback and release the MediaCodec decoder + demuxer.
|
||||||
|
// Does not synchronously tear down the native mpv handle (see
|
||||||
|
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
||||||
|
// away from the player screen to avoid OOM during screen
|
||||||
|
// transitions on low-RAM devices.
|
||||||
|
AsyncFunction("destroy") { view: MpvPlayerView ->
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||||
view.seekTo(position)
|
view.seekTo(position)
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.SurfaceTexture
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.TextureView
|
import android.view.SurfaceHolder
|
||||||
import android.view.View
|
import android.view.SurfaceView
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
@@ -26,15 +24,30 @@ data class VideoLoadConfig(
|
|||||||
val autoplay: Boolean = true,
|
val autoplay: Boolean = true,
|
||||||
val initialSubtitleId: Int? = null,
|
val initialSubtitleId: Int? = null,
|
||||||
val initialAudioId: Int? = null,
|
val initialAudioId: Int? = null,
|
||||||
val voDriver: String? = null
|
val voDriver: String? = null,
|
||||||
|
val cacheEnabled: String? = null,
|
||||||
|
val cacheSeconds: Int? = null,
|
||||||
|
val demuxerMaxBytes: Int? = null,
|
||||||
|
val demuxerMaxBackBytes: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
* Uses TextureView for reliable Picture-in-Picture support.
|
*
|
||||||
|
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
||||||
|
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
||||||
|
* app's window surface. This matches mpv-android's architecture and
|
||||||
|
* gives mpv a standalone surface.
|
||||||
|
*
|
||||||
|
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
||||||
|
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
||||||
|
* can be stale until the next layout pass. We push dimension updates to
|
||||||
|
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
||||||
|
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
||||||
|
* passes on the view itself) reaches mpv promptly.
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
@@ -48,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
private var textureView: TextureView
|
private var surfaceView: SurfaceView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
@@ -59,30 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var surfaceReady: Boolean = false
|
private var surfaceReady: Boolean = false
|
||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var pendingSurface: Surface? = null
|
private var activeSurface: Surface? = null
|
||||||
private var surfaceTexture: SurfaceTexture? = null
|
|
||||||
|
|
||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
private var isWaitingForPiPTransition: Boolean = false
|
|
||||||
private var isPiPSurfaceForced: Boolean = false
|
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
// SurfaceView for video rendering. Routes the surface directly to
|
||||||
textureView = TextureView(context).apply {
|
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
||||||
|
// surface. TextureView composites into the app's window surface
|
||||||
|
// which is less efficient and breaks PiP transitions.
|
||||||
|
surfaceView = SurfaceView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
surfaceTextureListener = this@MpvPlayerView
|
|
||||||
}
|
}
|
||||||
addView(textureView)
|
surfaceView.holder.addCallback(this@MpvPlayerView)
|
||||||
|
addView(surfaceView)
|
||||||
|
|
||||||
|
// Push dimension updates to mpv on every view bounds change. This
|
||||||
|
// is the primary PiP black-screen fix: entering PiP fires a layout
|
||||||
|
// pass on the SurfaceView itself, and we proactively tell mpv the
|
||||||
|
// new size so it resizes its EGL swapchain before rendering.
|
||||||
|
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
||||||
|
oldLeft, oldTop, oldRight, oldBottom ->
|
||||||
|
val w = right - left
|
||||||
|
val h = bottom - top
|
||||||
|
val oldW = oldRight - oldLeft
|
||||||
|
val oldH = oldBottom - oldTop
|
||||||
|
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
||||||
|
renderer?.updateSurfaceSize(w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(textureView)
|
pipController?.setPlayerView(surfaceView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -98,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
if (isInPiP) {
|
if (isInPiP) {
|
||||||
if (!isWaitingForPiPTransition) {
|
// Post size syncs after the PiP layout settles. Two passes
|
||||||
isWaitingForPiPTransition = true
|
// catch both the immediate surface re-attach and the
|
||||||
|
// post-animation layout pass. Replaces the old TextureView
|
||||||
|
// measure/layout polling hack (forcePiPBufferSize).
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isWaitingForPiPTransition = false
|
// Restore from PiP: surface resized back to fullscreen.
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
restoreFromPiP()
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||||
}
|
}
|
||||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
}
|
}
|
||||||
@@ -121,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the renderer with the given VO driver.
|
* Start the renderer with the given VO driver.
|
||||||
* Called lazily on first loadVideo so the voDriver setting is available.
|
* Called lazily on first loadVideo so user settings are available.
|
||||||
*/
|
*/
|
||||||
private fun ensureRendererStarted(voDriver: String?) {
|
private fun ensureRendererStarted(voDriver: String?) {
|
||||||
if (rendererStarted) return
|
if (rendererStarted) return
|
||||||
@@ -130,9 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
|
|
||||||
pendingSurface?.let { surface ->
|
// If the surface is already alive (surfaceCreated fired before
|
||||||
|
// loadVideo), attach it now. With SurfaceView the surface is
|
||||||
|
// owned by the holder, so we read it from there directly rather
|
||||||
|
// than stashing it on the side.
|
||||||
|
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
||||||
|
activeSurface = surface
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
pendingSurface = null
|
syncSurfaceSizeToView()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
@@ -140,18 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TextureView.SurfaceTextureListener
|
// MARK: - SurfaceHolder.Callback
|
||||||
|
|
||||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
this.surfaceTexture = surfaceTexture
|
val surface = holder.surface
|
||||||
val surface = Surface(surfaceTexture)
|
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
|
// 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)
|
renderer?.attachSurface(surface)
|
||||||
} else {
|
// Push the actual view dimensions immediately so mpv doesn't
|
||||||
pendingSurface = surface
|
// render against stale full-screen geometry during PiP transitions.
|
||||||
|
syncSurfaceSizeToView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -162,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
if (width > 0 && height > 0) {
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
|
||||||
this.surfaceTexture = null
|
|
||||||
surfaceReady = false
|
|
||||||
renderer?.detachSurface()
|
|
||||||
return false // mpv manages the SurfaceTexture
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
// Called every frame — no action needed, mpv drives rendering directly
|
surfaceReady = false
|
||||||
|
renderer?.detachSurface()
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the actual SurfaceView width/height and push them to mpv.
|
||||||
|
* The PiP transition can fire surfaceCreated before the view's layout
|
||||||
|
* has settled to PiP dimensions, so we re-sync after layout passes.
|
||||||
|
*/
|
||||||
|
private fun syncSurfaceSizeToView() {
|
||||||
|
if (!surfaceReady) return
|
||||||
|
val w = surfaceView.width
|
||||||
|
val h = surfaceView.height
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
renderer?.updateSurfaceSize(w, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
@@ -207,7 +258,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
startPosition = config.startPosition,
|
startPosition = config.startPosition,
|
||||||
externalSubtitles = config.externalSubtitles,
|
externalSubtitles = config.externalSubtitles,
|
||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId
|
initialAudioId = config.initialAudioId,
|
||||||
|
cacheEnabled = config.cacheEnabled,
|
||||||
|
cacheSeconds = config.cacheSeconds,
|
||||||
|
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||||
|
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
@@ -236,6 +291,50 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback and release decoder resources.
|
||||||
|
*
|
||||||
|
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
||||||
|
* on a background thread (flushing the demuxer and releasing the
|
||||||
|
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
||||||
|
*
|
||||||
|
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
||||||
|
* nativeDestroy has an internal use-after-free on the JNI global ref
|
||||||
|
* path, so the native mpv handle is intentionally left for the JVM GC
|
||||||
|
* / native finalizer rather than torn down synchronously. See
|
||||||
|
* [MPVLib] class doc for the full rationale.
|
||||||
|
*
|
||||||
|
* Call this BEFORE navigating away from the player screen so the
|
||||||
|
* decoder is reclaimed before the next screen (or the next episode's
|
||||||
|
* player) mounts. Otherwise Expo Router renders the new screen first
|
||||||
|
* and you briefly have two mpv instances + two 4K decoders alive —
|
||||||
|
* instant OOM on a 2 GB device.
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
renderer?.stop()
|
||||||
|
|
||||||
|
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
||||||
|
// instance re-creates the mpv handle and re-attaches the still-live
|
||||||
|
// 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()
|
||||||
|
// against mpv == null, where every mpv?.command() (including the
|
||||||
|
// "stop" and load commands) silently no-ops, leaving a black frame.
|
||||||
|
//
|
||||||
|
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||||
|
// 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
|
||||||
|
activeSurface = null
|
||||||
|
}
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
@@ -267,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
isWaitingForPiPTransition = true
|
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
// Resize buffer to match PiP window after animation settles
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
|
||||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
|
||||||
*/
|
|
||||||
private fun forcePiPBufferSize() {
|
|
||||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
|
||||||
|
|
||||||
val rect = Rect()
|
|
||||||
textureView.getGlobalVisibleRect(rect)
|
|
||||||
val visW = rect.width()
|
|
||||||
val visH = rect.height()
|
|
||||||
val vw = textureView.width
|
|
||||||
val vh = textureView.height
|
|
||||||
|
|
||||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
|
||||||
|
|
||||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
|
||||||
renderer?.updateSurfaceSize(visW, visH)
|
|
||||||
|
|
||||||
// Force TextureView layout to match PiP visible area.
|
|
||||||
// layoutParams alone doesn't work during PiP because the parent
|
|
||||||
// never re-lays out its children.
|
|
||||||
textureView.measure(
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
|
||||||
)
|
|
||||||
textureView.layout(0, 0, visW, visH)
|
|
||||||
isPiPSurfaceForced = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreFromPiP() {
|
|
||||||
if (!isPiPSurfaceForced) return
|
|
||||||
isPiPSurfaceForced = false
|
|
||||||
|
|
||||||
val lp = textureView.layoutParams
|
|
||||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
textureView.layoutParams = lp
|
|
||||||
textureView.requestLayout()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
@@ -479,13 +529,24 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactively tear down the player. Called from onDetachedFromWindow so
|
||||||
|
* the app releases mpv + decoder buffers when the View detaches from the
|
||||||
|
* window. The JS-facing destroy() is intentionally thinner (just
|
||||||
|
* renderer.stop()) — see this thread for why the full teardown was kept
|
||||||
|
* off the JS path.
|
||||||
|
*/
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
surfaceTexture = null
|
renderer?.delegate = null
|
||||||
|
|
||||||
|
// SurfaceView owns the Surface via its holder — do NOT release it.
|
||||||
|
activeSurface = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
|
currentUrl = null
|
||||||
|
rendererStarted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
|
// Independently tracks whether the system should auto-enter PiP on home
|
||||||
|
// press. Decoupled from playbackRate so that disabling auto-enter
|
||||||
|
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
||||||
|
// state that buildPiPActions() derives from playbackRate.
|
||||||
|
private var autoEnterEnabled: Boolean = false
|
||||||
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
// Disable auto-enter eligibility without touching playbackRate.
|
||||||
|
// playbackRate drives the play/pause icon in buildPiPActions();
|
||||||
|
// mutating it here would cause a stale icon if PiP is re-entered
|
||||||
|
// before the next playback state callback corrects it.
|
||||||
|
autoEnterEnabled = false
|
||||||
isInPiPMode = false
|
isInPiPMode = false
|
||||||
pipEntryNotified = false
|
pipEntryNotified = false
|
||||||
unregisterLifecycleCallbacks()
|
unregisterLifecycleCallbacks()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val activity = getActivity()
|
val activity = getActivity() ?: return
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
|
||||||
activity.moveTaskToBack(false)
|
// Push minimal params with just auto-enter disabled. Do NOT call
|
||||||
|
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
||||||
|
// setActions(), which would re-register the broadcast receiver
|
||||||
|
// (just unregistered above) and attach play/pause/skip actions to
|
||||||
|
// params being torn down. That leaves a live receiver + stale
|
||||||
|
// actions after the player has unmounted.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
try {
|
||||||
|
activity.setPictureInPictureParams(
|
||||||
|
PictureInPictureParams.Builder()
|
||||||
|
.setAutoEnterEnabled(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (activity.isInPictureInPictureMode) {
|
||||||
|
activity.moveTaskToBack(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
|
autoEnterEnabled = rate > 0
|
||||||
|
|
||||||
if (rate > 0) {
|
if (rate > 0) {
|
||||||
registerLifecycleCallbacks()
|
registerLifecycleCallbacks()
|
||||||
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
builder.setActions(buildPiPActions())
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
|
|||||||
@@ -771,11 +771,31 @@ final class MPVLayerRenderer {
|
|||||||
track["lang"] = lang
|
track["lang"] = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||||
|
track["codec"] = codec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||||
|
var external: Int32 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
|
||||||
|
track["external"] = external != 0
|
||||||
|
|
||||||
|
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
|
||||||
|
track["externalFilename"] = extFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
var ffIndex: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
|
||||||
|
track["ffIndex"] = Int(ffIndex)
|
||||||
|
}
|
||||||
|
|
||||||
var selected: Int32 = 0
|
var selected: Int32 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
track["selected"] = selected != 0
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,12 +1040,44 @@ final class MPVLayerRenderer {
|
|||||||
info["cacheSeconds"] = cacheSeconds
|
info["cacheSeconds"] = cacheSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured cache limits — read back from mpv to confirm user
|
||||||
|
// settings actually took effect. mpv stores byte sizes as int64
|
||||||
|
// (bytes); convert to MiB for display.
|
||||||
|
var demuxerMaxBytes: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
||||||
|
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
||||||
|
}
|
||||||
|
var demuxerMaxBackBytes: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
||||||
|
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
||||||
|
}
|
||||||
|
var cacheSecsLimit: Double = 0
|
||||||
|
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
||||||
|
info["cacheSecsLimit"] = cacheSecsLimit
|
||||||
|
}
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
var droppedFrames: Int64 = 0
|
var droppedFrames: Int64 = 0
|
||||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||||
info["droppedFrames"] = Int(droppedFrames)
|
info["droppedFrames"] = Int(droppedFrames)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active video output driver
|
||||||
|
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
||||||
|
info["voDriver"] = voDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active hardware decoder
|
||||||
|
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
||||||
|
info["hwdec"] = hwdec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimated video output fps (post-filter)
|
||||||
|
var estimatedVfFps: Double = 0
|
||||||
|
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
||||||
|
info["estimatedVfFps"] = estimatedVfFps
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ public class MpvPlayerModule: Module {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronously destroy mpv instance + decoder before navigating
|
||||||
|
// away from the player screen (cross-platform; matches Android).
|
||||||
|
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
view.seekTo(position: position)
|
view.seekTo(position: position)
|
||||||
|
|||||||
@@ -289,6 +289,49 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
||||||
|
* freed before the next screen mounts. Safe to call multiple times — the
|
||||||
|
* underlying renderer.stop() guards against re-entry.
|
||||||
|
*
|
||||||
|
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
||||||
|
*/
|
||||||
|
func destroy() {
|
||||||
|
renderer?.stop()
|
||||||
|
|
||||||
|
// Reset view state and re-create the mpv handle so a subsequent
|
||||||
|
// loadVideo() on the SAME view instance can actually load.
|
||||||
|
// Without this, stop() leaves renderer.mpv == nil, and the next
|
||||||
|
// loadVideo(config:) calls renderer.load() which early-returns
|
||||||
|
// at `guard let handle = self.mpv else { return }` — but only
|
||||||
|
// after flipping isLoading = true and dispatching the loading
|
||||||
|
// delegate callback, so the JS layer is stuck in a perpetual
|
||||||
|
// "loading" state with no actual playback.
|
||||||
|
//
|
||||||
|
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||||
|
// which call destroy() immediately before router.replace() to
|
||||||
|
// the same route — Expo Router reuses the same MpvPlayerView
|
||||||
|
// instance, so the next `source` prop update arrives on this
|
||||||
|
// view without a remount. setupView() is otherwise the only
|
||||||
|
// place start() is called, so without re-starting here the
|
||||||
|
// renderer stays dead until the whole view is unmounted and
|
||||||
|
// recreated.
|
||||||
|
//
|
||||||
|
// start() is idempotent (`guard !isRunning else { return }`)
|
||||||
|
// and stop() has already nulled mpv synchronously before
|
||||||
|
// dispatching the async mpv_terminate_destroy, so creating a
|
||||||
|
// fresh handle here is safe even while the old handle's
|
||||||
|
// teardown is still in flight on a background queue (libmpv
|
||||||
|
// handles are independent).
|
||||||
|
currentURL = nil
|
||||||
|
intendedPlayState = false
|
||||||
|
do {
|
||||||
|
try renderer?.start()
|
||||||
|
} catch {
|
||||||
|
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
|
|||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
||||||
|
* Call before navigating away from the player screen so memory is
|
||||||
|
* freed before the next screen mounts. Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
||||||
|
// stop: () => Promise<void>;
|
||||||
seekTo: (position: number) => Promise<void>;
|
seekTo: (position: number) => Promise<void>;
|
||||||
seekBy: (offset: number) => Promise<void>;
|
seekBy: (offset: number) => Promise<void>;
|
||||||
setSpeed: (speed: number) => Promise<void>;
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
@@ -133,6 +141,14 @@ export type SubtitleTrack = {
|
|||||||
id: number;
|
id: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
|
||||||
|
codec?: string;
|
||||||
|
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
|
||||||
|
external?: boolean;
|
||||||
|
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||||
|
externalFilename?: string;
|
||||||
|
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
|
||||||
|
ffIndex?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,9 +170,17 @@ export type TechnicalInfo = {
|
|||||||
videoBitrate?: number;
|
videoBitrate?: number;
|
||||||
audioBitrate?: number;
|
audioBitrate?: number;
|
||||||
cacheSeconds?: number;
|
cacheSeconds?: number;
|
||||||
|
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
||||||
|
demuxerMaxBytes?: number;
|
||||||
|
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
||||||
|
demuxerMaxBackBytes?: number;
|
||||||
|
/** Configured cache-secs floor, read back from mpv */
|
||||||
|
cacheSecsLimit?: number;
|
||||||
droppedFrames?: number;
|
droppedFrames?: number;
|
||||||
/** Active video output driver (read from MPV at runtime) */
|
/** Active video output driver (read from MPV at runtime) */
|
||||||
voDriver?: string;
|
voDriver?: string;
|
||||||
/** Active hardware decoder (read from MPV at runtime) */
|
/** Active hardware decoder (read from MPV at runtime) */
|
||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
|
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||||
|
estimatedVfFps?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
pause: async () => {
|
pause: async () => {
|
||||||
await nativeRef.current?.pause();
|
await nativeRef.current?.pause();
|
||||||
},
|
},
|
||||||
|
destroy: async () => {
|
||||||
|
await nativeRef.current?.destroy();
|
||||||
|
},
|
||||||
seekTo: async (position: number) => {
|
seekTo: async (position: number) => {
|
||||||
await nativeRef.current?.seekTo(position);
|
await nativeRef.current?.seekTo(position);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
"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",
|
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "bun scripts/typecheck.ts",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
"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",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
@@ -134,8 +134,9 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.7",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { withPodfile } = require("expo/config-plugins");
|
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||||
|
|
||||||
const PATCH_START = "## >>> runtime-framework headers";
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
const PATCH_END = "## <<< runtime-framework headers";
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
|||||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildPatch() {
|
function buildPatch(): string {
|
||||||
return [
|
return [
|
||||||
PATCH_START,
|
PATCH_START,
|
||||||
" extra_hdrs = [",
|
" extra_hdrs = [",
|
||||||
@@ -91,7 +91,7 @@ function buildPatch() {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function withRuntimeFrameworkHeaders(config) {
|
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
let podfile = config.modResults.contents;
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -125,3 +125,5 @@ end
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withRuntimeFrameworkHeaders;
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
const {
|
import {
|
||||||
|
type ConfigPlugin,
|
||||||
withAndroidColors,
|
withAndroidColors,
|
||||||
withAndroidColorsNight,
|
withAndroidColorsNight,
|
||||||
} = require("expo/config-plugins");
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const withAndroidAlertColors = (config) => {
|
interface ColorResourceItem {
|
||||||
const setColor = (colorsList, name, value) => {
|
$: { name: string };
|
||||||
|
_: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||||
|
const setColor = (
|
||||||
|
colorsList: ColorResourceItem[],
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
const existingColor = colorsList.find(
|
const existingColor = colorsList.find(
|
||||||
(item) => item.$ && item.$.name === name,
|
(item) => item.$ && item.$.name === name,
|
||||||
);
|
);
|
||||||
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColors(config, (config) => {
|
config = withAndroidColors(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = colors.resources.color || [];
|
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||||
setColor(colorsList, "colorPrimary", "#000000");
|
setColor(colorsList, "colorPrimary", "#000000");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColorsNight(config, (config) => {
|
config = withAndroidColorsNight(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = colors.resources.color || [];
|
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withAndroidAlertColors;
|
export default withAndroidAlertColors;
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
const { withAndroidManifest } = require("expo/config-plugins");
|
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
||||||
|
|
||||||
const _withGoogleCastAndroidManifest = (config) =>
|
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
||||||
withAndroidManifest(config, async (mod) => {
|
withAndroidManifest(config, async (mod) => {
|
||||||
const mainApplication = mod.modResults.manifest.application[0];
|
const mainApplication = mod.modResults.manifest.application?.[0];
|
||||||
|
|
||||||
|
if (!mainApplication) {
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
// Initialize activity array if it doesn't exist
|
||||||
if (!mainApplication.activity) {
|
if (!mainApplication.activity) {
|
||||||
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
|
|||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = _withGoogleCastAndroidManifest;
|
export default withGoogleCastAndroidManifest;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
const { readFileSync, writeFileSync } = require("node:fs");
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
const { join } = require("node:path");
|
import { join } from "node:path";
|
||||||
const { withDangerousMod } = require("expo/config-plugins");
|
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||||
|
|
||||||
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
||||||
withDangerousMod(expoConfig, [
|
withDangerousMod(expoConfig, [
|
||||||
"android",
|
"android",
|
||||||
(modConfig) => {
|
(modConfig) => {
|
||||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
module.exports = withChangeNativeAndroidTextToWhite;
|
export default withChangeNativeAndroidTextToWhite;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { withAppBuildGradle } = require("expo/config-plugins");
|
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
||||||
|
|
||||||
module.exports = function withExcludeMedia3Dash(config) {
|
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
||||||
return withAppBuildGradle(config, (config) => {
|
return withAppBuildGradle(config, (config) => {
|
||||||
const contents = config.modResults.contents;
|
const contents = config.modResults.contents;
|
||||||
|
|
||||||
@@ -32,3 +32,5 @@ configurations.all {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withExcludeMedia3Dash;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
const { withPodfile } = require("@expo/config-plugins");
|
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||||
|
|
||||||
const withGitPod = (config, { podName, podspecUrl }) => {
|
interface GitPodOptions {
|
||||||
|
podName: string;
|
||||||
|
podspecUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withGitPod: ConfigPlugin<GitPodOptions> = (
|
||||||
|
config,
|
||||||
|
{ podName, podspecUrl },
|
||||||
|
) => {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
const podfile = config.modResults.contents;
|
const podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withGitPod;
|
export default withGitPod;
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
const { withGradleProperties } = require("expo/config-plugins");
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
import {
|
||||||
|
AndroidConfig,
|
||||||
|
type ConfigPlugin,
|
||||||
|
withGradleProperties,
|
||||||
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
function setGradlePropertiesValue(config, key, value) {
|
function setGradlePropertiesValue(
|
||||||
|
config: ExpoConfig,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): ExpoConfig {
|
||||||
return withGradleProperties(config, (exportedConfig) => {
|
return withGradleProperties(config, (exportedConfig) => {
|
||||||
const props = exportedConfig.modResults;
|
const props = exportedConfig.modResults;
|
||||||
const keyIdx = props.findIndex(
|
const keyIdx = props.findIndex(
|
||||||
(item) => item.type === "property" && item.key === key,
|
(item) => item.type === "property" && item.key === key,
|
||||||
);
|
);
|
||||||
const property = {
|
const property: AndroidConfig.Properties.PropertiesItem = {
|
||||||
type: "property",
|
type: "property",
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
@@ -22,11 +31,14 @@ function setGradlePropertiesValue(config, key, value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function withCustomPlugin(config) {
|
const withCustomGradleProperties: ConfigPlugin = (config) => {
|
||||||
// Expo 52 is not setting this
|
// Expo 52 is not setting this
|
||||||
// https://github.com/expo/expo/issues/32558
|
// https://github.com/expo/expo/issues/32558
|
||||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||||
|
|
||||||
|
// NDK version required by libmpv 1.0.0
|
||||||
|
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
||||||
|
|
||||||
// Increase memory
|
// Increase memory
|
||||||
config = setGradlePropertiesValue(
|
config = setGradlePropertiesValue(
|
||||||
config,
|
config,
|
||||||
@@ -35,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
|
|||||||
);
|
);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withCustomGradleProperties;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { withXcodeProject } = require("@expo/config-plugins");
|
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
|
||||||
|
|
||||||
const withTVOSAppIcon = (config) => {
|
const withTVOSAppIcon: ConfigPlugin = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVOSAppIcon;
|
export default withTVOSAppIcon;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
const {
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
import {
|
||||||
|
type ConfigPlugin,
|
||||||
withEntitlementsPlist,
|
withEntitlementsPlist,
|
||||||
withInfoPlist,
|
withInfoPlist,
|
||||||
withXcodeProject,
|
withXcodeProject,
|
||||||
} = require("@expo/config-plugins");
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||||
@@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
|||||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||||
"StreamyfinKeychainAccessGroupIdentifier";
|
"StreamyfinKeychainAccessGroupIdentifier";
|
||||||
|
|
||||||
function getBundleIdentifier(config) {
|
interface AppExtensionConfig {
|
||||||
|
targetName: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
entitlements: {
|
||||||
|
"com.apple.security.application-groups": string[];
|
||||||
|
"keychain-access-groups": string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundleIdentifier(config: ExpoConfig): string {
|
||||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppGroupIdentifier(config) {
|
function getAppGroupIdentifier(config: ExpoConfig): string {
|
||||||
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeychainAccessGroupIdentifier(config) {
|
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
|
||||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildConfigurations(project, configurationListId) {
|
// The xcode project object has no usable typings — keep `any` here.
|
||||||
|
function getBuildConfigurations(project: any, configurationListId: string) {
|
||||||
const configurationList =
|
const configurationList =
|
||||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||||
|
|
||||||
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
|
|||||||
|
|
||||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||||
return configurationList.buildConfigurations
|
return configurationList.buildConfigurations
|
||||||
.map((config) => buildConfigurations[config.value])
|
.map((config: { value: string }) => buildConfigurations[config.value])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppGroup(value, appGroupIdentifier) {
|
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(appGroupIdentifier)
|
return groups.includes(appGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
: [...groups, appGroupIdentifier];
|
: [...groups, appGroupIdentifier];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
function ensureKeychainAccessGroup(
|
||||||
|
value: unknown,
|
||||||
|
keychainAccessGroupIdentifier: string,
|
||||||
|
): string[] {
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(keychainAccessGroupIdentifier)
|
return groups.includes(keychainAccessGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppExtension(
|
function ensureAppExtension(
|
||||||
appExtensions,
|
appExtensions: unknown,
|
||||||
targetName,
|
targetName: string,
|
||||||
bundleIdentifier,
|
bundleIdentifier: string,
|
||||||
appGroupIdentifier,
|
appGroupIdentifier: string,
|
||||||
keychainAccessGroupIdentifier,
|
keychainAccessGroupIdentifier: string,
|
||||||
) {
|
): AppExtensionConfig[] {
|
||||||
const extensionConfig = {
|
const extensionConfig: AppExtensionConfig = {
|
||||||
targetName,
|
targetName,
|
||||||
bundleIdentifier,
|
bundleIdentifier,
|
||||||
entitlements: {
|
entitlements: {
|
||||||
@@ -63,7 +78,9 @@ function ensureAppExtension(
|
|||||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
|
||||||
|
? appExtensions
|
||||||
|
: [];
|
||||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||||
const existingIndex = extensions.findIndex(
|
const existingIndex = extensions.findIndex(
|
||||||
(appExtension) => appExtension?.targetName === targetName,
|
(appExtension) => appExtension?.targetName === targetName,
|
||||||
@@ -78,7 +95,7 @@ function ensureAppExtension(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTVOSTopShelf = (config) => {
|
const withTVOSTopShelf: ConfigPlugin = (config) => {
|
||||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||||
const keychainAccessGroupIdentifier =
|
const keychainAccessGroupIdentifier =
|
||||||
getKeychainAccessGroupIdentifier(config);
|
getKeychainAccessGroupIdentifier(config);
|
||||||
@@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVOSTopShelf;
|
export default withTVOSTopShelf;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const { withEntitlementsPlist } = require("expo/config-plugins");
|
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||||
*/
|
*/
|
||||||
const withTVUserManagement = (config) => {
|
const withTVUserManagement: ConfigPlugin = (config) => {
|
||||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||||
// be present in the provisioning profile, so injecting it into mobile builds
|
// be present in the provisioning profile, so injecting it into mobile builds
|
||||||
// breaks signing ("Entitlement ... not found and could not be included in
|
// breaks signing ("Entitlement ... not found and could not be included in
|
||||||
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVUserManagement;
|
export default withTVUserManagement;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const { withDangerousMod } = require("@expo/config-plugins");
|
import { execSync } from "node:child_process";
|
||||||
const { execSync } = require("node:child_process");
|
import fs from "node:fs";
|
||||||
const fs = require("node:fs");
|
import path from "node:path";
|
||||||
const path = require("node:path");
|
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
||||||
@@ -12,7 +12,7 @@ const path = require("node:path");
|
|||||||
*
|
*
|
||||||
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
||||||
*/
|
*/
|
||||||
const withTVXcodeEnv = (config) => {
|
const withTVXcodeEnv: ConfigPlugin = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
|
|||||||
/**
|
/**
|
||||||
* Get the actual node binary path, handling nvm installations.
|
* Get the actual node binary path, handling nvm installations.
|
||||||
*/
|
*/
|
||||||
function getNodeBinaryPath() {
|
function getNodeBinaryPath(): string | null {
|
||||||
try {
|
try {
|
||||||
// First try to get node path directly (works for non-nvm installs)
|
// First try to get node path directly (works for non-nvm installs)
|
||||||
const directPath = execSync("which node 2>/dev/null", {
|
const directPath = execSync("which node 2>/dev/null", {
|
||||||
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withTVXcodeEnv;
|
export default withTVXcodeEnv;
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
|
import fs from "node:fs";
|
||||||
const path = require("node:path");
|
import path from "node:path";
|
||||||
const fs = require("node:fs");
|
import {
|
||||||
|
AndroidConfig,
|
||||||
|
type ConfigPlugin,
|
||||||
|
type ExportedConfigWithProps,
|
||||||
|
withAndroidManifest,
|
||||||
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const fsPromises = fs.promises;
|
const fsPromises = fs.promises;
|
||||||
|
|
||||||
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
||||||
|
|
||||||
const withTrustLocalCerts = (config) => {
|
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
||||||
|
|
||||||
|
const withTrustLocalCerts: ConfigPlugin = (config) => {
|
||||||
return withAndroidManifest(config, async (mod) => {
|
return withAndroidManifest(config, async (mod) => {
|
||||||
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setCustomConfigAsync(config, androidManifest) {
|
async function setCustomConfigAsync(
|
||||||
|
config: ExportedConfigWithProps<AndroidManifest>,
|
||||||
|
androidManifest: AndroidManifest,
|
||||||
|
): Promise<AndroidManifest> {
|
||||||
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
||||||
const res_file_path = path.join(
|
const res_file_path = path.join(
|
||||||
await AndroidConfig.Paths.getResourceFolderAsync(
|
await AndroidConfig.Paths.getResourceFolderAsync(
|
||||||
@@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) {
|
|||||||
return androidManifest;
|
return androidManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withTrustLocalCerts;
|
export default withTrustLocalCerts;
|
||||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
/** The recap segments for the item. */
|
|
||||||
recapSegments?: MediaTimeSegment[];
|
|
||||||
/** The commercial segments for the item. */
|
|
||||||
commercialSegments?: MediaTimeSegment[];
|
|
||||||
/** The preview segments for the item. */
|
|
||||||
previewSegments?: MediaTimeSegment[];
|
|
||||||
/** The user data for the item. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
|
||||||
recapSegments?: MediaTimeSegment[];
|
|
||||||
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
|
||||||
commercialSegments?: MediaTimeSegment[];
|
|
||||||
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
|
||||||
previewSegments?: MediaTimeSegment[];
|
|
||||||
/** The audio stream index selected for this download */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
* bun scripts/check-i18n-keys.ts # 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.ts --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.ts --unused=off # ignore unused entirely
|
||||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
* bun scripts/check-i18n-keys.ts --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 --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +34,20 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
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 ROOT = process.cwd();
|
||||||
const args = process.argv.slice(2);
|
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}=`));
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
if (!a) return def;
|
if (!a) return def;
|
||||||
const [, v] = a.split("=");
|
const [, v] = a.split("=");
|
||||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
|||||||
|
|
||||||
// ---- config ----
|
// ---- config ----
|
||||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG: I18nConfig = {
|
||||||
localesDir: "translations",
|
localesDir: "translations",
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
// 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.
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
ignoreUnused: [],
|
ignoreUnused: [],
|
||||||
};
|
};
|
||||||
const config = existsSync(CONFIG_PATH)
|
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
? {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||||
|
}
|
||||||
: DEFAULT_CONFIG;
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
const flatten = (obj, prefix = "", out = {}) => {
|
const flatten = (
|
||||||
|
obj: LocaleTree,
|
||||||
|
prefix = "",
|
||||||
|
out: Record<string, string> = {},
|
||||||
|
): Record<string, string> => {
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
else out[key] = v;
|
else out[key] = v as string;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globMatch = (key, pattern) => {
|
const globMatch = (key: string, pattern: string): boolean => {
|
||||||
if (pattern.endsWith(".*"))
|
if (pattern.endsWith(".*"))
|
||||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
return key === pattern;
|
return key === pattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (dir, files = []) => {
|
const walk = (dir: string, files: string[] = []): string[] => {
|
||||||
let entries;
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
|||||||
}
|
}
|
||||||
for (const name of entries) {
|
for (const name of entries) {
|
||||||
const full = join(dir, name);
|
const full = join(dir, name);
|
||||||
let st;
|
let st: ReturnType<typeof statSync>;
|
||||||
try {
|
try {
|
||||||
st = statSync(full);
|
st = statSync(full);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
|||||||
// ---- load source keys ----
|
// ---- load source keys ----
|
||||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
const sourceKeys = Object.keys(
|
const sourceKeys = Object.keys(
|
||||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||||
);
|
);
|
||||||
const sourceKeySet = new Set(sourceKeys);
|
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; // <Trans i18nKey="a.b">
|
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
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 usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||||
const dynamicPrefixes = new Set();
|
const dynamicPrefixes = new Set<string>();
|
||||||
const fullyDynamic = []; // { file, line }
|
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
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
|
// 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);
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
// `://` inside string URLs intact.
|
// `://` inside string URLs intact.
|
||||||
const stripComments = (src) =>
|
const stripComments = (src: string): string =>
|
||||||
src
|
src
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
|||||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
// 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).
|
// 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}'`) ||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
codeBlob.includes(`\`${key}\``);
|
codeBlob.includes(`\`${key}\``);
|
||||||
const isUsed = (key) =>
|
const isUsed = (key: string): boolean =>
|
||||||
literalUsed(key) ||
|
literalUsed(key) ||
|
||||||
prefixList.some((p) => key.startsWith(p)) ||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
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.
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
// ---- 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;
|
const [head, ...rest] = parts;
|
||||||
if (!(head in obj)) return;
|
if (!(head in obj)) return;
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
delete obj[head];
|
delete obj[head];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeKey(obj[head], rest);
|
const child = obj[head];
|
||||||
if (
|
if (!child || typeof child !== "object") return;
|
||||||
obj[head] &&
|
removeKey(child, rest);
|
||||||
typeof obj[head] === "object" &&
|
if (Object.keys(child).length === 0) delete obj[head];
|
||||||
Object.keys(obj[head]).length === 0
|
|
||||||
)
|
|
||||||
delete obj[head];
|
|
||||||
};
|
};
|
||||||
if (FIX_UNUSED && unused.length) {
|
if (FIX_UNUSED && unused.length) {
|
||||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
// 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.
|
// 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("."));
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
|||||||
);
|
);
|
||||||
for (const k of unused) console.log(` - ${k}`);
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
console.log(
|
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(
|
console.log(
|
||||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
@@ -21,8 +21,14 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
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.
|
// 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];
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw === "") return def;
|
if (raw === undefined || raw === "") return def;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
|||||||
).split(/\s+/),
|
).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 || "")
|
(s || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
|||||||
.map(stem)
|
.map(stem)
|
||||||
.filter((w) => w.length > 2);
|
.filter((w) => w.length > 2);
|
||||||
|
|
||||||
const jaccard = (a, b) => {
|
const jaccard = (a: string[], b: string[]): number => {
|
||||||
const A = new Set(a);
|
const A = new Set(a);
|
||||||
const B = new Set(b);
|
const B = new Set(b);
|
||||||
if (!A.size || !B.size) return 0;
|
if (!A.size || !B.size) return 0;
|
||||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
|||||||
|
|
||||||
const newTitle = tokens(TITLE);
|
const newTitle = tokens(TITLE);
|
||||||
const newBody = tokens(BODY);
|
const newBody = tokens(BODY);
|
||||||
const score = (o) =>
|
const score = (o: Issue): number =>
|
||||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||||
0.4 * jaccard(newBody, tokens(o.body));
|
0.4 * jaccard(newBody, tokens(o.body));
|
||||||
|
|
||||||
// fetch open issues (excluding PRs and the new issue itself)
|
// fetch open issues (excluding PRs and the new issue itself)
|
||||||
let issues;
|
let issues: Issue[];
|
||||||
if (process.env.DUP_FIXTURE) {
|
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 {
|
} else {
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"gh",
|
"gh",
|
||||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
|||||||
issues = raw
|
issues = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((l) => JSON.parse(l));
|
.map((l) => JSON.parse(l) as Issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = issues
|
const matches = issues
|
||||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
|||||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
// 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
|
// 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.
|
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||||
const safeTitle = (t) =>
|
const safeTitle = (t: string): string =>
|
||||||
(t || "")
|
(t || "")
|
||||||
.replace(/@/g, "@")
|
.replace(/@/g, "@")
|
||||||
.replace(/[`<>|*_~[\]]/g, " ")
|
.replace(/[`<>|*_~[\]]/g, " ")
|
||||||
@@ -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;
|
|
||||||
// }
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
import { execFileSync } from "node:child_process";
|
||||||
const process = require("node:process");
|
import { createRequire } from "node:module";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// Enhanced ANSI color codes and styles
|
// Enhanced ANSI color codes and styles
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
|||||||
|
|
||||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||||
|
|
||||||
function log(message, color = "") {
|
function log(message: string, color = "") {
|
||||||
if (useColor && color) {
|
if (useColor && color) {
|
||||||
console.log(`${color}${message}${colors.reset}`);
|
console.log(`${color}${message}${colors.reset}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(errorLine) {
|
function formatError(errorLine: string): string {
|
||||||
if (!useColor) return errorLine;
|
if (!useColor) return errorLine;
|
||||||
|
|
||||||
// Color file paths in cyan
|
// Color file paths in cyan
|
||||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorsAndCreateSummary(errorOutput) {
|
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||||
|
formattedErrors: string[];
|
||||||
|
errorsByFile: Map<string, number>;
|
||||||
|
} {
|
||||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||||
const errorsByFile = new Map();
|
const errorsByFile = new Map<string, number>();
|
||||||
const formattedErrors = [];
|
const formattedErrors: string[] = [];
|
||||||
|
|
||||||
let currentError = [];
|
let currentError: string[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
if (!errorsByFile.has(filePath)) {
|
if (!errorsByFile.has(filePath)) {
|
||||||
errorsByFile.set(filePath, 0);
|
errorsByFile.set(filePath, 0);
|
||||||
}
|
}
|
||||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||||
|
|
||||||
// Start new error
|
// Start new error
|
||||||
currentError.push(formatError(line));
|
currentError.push(formatError(line));
|
||||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
return { formattedErrors, errorsByFile };
|
return { formattedErrors, errorsByFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createErrorSummaryTable(errorsByFile) {
|
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||||
if (errorsByFile.size === 0) return "";
|
if (errorsByFile.size === 0) return "";
|
||||||
|
|
||||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTypeCheck() {
|
function runTypeCheck(): { ok: boolean } {
|
||||||
const extraArgs = process.argv.slice(2);
|
const extraArgs = process.argv.slice(2);
|
||||||
|
|
||||||
// Prefer local TypeScript binary when available
|
// Prefer local TypeScript binary when available
|
||||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
|||||||
"false",
|
"false",
|
||||||
...extraArgs,
|
...extraArgs,
|
||||||
];
|
];
|
||||||
let execArgs = null;
|
let execArgs: { cmd: string; args: string[] };
|
||||||
try {
|
try {
|
||||||
const tscBin = require.resolve("typescript/bin/tsc");
|
const tscBin = require.resolve("typescript/bin/tsc");
|
||||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||||
} catch {
|
} catch {
|
||||||
// fallback to PATH tsc
|
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||||
execArgs = {
|
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||||
cmd: "tsc",
|
|
||||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
|||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} 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
|
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||||
// that generates a large volume of known type errors
|
// that generates a large volume of known type errors
|
||||||
@@ -304,21 +304,6 @@
|
|||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
"auto_play_next_episode": "Auto-play next episode",
|
"auto_play_next_episode": "Auto-play next episode",
|
||||||
"max_auto_play_episode_count": "Max auto-play episode count",
|
"max_auto_play_episode_count": "Max auto-play episode count",
|
||||||
"segment_skip_settings": "Segment skip settings",
|
|
||||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
|
||||||
"skip_intro": "Skip intro",
|
|
||||||
"skip_intro_description": "Action when intro segment is detected",
|
|
||||||
"skip_outro": "Skip outro/credits",
|
|
||||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
|
||||||
"skip_recap": "Skip recap",
|
|
||||||
"skip_recap_description": "Action when recap segment is detected",
|
|
||||||
"skip_commercial": "Skip commercial",
|
|
||||||
"skip_commercial_description": "Action when commercial segment is detected",
|
|
||||||
"skip_preview": "Skip preview",
|
|
||||||
"skip_preview_description": "Action when preview segment is detected",
|
|
||||||
"segment_skip_none": "None",
|
|
||||||
"segment_skip_ask": "Show skip button",
|
|
||||||
"segment_skip_auto": "Auto skip",
|
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
@@ -644,10 +629,6 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"skip_intro": "Skip intro",
|
"skip_intro": "Skip intro",
|
||||||
"skip_credits": "Skip credits",
|
"skip_credits": "Skip credits",
|
||||||
"skip_outro": "Skip outro",
|
|
||||||
"skip_recap": "Skip recap",
|
|
||||||
"skip_commercial": "Skip commercial",
|
|
||||||
"skip_preview": "Skip preview",
|
|
||||||
"stopPlayback": "Stop playback",
|
"stopPlayback": "Stop playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -183,9 +184,6 @@ export enum TVTypographyScale {
|
|||||||
ExtraLarge = "extraLarge",
|
ExtraLarge = "extraLarge",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -249,12 +247,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -358,12 +350,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
@@ -376,11 +362,16 @@ export const defaultValues: Settings = {
|
|||||||
mpvSubtitleFontSize: undefined,
|
mpvSubtitleFontSize: undefined,
|
||||||
mpvSubtitleBackgroundEnabled: false,
|
mpvSubtitleBackgroundEnabled: false,
|
||||||
mpvSubtitleBackgroundOpacity: 75,
|
mpvSubtitleBackgroundOpacity: 75,
|
||||||
// MPV buffer/cache defaults
|
// MPV buffer/cache defaults.
|
||||||
|
// Android TV gets tighter caps — combined with libmpv 1.0's larger
|
||||||
|
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
|
||||||
|
// retention) the larger mobile budget pushes 2 GB Android TV boxes
|
||||||
|
// into swap death during 4K HDR playback. Apple TV has more RAM and
|
||||||
|
// keeps the full budget. Users can override via the settings screen.
|
||||||
mpvCacheEnabled: "auto",
|
mpvCacheEnabled: "auto",
|
||||||
mpvCacheSeconds: 10,
|
mpvCacheSeconds: 10,
|
||||||
mpvDemuxerMaxBytes: 150, // MB
|
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
|
||||||
mpvDemuxerMaxBackBytes: 50, // MB
|
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
|
||||||
// MPV video output driver defaults (Android only)
|
// MPV video output driver defaults (Android only)
|
||||||
mpvVoDriver: "gpu-next",
|
mpvVoDriver: "gpu-next",
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
|
|||||||
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type {
|
||||||
|
MediaStream,
|
||||||
|
SubtitleDeliveryMethod,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
compareTracksForMenu,
|
||||||
|
isExternalSubtitle,
|
||||||
|
type PlayerSubtitleTrack,
|
||||||
|
resolveSubtitleTrack,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
|
|
||||||
|
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
|
||||||
|
const External = "External" as SubtitleDeliveryMethod;
|
||||||
|
const Embed = "Embed" as SubtitleDeliveryMethod;
|
||||||
|
|
||||||
|
// --- fixtures --------------------------------------------------------------
|
||||||
|
|
||||||
|
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
|
||||||
|
({ Type: "Subtitle", ...o }) as MediaStream;
|
||||||
|
|
||||||
|
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||||
|
sub({
|
||||||
|
Index,
|
||||||
|
DeliveryMethod: External,
|
||||||
|
IsExternal: true,
|
||||||
|
DeliveryUrl: `/sub/${Index}.srt`,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||||
|
sub({ Index, DeliveryMethod: Embed, ...o });
|
||||||
|
|
||||||
|
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
|
||||||
|
|
||||||
|
// Mirror direct-player.tsx online URL builder.
|
||||||
|
const urlBuilder =
|
||||||
|
(base: string) =>
|
||||||
|
(s: MediaStream): string | undefined =>
|
||||||
|
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
|
||||||
|
|
||||||
|
const resolve = (
|
||||||
|
streams: MediaStream[],
|
||||||
|
index: number | undefined,
|
||||||
|
player: PlayerSubtitleTrack[],
|
||||||
|
getExpectedExternalUrl = urlBuilder("http://srv"),
|
||||||
|
) =>
|
||||||
|
resolveSubtitleTrack({
|
||||||
|
subtitleStreams: streams,
|
||||||
|
jellyfinSubtitleIndex: index,
|
||||||
|
playerTracks: player,
|
||||||
|
getExpectedExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("isExternalSubtitle", () => {
|
||||||
|
test("true for External delivery or the IsExternal flag, not a bare DeliveryUrl", () => {
|
||||||
|
expect(isExternalSubtitle(ext(0))).toBe(true);
|
||||||
|
expect(isExternalSubtitle(sub({ Index: 1, IsExternal: true }))).toBe(true);
|
||||||
|
expect(isExternalSubtitle(emb(2))).toBe(false);
|
||||||
|
// A DeliveryUrl alone (e.g. an Hls-delivered sub) is NOT a sub-added sidecar.
|
||||||
|
expect(isExternalSubtitle(sub({ Index: 3, DeliveryUrl: "/x.srt" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — disable / notFound", () => {
|
||||||
|
test("index -1 or undefined disables", () => {
|
||||||
|
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
|
||||||
|
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index not present returns notFound", () => {
|
||||||
|
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
|
||||||
|
kind: "notFound",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
|
||||||
|
// Server hides embedded subs: MediaStreams lists only the 3 externals,
|
||||||
|
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "por" }),
|
||||||
|
ext(1, { Language: "eng" }),
|
||||||
|
ext(2, { Language: "eng", Title: "SDH" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "eng", title: "CC" }),
|
||||||
|
track({ id: 2, external: false, language: "spa" }),
|
||||||
|
track({ id: 3, external: false, language: "fre" }),
|
||||||
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||||
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
|
||||||
|
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("each external maps to the right player id by filename (not 1,2,3)", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to external ordinal when filenames are unavailable", () => {
|
||||||
|
const noNames = player.map((t) =>
|
||||||
|
t.external ? { ...t, externalFilename: undefined } : t,
|
||||||
|
);
|
||||||
|
expect(resolve(streams, 1, noNames)).toEqual({
|
||||||
|
kind: "select",
|
||||||
|
trackId: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
|
||||||
|
// Jellyfin lists externals first; mpv lists embedded first then externals.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
emb(1, { Language: "spa" }),
|
||||||
|
emb(2, { Language: "fre" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "spa" }),
|
||||||
|
track({ id: 2, external: false, language: "fre" }),
|
||||||
|
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("external resolves by filename, embedded by language", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
|
||||||
|
// Middle external has no DeliveryUrl → never loaded into the player.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
|
||||||
|
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
|
||||||
|
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
|
||||||
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("loaded externals still map correctly despite the gap", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting the unloaded external returns notFound", () => {
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — embedded matching", () => {
|
||||||
|
test("unique language match wins even when player order differs (not positional)", () => {
|
||||||
|
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
|
||||||
|
// Player lists them in the OPPOSITE order — a positional map would mis-pick.
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "jpn" }),
|
||||||
|
track({ id: 2, external: false, language: "eng" }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); // eng
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); // jpn
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-language tracks with no distinguishing title fall back to ordinal among matches", () => {
|
||||||
|
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "eng" })];
|
||||||
|
// Both eng, no title → identity can't disambiguate → ordinal among matches.
|
||||||
|
const player = [
|
||||||
|
track({ id: 5, external: false, language: "eng" }),
|
||||||
|
track({ id: 6, external: false, language: "eng" }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to embedded ordinal when no language/title info", () => {
|
||||||
|
const streams = [emb(0), emb(1)];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false }),
|
||||||
|
track({ id: 2, external: false }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareTracksForMenu — jellyfin-web order", () => {
|
||||||
|
test("externals sort after embedded despite lower Index", () => {
|
||||||
|
const sorted = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
emb(7, { Language: "fra" }),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forced then default float to the top within a group", () => {
|
||||||
|
const sorted = [
|
||||||
|
emb(2, { Language: "eng" }),
|
||||||
|
emb(1, { Language: "eng", IsDefault: true }),
|
||||||
|
emb(0, { Language: "eng", IsForced: true }),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full Okiku order: embedded first, externals last by Index", () => {
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
ext(1, { Language: "eng" }),
|
||||||
|
ext(2, { Language: "fra" }),
|
||||||
|
ext(3, { Language: "fra" }),
|
||||||
|
emb(7, { Language: "fra", Title: "French" }),
|
||||||
|
];
|
||||||
|
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
|
||||||
|
[7, 0, 1, 2, 3],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,91 +1,287 @@
|
|||||||
/**
|
/**
|
||||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
||||||
|
* the *player's real track list* by identity — never by positional counting.
|
||||||
*
|
*
|
||||||
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
|
||||||
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
* embedded-from-container first and externals (`sub-add`) last; and a library that
|
||||||
|
* hides embedded subs drops them from MediaStreams while the player still demuxes
|
||||||
|
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
|
||||||
|
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
|
||||||
*
|
*
|
||||||
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||||
* and NOT available in MPV's track list.
|
* and absent from the player's track list.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
type MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
type MediaStream,
|
MediaStream,
|
||||||
SubtitleDeliveryMethod,
|
SubtitleDeliveryMethod,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
|
||||||
|
// literal so this util needs no *runtime* import of the SDK barrel — which pulls in
|
||||||
|
// the axios-dependent `/api` modules and breaks unit tests under `bun test`.
|
||||||
|
const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod;
|
||||||
|
|
||||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||||
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||||
sub.IsTextSubtitleStream === false;
|
sub.IsTextSubtitleStream === false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a subtitle will be available in MPV's track list.
|
* A Jellyfin subtitle stream is "external" when the server delivers it as a
|
||||||
|
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
|
||||||
|
* flag before a device-specific delivery method is assigned).
|
||||||
*
|
*
|
||||||
* A subtitle is in MPV if:
|
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
|
||||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
|
||||||
|
* it must resolve through the embedded path. Keeping this in lockstep with the
|
||||||
|
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
|
||||||
|
* menu comparator below avoids a sub being sorted as embedded yet resolved as
|
||||||
|
* external (→ `notFound`).
|
||||||
*/
|
*/
|
||||||
export const isSubtitleInMpv = (
|
export const isExternalSubtitle = (sub: MediaStream): boolean =>
|
||||||
sub: MediaStream,
|
sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true;
|
||||||
isTranscoding: boolean,
|
|
||||||
): boolean => {
|
|
||||||
// During transcoding, image-based subs are burned in, not in MPV
|
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
/**
|
||||||
return (
|
* Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
* `itemHelper.sortTracks`: in-container tracks first then external, and within
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
* each group forced first, then default, then `Index` ascending. Callers prepend
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
* their own "None/Off" entry separately.
|
||||||
);
|
*
|
||||||
|
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
|
||||||
|
* `MediaStreams` (low indices), so raw Index order shows externals first — this
|
||||||
|
* comparator flips that to match web (externals last). Uses {@link isExternalSubtitle}
|
||||||
|
* (not the raw `IsExternal` flag) so ordering and resolution agree.
|
||||||
|
*/
|
||||||
|
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||||
|
Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) ||
|
||||||
|
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||||
|
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||||
|
(a.Index ?? 0) - (b.Index ?? 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of a subtitle track as reported by the *player's real track list*
|
||||||
|
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
|
||||||
|
* the same resolver can drive the mpv player today and the Chromecast backend later.
|
||||||
|
*/
|
||||||
|
export type PlayerSubtitleTrack = {
|
||||||
|
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
|
||||||
|
id: number;
|
||||||
|
/** True if loaded from a separate file (mpv `external`). */
|
||||||
|
external?: boolean;
|
||||||
|
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||||
|
externalFilename?: string;
|
||||||
|
language?: string;
|
||||||
|
title?: string;
|
||||||
|
codec?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubtitleSelection =
|
||||||
|
| { kind: "select"; trackId: number }
|
||||||
|
| { kind: "disable" }
|
||||||
|
| { kind: "notFound" };
|
||||||
|
|
||||||
|
/** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */
|
||||||
|
const normalizeUrl = (url: string): string => {
|
||||||
|
let u = url;
|
||||||
|
try {
|
||||||
|
u = decodeURIComponent(u);
|
||||||
|
} catch {
|
||||||
|
// not decodable — compare raw
|
||||||
|
}
|
||||||
|
return u.replace(/^file:\/\//, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalFilenameMatches = (
|
||||||
|
trackFilename: string | undefined,
|
||||||
|
expectedUrl: string | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!trackFilename || !expectedUrl) return false;
|
||||||
|
const a = normalizeUrl(trackFilename);
|
||||||
|
const b = normalizeUrl(expectedUrl);
|
||||||
|
return a === b || a.endsWith(b) || b.endsWith(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const eq = (a?: string | null, b?: string | null): boolean =>
|
||||||
|
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
|
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
|
||||||
|
const embeddedIdentityMatches = (
|
||||||
|
track: PlayerSubtitleTrack,
|
||||||
|
stream: MediaStream,
|
||||||
|
): boolean => {
|
||||||
|
if (eq(track.language, stream.Language)) {
|
||||||
|
// When both carry a title it must agree; otherwise language alone is enough.
|
||||||
|
if (track.title && stream.Title) return eq(track.title, stream.Title);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// No language on one side — fall back to a title match.
|
||||||
|
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
* Resolve the player track id for a given Jellyfin subtitle index by matching
|
||||||
|
* against the player's REAL track list (identity), never by positional counting.
|
||||||
*
|
*
|
||||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
|
||||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
* while the player enumerates embedded-from-container first and externals
|
||||||
* the one matching the Jellyfin index.
|
* (`sub-add`) last; and when a library hides embedded subs they vanish from
|
||||||
|
* `MediaStreams` but still physically exist in the file the player demuxes.
|
||||||
|
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
|
||||||
|
* English — issues #954/#1690/#618/#1467/#976/#1451).
|
||||||
*
|
*
|
||||||
* @param mediaSource - The media source containing subtitle streams
|
* Strategy:
|
||||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
* - disabled (-1/undefined) → `disable`
|
||||||
* @param isTranscoding - Whether the stream is being transcoded
|
* - external Jellyfin sub → match the player track by `externalFilename`
|
||||||
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
* (exact identity, immune to hidden-embedded shifts); fall back to the
|
||||||
|
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
|
||||||
|
* - embedded Jellyfin sub → match by language/title among non-external tracks;
|
||||||
|
* fall back to the embedded ordinal (container order aligns on both sides).
|
||||||
|
*
|
||||||
|
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
|
||||||
|
* player and (later) the Chromecast backend share one source of truth.
|
||||||
*/
|
*/
|
||||||
export const getMpvSubtitleId = (
|
export const resolveSubtitleTrack = (params: {
|
||||||
mediaSource: MediaSourceInfo | null | undefined,
|
subtitleStreams: MediaStream[] | undefined;
|
||||||
jellyfinSubtitleIndex: number | undefined,
|
jellyfinSubtitleIndex: number | undefined;
|
||||||
isTranscoding: boolean,
|
playerTracks: PlayerSubtitleTrack[];
|
||||||
): number | undefined => {
|
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
|
||||||
// -1 or undefined means disabled
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||||
|
}): SubtitleSelection => {
|
||||||
|
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
|
||||||
|
params;
|
||||||
|
const subtitleStreams = params.subtitleStreams ?? [];
|
||||||
|
|
||||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||||
return -1;
|
return { kind: "disable" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubs =
|
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
if (!target) return { kind: "notFound" };
|
||||||
|
|
||||||
// Find the subtitle with the matching Jellyfin index
|
if (isExternalSubtitle(target)) {
|
||||||
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
const playerExternals = playerTracks.filter((t) => t.external === true);
|
||||||
|
|
||||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
// 1) Exact identity by external filename — robust against hidden-embedded offset.
|
||||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
const expectedUrl = getExpectedExternalUrl?.(target);
|
||||||
return undefined;
|
const byName = playerExternals.find((t) =>
|
||||||
|
externalFilenameMatches(t.externalFilename, expectedUrl),
|
||||||
|
);
|
||||||
|
if (byName) return { kind: "select", trackId: byName.id };
|
||||||
|
|
||||||
|
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
|
||||||
|
// *loadable* externals (those actually added to the player) stays in lockstep
|
||||||
|
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
|
||||||
|
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
|
||||||
|
const loadableExternals = getExpectedExternalUrl
|
||||||
|
? externalStreams.filter((s) => getExpectedExternalUrl(s))
|
||||||
|
: externalStreams;
|
||||||
|
const ordinal = loadableExternals.findIndex(
|
||||||
|
(s) => s.Index === jellyfinSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (ordinal >= 0 && ordinal < playerExternals.length) {
|
||||||
|
return { kind: "select", trackId: playerExternals[ordinal].id };
|
||||||
|
}
|
||||||
|
return { kind: "notFound" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count MPV track position (1-based)
|
// Embedded / in-container subtitle.
|
||||||
let mpvIndex = 0;
|
const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s));
|
||||||
for (const sub of allSubs) {
|
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
|
||||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
|
||||||
mpvIndex++;
|
// 1) Identity by language/title (unique match wins).
|
||||||
if (sub.Index === jellyfinSubtitleIndex) {
|
const identityMatches = playerEmbedded.filter((t) =>
|
||||||
return mpvIndex;
|
embeddedIdentityMatches(t, target),
|
||||||
}
|
);
|
||||||
}
|
if (identityMatches.length === 1) {
|
||||||
|
return { kind: "select", trackId: identityMatches[0].id };
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
// 2) Fallback: embedded order is container order on both sides → ordinal.
|
||||||
|
const ordinal = embeddedStreams.findIndex(
|
||||||
|
(s) => s.Index === jellyfinSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (identityMatches.length > 1 && ordinal >= 0) {
|
||||||
|
// Multiple same-language tracks: pick by position among the matches.
|
||||||
|
const idx = Math.min(ordinal, identityMatches.length - 1);
|
||||||
|
return { kind: "select", trackId: identityMatches[idx].id };
|
||||||
|
}
|
||||||
|
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
|
||||||
|
return { kind: "select", trackId: playerEmbedded[ordinal].id };
|
||||||
|
}
|
||||||
|
return { kind: "notFound" };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subtitle track as reported by a concrete player's track-list API
|
||||||
|
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
|
||||||
|
*/
|
||||||
|
export type PlayerSubtitleTrackRaw = {
|
||||||
|
id: number;
|
||||||
|
lang?: string;
|
||||||
|
title?: string;
|
||||||
|
codec?: string;
|
||||||
|
external?: boolean;
|
||||||
|
externalFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal player surface needed to select a subtitle. Satisfied structurally by
|
||||||
|
* the mpv player ref and (later) implementable by the Chromecast backend.
|
||||||
|
*/
|
||||||
|
export interface SubtitleSelectablePlayer {
|
||||||
|
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
|
||||||
|
setSubtitleTrack: (trackId: number) => unknown;
|
||||||
|
disableSubtitles: () => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the player's real track list, resolve the Jellyfin subtitle index by
|
||||||
|
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
|
||||||
|
* for both the mobile controls and the player screen, so selection stays
|
||||||
|
* consistent everywhere. Returns the resolution for callers that want to react.
|
||||||
|
*/
|
||||||
|
export const applyMpvSubtitleSelection = async (
|
||||||
|
player: SubtitleSelectablePlayer | null | undefined,
|
||||||
|
params: {
|
||||||
|
subtitleStreams: MediaStream[] | undefined;
|
||||||
|
jellyfinSubtitleIndex: number;
|
||||||
|
/** Build the exact URL/path an external sub was loaded into the player with. */
|
||||||
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||||
|
},
|
||||||
|
): Promise<SubtitleSelection> => {
|
||||||
|
if (!player) return { kind: "notFound" };
|
||||||
|
|
||||||
|
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
|
||||||
|
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
|
||||||
|
// swallowed here instead of escaping as an unhandled promise rejection.
|
||||||
|
try {
|
||||||
|
const tracks = (await player.getSubtitleTracks()) ?? [];
|
||||||
|
const selection = resolveSubtitleTrack({
|
||||||
|
subtitleStreams: params.subtitleStreams,
|
||||||
|
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
|
||||||
|
playerTracks: tracks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
external: t.external,
|
||||||
|
externalFilename: t.externalFilename,
|
||||||
|
language: t.lang,
|
||||||
|
title: t.title,
|
||||||
|
codec: t.codec,
|
||||||
|
})),
|
||||||
|
getExpectedExternalUrl: params.getExpectedExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selection.kind === "select") {
|
||||||
|
await player.setSubtitleTrack(selection.trackId);
|
||||||
|
} else if (selection.kind === "disable") {
|
||||||
|
await player.disableSubtitles();
|
||||||
|
}
|
||||||
|
// notFound → leave current selection (e.g. image subs burned in while transcoding)
|
||||||
|
return selection;
|
||||||
|
} catch {
|
||||||
|
return { kind: "notFound" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
import { generateDeviceProfile } from "./native";
|
import type {
|
||||||
|
DeviceProfile,
|
||||||
/**
|
SubtitleProfile,
|
||||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
*/
|
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download-specific subtitle profiles.
|
* Download-specific subtitle profiles.
|
||||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||||
*/
|
*/
|
||||||
const downloadSubtitleProfiles = [
|
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||||
// Official formats
|
// Official formats
|
||||||
{ Format: "vtt", Method: "Encode" },
|
{ Format: "vtt", Method: "Encode" },
|
||||||
{ Format: "webvtt", Method: "Encode" },
|
{ Format: "webvtt", Method: "Encode" },
|
||||||
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
|
|||||||
/**
|
/**
|
||||||
* Generates a device profile optimized for downloads.
|
* Generates a device profile optimized for downloads.
|
||||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
* 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
|
// Get the base profile with proper audio codec configuration
|
||||||
const baseProfile = generateDeviceProfile({ audioMode });
|
const baseProfile = generateDeviceProfile({ audioMode });
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* 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 { Platform } from "react-native";
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: getSubtitleProfiles(),
|
SubtitleProfiles: getSubtitleProfiles(),
|
||||||
};
|
} satisfies DeviceProfile;
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* 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)
|
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||||
// because MPV cannot load them externally over HTTP
|
// because MPV cannot load them externally over HTTP
|
||||||
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
|
|||||||
"pgssub",
|
"pgssub",
|
||||||
"teletext",
|
"teletext",
|
||||||
"vobsub",
|
"vobsub",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
// Text-based formats - these can be loaded externally by MPV
|
// Text-based formats - these can be loaded externally by MPV
|
||||||
const TEXT_BASED_FORMATS = [
|
const TEXT_BASED_FORMATS = [
|
||||||
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
|
|||||||
"text",
|
"text",
|
||||||
"vplayer",
|
"vplayer",
|
||||||
"xsub",
|
"xsub",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const getSubtitleProfiles = () => {
|
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||||
const profiles = [];
|
const profiles: SubtitleProfile[] = [];
|
||||||
|
|
||||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||||
for (const format of IMAGE_BASED_FORMATS) {
|
for (const format of IMAGE_BASED_FORMATS) {
|
||||||
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Export for use in player filtering
|
// Export for use in player filtering
|
||||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -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;
|
|
||||||
@@ -3,23 +3,25 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* 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 { Platform } from "react-native";
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
|
import type { PlatformType } from "./native";
|
||||||
|
|
||||||
/**
|
export interface TrackPlayerProfileOptions {
|
||||||
* @typedef {"ios" | "android"} PlatformType
|
/** Target platform */
|
||||||
*
|
platform?: PlatformType;
|
||||||
* @typedef {Object} TrackPlayerProfileOptions
|
}
|
||||||
* @property {PlatformType} [platform] - Target platform
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio direct play profiles for react-native-track-player.
|
* Audio direct play profiles for react-native-track-player.
|
||||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
* 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") {
|
if (platform === "ios") {
|
||||||
// iOS AVPlayer supported formats
|
// iOS AVPlayer supported formats
|
||||||
return {
|
return {
|
||||||
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio codec profiles for react-native-track-player.
|
* Audio codec profiles for react-native-track-player.
|
||||||
*
|
|
||||||
* @param {PlatformType} platform
|
|
||||||
*/
|
*/
|
||||||
const getCodecProfile = (platform) => {
|
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||||
if (platform === "ios") {
|
if (platform === "ios") {
|
||||||
// iOS AVPlayer codec constraints
|
// iOS AVPlayer codec constraints
|
||||||
return {
|
return {
|
||||||
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
|
|||||||
* This profile is specifically for standalone audio playback using:
|
* This profile is specifically for standalone audio playback using:
|
||||||
* - AVPlayer on iOS
|
* - AVPlayer on iOS
|
||||||
* - ExoPlayer on Android
|
* - ExoPlayer on Android
|
||||||
*
|
|
||||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
|
||||||
* @returns {Object} Jellyfin device profile for track player
|
|
||||||
*/
|
*/
|
||||||
export const generateTrackPlayerProfile = (options = {}) => {
|
export const generateTrackPlayerProfile = (
|
||||||
const platform = options.platform || Platform.OS;
|
options: TrackPlayerProfileOptions = {},
|
||||||
|
): DeviceProfile => {
|
||||||
|
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Name: "Track Player",
|
Name: "Track Player",
|
||||||
@@ -1,40 +1,46 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { MediaSegmentType } from "@jellyfin/sdk/lib/generated-client/models/media-segment-type";
|
|
||||||
import { getMediaSegmentsApi } from "@jellyfin/sdk/lib/utils/api/media-segments-api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||||
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||||
|
|
||||||
export interface SegmentBuckets {
|
// New Jellyfin 10.11+ Media Segments API types
|
||||||
introSegments: MediaTimeSegment[];
|
interface MediaSegmentDto {
|
||||||
creditSegments: MediaTimeSegment[];
|
Id: string;
|
||||||
recapSegments: MediaTimeSegment[];
|
ItemId: string;
|
||||||
commercialSegments: MediaTimeSegment[];
|
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||||
previewSegments: MediaTimeSegment[];
|
StartTicks: number;
|
||||||
|
EndTicks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
|
interface MediaSegmentsResponse {
|
||||||
|
Items: MediaSegmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy API types (for fallback)
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
IntroStart: number;
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
IntroEnd: number;
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
Valid: boolean;
|
Valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Credits: { Start: number; End: number; Valid: boolean };
|
Introduction: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
Credits: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10_000_000;
|
const TICKS_PER_SECOND = 10000000;
|
||||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
|
||||||
|
|
||||||
const emptyBuckets = (): SegmentBuckets => ({
|
|
||||||
introSegments: [],
|
|
||||||
creditSegments: [],
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useSegments = (
|
export const useSegments = (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@@ -42,6 +48,7 @@ export const useSegments = (
|
|||||||
downloadedFiles: DownloadedItem[] | undefined,
|
downloadedFiles: DownloadedItem[] | undefined,
|
||||||
api: Api | null,
|
api: Api | null,
|
||||||
) => {
|
) => {
|
||||||
|
// Memoize the lookup so the array is only traversed when dependencies change
|
||||||
const downloadedItem = React.useMemo(
|
const downloadedItem = React.useMemo(
|
||||||
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
() => downloadedFiles?.find((d) => d.item.Id === itemId),
|
||||||
[downloadedFiles, itemId],
|
[downloadedFiles, itemId],
|
||||||
@@ -58,76 +65,92 @@ export const useSegments = (
|
|||||||
}
|
}
|
||||||
return fetchAndParseSegments(itemId, api);
|
return fetchAndParseSegments(itemId, api);
|
||||||
},
|
},
|
||||||
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
|
enabled: isOffline ? !!downloadedItem : !!api,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
|
export const getSegmentsForItem = (
|
||||||
|
item: DownloadedItem,
|
||||||
|
): {
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
} => {
|
||||||
|
return {
|
||||||
introSegments: item.introSegments || [],
|
introSegments: item.introSegments || [],
|
||||||
creditSegments: item.creditSegments || [],
|
creditSegments: item.creditSegments || [],
|
||||||
recapSegments: item.recapSegments || [],
|
};
|
||||||
commercialSegments: item.commercialSegments || [],
|
};
|
||||||
previewSegments: item.previewSegments || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
|
/**
|
||||||
|
* Converts Jellyfin ticks to seconds
|
||||||
|
*/
|
||||||
|
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||||
|
*/
|
||||||
const fetchMediaSegments = async (
|
const fetchMediaSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets | null> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await getMediaSegmentsApi(api).getItemSegments({
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
itemId,
|
`${api.basePath}/MediaSegments/${itemId}`,
|
||||||
includeSegmentTypes: [
|
{
|
||||||
MediaSegmentType.Intro,
|
headers: getAuthHeaders(api),
|
||||||
MediaSegmentType.Outro,
|
params: {
|
||||||
MediaSegmentType.Recap,
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
MediaSegmentType.Commercial,
|
},
|
||||||
MediaSegmentType.Preview,
|
},
|
||||||
],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const buckets = emptyBuckets();
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
for (const segment of response.data.Items ?? []) {
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
if (segment.StartTicks == null || segment.EndTicks == null) continue;
|
|
||||||
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
startTime: ticksToSeconds(segment.StartTicks),
|
startTime: ticksToSeconds(segment.StartTicks),
|
||||||
endTime: ticksToSeconds(segment.EndTicks),
|
endTime: ticksToSeconds(segment.EndTicks),
|
||||||
text: segment.Type ?? "",
|
text: segment.Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (segment.Type) {
|
switch (segment.Type) {
|
||||||
case MediaSegmentType.Intro:
|
case "Intro":
|
||||||
buckets.introSegments.push(timeSegment);
|
introSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Outro:
|
case "Outro":
|
||||||
buckets.creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case MediaSegmentType.Recap:
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
buckets.recapSegments.push(timeSegment);
|
default:
|
||||||
break;
|
|
||||||
case MediaSegmentType.Commercial:
|
|
||||||
buckets.commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case MediaSegmentType.Preview:
|
|
||||||
buckets.previewSegments.push(timeSegment);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return buckets;
|
return { introSegments, creditSegments };
|
||||||
} catch {
|
} catch (_error) {
|
||||||
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
|
/**
|
||||||
|
* Fetches segments using legacy pre-10.11 endpoints
|
||||||
|
*/
|
||||||
const fetchLegacySegments = async (
|
const fetchLegacySegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
const buckets = emptyBuckets();
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
const [introRes, creditRes] = await Promise.allSettled([
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
api.axiosInstance.get<IntroTimestamps>(
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
@@ -140,28 +163,43 @@ const fetchLegacySegments = async (
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||||
buckets.introSegments.push({
|
introSegments.push({
|
||||||
startTime: introRes.value.data.IntroStart,
|
startTime: introRes.value.data.IntroStart,
|
||||||
endTime: introRes.value.data.IntroEnd,
|
endTime: introRes.value.data.IntroEnd,
|
||||||
text: "Intro",
|
text: "Intro",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
if (
|
||||||
buckets.creditSegments.push({
|
creditRes.status === "fulfilled" &&
|
||||||
|
creditRes.value.data.Credits.Valid
|
||||||
|
) {
|
||||||
|
creditSegments.push({
|
||||||
startTime: creditRes.value.data.Credits.Start,
|
startTime: creditRes.value.data.Credits.Start,
|
||||||
endTime: creditRes.value.data.Credits.End,
|
endTime: creditRes.value.data.Credits.End,
|
||||||
text: "Outro",
|
text: "Credits",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch legacy segments", error);
|
||||||
|
}
|
||||||
|
|
||||||
return buckets;
|
return { introSegments, creditSegments };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
itemId: string,
|
itemId: string,
|
||||||
api: Api,
|
api: Api,
|
||||||
): Promise<SegmentBuckets> => {
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
return newSegments ?? fetchLegacySegments(itemId, api);
|
if (newSegments) {
|
||||||
|
return newSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy endpoints
|
||||||
|
return fetchLegacySegments(itemId, api);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Constants from "expo-constants";
|
|||||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||||
|
|
||||||
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
|
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
|
||||||
export interface BuildMeta {
|
export interface BuildMeta {
|
||||||
commit?: string | null;
|
commit?: string | null;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user