mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 18:24:18 +01:00
Compare commits
83 Commits
renovate/m
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54f9321c9 | ||
|
|
470e69774d | ||
|
|
8a0d43fc98 | ||
|
|
2e9c30580e | ||
|
|
6dd98ccae8 | ||
|
|
b4eb853048 | ||
|
|
76d9b3177b | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
bd0eef0407 | ||
|
|
95b5dab293 | ||
|
|
2df160adb7 | ||
|
|
8d54d65b63 | ||
|
|
95f4d15e98 | ||
|
|
77156630d6 | ||
|
|
680f930a47 | ||
|
|
ed8744389b | ||
|
|
b879026745 | ||
|
|
28a75a2b8c | ||
|
|
aa0eb0a655 | ||
|
|
286a3cad47 | ||
|
|
97b6a912e0 | ||
|
|
cc0007926d | ||
|
|
9e29305e28 | ||
|
|
ae9c05637b | ||
|
|
f820bedf6e | ||
|
|
47c5d61f28 | ||
|
|
517bc7bbb5 | ||
|
|
b256e99fc8 | ||
|
|
e660b98871 | ||
|
|
ce66f0256e | ||
|
|
2ec6594462 | ||
|
|
18f01fa4ab | ||
|
|
df56d62acd | ||
|
|
872d14786e | ||
|
|
5bf07b4798 | ||
|
|
c3dceedad0 | ||
|
|
bf3dc4a366 | ||
|
|
3e81291843 | ||
|
|
7703a1c76f | ||
|
|
2755f340c4 | ||
|
|
63ed386369 | ||
|
|
a38737df2e | ||
|
|
ce1e1dfc77 | ||
|
|
719c875293 | ||
|
|
d6d2cf6aca | ||
|
|
4389b56bd0 | ||
|
|
294568eef3 | ||
|
|
0ee5488308 | ||
|
|
c0908ad44d | ||
|
|
b5b29d434c | ||
|
|
626c0a98a8 | ||
|
|
2592cbdc48 | ||
|
|
b34afb0715 | ||
|
|
fd667c127f | ||
|
|
0e5c3215f3 | ||
|
|
16187aab90 | ||
|
|
2acdf1eea6 | ||
|
|
976af60185 | ||
|
|
6b90d3e868 | ||
|
|
de81b36725 | ||
|
|
b83b5b0bbb | ||
|
|
ec92e98b16 | ||
|
|
9c0de94247 | ||
|
|
0a58514964 | ||
|
|
58f8015e3b | ||
|
|
a27ea154ba | ||
|
|
6c3fa704db | ||
|
|
fe315699b9 | ||
|
|
c3271859b8 | ||
|
|
294b3f19c3 | ||
|
|
e9bb6b3c40 | ||
|
|
9d437e8cd1 | ||
|
|
ebf6e31478 | ||
|
|
378288bf08 | ||
|
|
92460cf202 | ||
|
|
feb5a41cff | ||
|
|
97607b2263 | ||
|
|
d3bc2ac5d5 | ||
|
|
96f6ad000b | ||
|
|
be575b7c04 | ||
|
|
91de36c3bd | ||
|
|
62f50590d4 |
21
.github/actions/refresh-pr-comment/action.yml
vendored
Normal file
21
.github/actions/refresh-pr-comment/action.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Refresh PR build comment
|
||||
description: >-
|
||||
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
|
||||
comment reflects live per-platform progress as each build job finishes.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
|
||||
# artifact-comment's concurrency group collapses simultaneous nudges, so
|
||||
# this can't spam the comment. Skipped on forks (their read-only token
|
||||
# cannot dispatch). github.token is used because composite actions cannot
|
||||
# read the secrets context.
|
||||
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"
|
||||
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
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
132
.github/workflows/artifact-comment.yml
vendored
132
.github/workflows/artifact-comment.yml
vendored
@@ -144,7 +144,7 @@ jobs:
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
|
||||
|
||||
// Log current status of each build for debugging
|
||||
buildRuns.forEach(run => {
|
||||
@@ -184,21 +184,35 @@ jobs:
|
||||
const latestAndroidRun = findBestRun('Android APK Build');
|
||||
const latestIOSRun = findBestRun('iOS IPA Build');
|
||||
|
||||
// Map our build targets to their job display names. Exact name is
|
||||
// tried first so a signed target never collides with its
|
||||
// "(Unsigned)" sibling (whose name contains the signed name).
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)'],
|
||||
'iOS': ['🍎 Build iOS IPA (Phone)'],
|
||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
|
||||
'tvOS': ['🍎 Build tvOS IPA'],
|
||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
|
||||
};
|
||||
|
||||
// Prefer an exact name match over a substring match so
|
||||
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
|
||||
const findJobForTarget = (jobs, jobNames) =>
|
||||
jobs.find(j => jobNames.some(name => j.name === name)) ||
|
||||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
|
||||
|
||||
// Format a millisecond duration as "Xm Ys".
|
||||
const fmtDuration = (ms) => {
|
||||
const min = Math.floor(ms / 60000);
|
||||
const sec = Math.floor((ms % 60000) / 1000);
|
||||
return `${min}m ${sec}s`;
|
||||
};
|
||||
|
||||
// For the consolidated workflow, get individual job statuses
|
||||
if (latestAppsRun) {
|
||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||
|
||||
// Map job names to our build targets. Declared outside the try so
|
||||
// the catch fallback can reuse the same keys.
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
||||
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all jobs for this workflow run
|
||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
@@ -229,10 +243,8 @@ jobs:
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||
);
|
||||
|
||||
const job = findJobForTarget(jobs.jobs, jobNames);
|
||||
|
||||
if (job) {
|
||||
buildStatuses[platform] = {
|
||||
name: job.name,
|
||||
@@ -358,6 +370,43 @@ jobs:
|
||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
||||
});
|
||||
|
||||
// Pull per-job durations from the latest successful develop build so
|
||||
// in-progress / queued targets can show a realistic ETA instead of
|
||||
// an open-ended spinner. Best-effort: any failure just drops the ETA.
|
||||
let referenceDurations = {};
|
||||
try {
|
||||
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'build-apps.yml',
|
||||
branch: 'develop',
|
||||
status: 'success',
|
||||
per_page: 1
|
||||
});
|
||||
|
||||
if (devRuns.workflow_runs.length > 0) {
|
||||
const refRun = devRuns.workflow_runs[0];
|
||||
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: refRun.id
|
||||
});
|
||||
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = findJobForTarget(refJobs.jobs, jobNames);
|
||||
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
|
||||
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
|
||||
}
|
||||
}
|
||||
console.log(`Reference durations from develop run ${refRun.id}:`,
|
||||
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
|
||||
} else {
|
||||
console.log('No successful develop build found for ETA reference');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch develop reference durations:', error.message);
|
||||
}
|
||||
|
||||
// Build comment body with progressive status for individual builds
|
||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
||||
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
||||
@@ -369,9 +418,9 @@ jobs:
|
||||
const buildTargets = [
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
|
||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
|
||||
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||
];
|
||||
|
||||
@@ -407,11 +456,9 @@ jobs:
|
||||
let durationInfo = '';
|
||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
||||
}
|
||||
|
||||
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
@@ -421,10 +468,16 @@ jobs:
|
||||
downloadLink = '*Build cancelled*';
|
||||
} else if (matchingStatus.status === 'in_progress') {
|
||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||
downloadLink = '*Build in progress...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
|
||||
: '*Build in progress...*';
|
||||
} else if (matchingStatus.status === 'queued') {
|
||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||
downloadLink = '*Waiting to start...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
|
||||
: '*Waiting to start...*';
|
||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||
// Workflow completed but conclusion not yet available (rare edge case)
|
||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||
@@ -445,26 +498,27 @@ jobs:
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
// Show installation instructions if we have any artifacts
|
||||
// Static rundown of the build optimisations + what each artifact
|
||||
// installs on. Always shown (even mid-build) so testers know what
|
||||
// to expect before downloads are ready.
|
||||
commentBody += `<details>\n`;
|
||||
commentBody += `<summary>📦 Build details & device compatibility</summary>\n\n`;
|
||||
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
|
||||
commentBody += `| Artifact | Architectures | Installs on |\n`;
|
||||
commentBody += `|---|---|---|\n`;
|
||||
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
|
||||
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
|
||||
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
|
||||
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
|
||||
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
|
||||
commentBody += `</details>\n\n`;
|
||||
|
||||
// Installation instructions only matter once something is downloadable.
|
||||
if (allArtifacts.length > 0) {
|
||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
|
||||
commentBody += `> ⚠️ **Note**: Artifacts expire in 7 days from build date\n\n`;
|
||||
|
||||
// Collapsible rundown of the build optimisations + what each
|
||||
// artifact actually installs on, so testers grab the right file.
|
||||
commentBody += `<details>\n`;
|
||||
commentBody += `<summary>📦 Build details & device compatibility</summary>\n\n`;
|
||||
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
|
||||
commentBody += `| Artifact | Architectures | Installs on |\n`;
|
||||
commentBody += `|---|---|---|\n`;
|
||||
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
|
||||
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
|
||||
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
|
||||
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
|
||||
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
|
||||
commentBody += `</details>\n\n`;
|
||||
} else {
|
||||
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
|
||||
}
|
||||
|
||||
411
.github/workflows/build-apps.yml
vendored
411
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||
# run (artifacts + logs) without needing Expo access.
|
||||
@@ -27,71 +27,74 @@ jobs:
|
||||
name: 🤖 Build Android APK (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -117,77 +120,83 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-android-tv:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-26.04
|
||||
name: 🤖 Build Android APK (TV)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -212,30 +221,48 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -250,19 +277,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -280,30 +294,41 @@ jobs:
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone-unsigned:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -318,12 +343,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -339,6 +358,9 @@ jobs:
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-tv:
|
||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
||||
@@ -349,24 +371,39 @@ jobs:
|
||||
name: 🍎 Build tvOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -381,19 +418,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
@@ -418,24 +442,32 @@ jobs:
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -450,12 +482,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
@@ -470,3 +496,6 @@ jobs:
|
||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
26
.github/workflows/check-lockfile.yml
vendored
26
.github/workflows/check-lockfile.yml
vendored
@@ -18,22 +18,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
2
.github/workflows/ci-codeql.yml
vendored
2
.github/workflows/ci-codeql.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
17
.github/workflows/detect-duplicate.yml
vendored
17
.github/workflows/detect-duplicate.yml
vendored
@@ -20,17 +20,18 @@ jobs:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
100
.github/workflows/linting.yml
vendored
100
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,39 +11,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -51,7 +23,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -67,18 +39,26 @@ jobs:
|
||||
name: 🚑 Expo Doctor Check
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -103,24 +83,26 @@ jobs:
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
# renovate: datasource=node-version depName=node versioning=node
|
||||
node-version: "24.16.0"
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: 📝 PR Title Comment
|
||||
|
||||
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
|
||||
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
|
||||
# read-only token on forks). workflow_run runs from the base branch with the base
|
||||
# repo's token. This is comment-only — the required check stays pr-title.yml.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["📝 PR Title"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # download the artifact from the triggering run
|
||||
pull-requests: write # post/update/delete the sticky comment
|
||||
|
||||
concurrency:
|
||||
group: pr-title-comment-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
# Only run when the lint run actually produced a result artifact: a run
|
||||
# cancelled by concurrency (rapid pushes to the same PR) completes with
|
||||
# conclusion "cancelled" before uploading it, and would fail the download.
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: ⬇️ Download lint result
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh run download "${{ github.event.workflow_run.id }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--name pr-title-result \
|
||||
--dir result
|
||||
|
||||
- name: 🔎 Read result
|
||||
id: result
|
||||
run: |
|
||||
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
|
||||
if [ -s result/error ]; then
|
||||
echo "has_error=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_error=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 📥 Load error message
|
||||
if: steps.result.outputs.has_error == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
|
||||
cat result/error
|
||||
echo
|
||||
echo "PR_TITLE_ERR_EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 💬 Post title error
|
||||
if: steps.result.outputs.has_error == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
number: ${{ steps.result.outputs.number }}
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ env.LINT_ERROR }}
|
||||
```
|
||||
|
||||
- name: 🧹 Clear title error
|
||||
if: steps.result.outputs.has_error == 'false'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
number: ${{ steps.result.outputs.number }}
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
42
.github/workflows/pr-title.yml
vendored
Normal file
42
.github/workflows/pr-title.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: 📝 PR Title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
|
||||
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
|
||||
# off the result (PR number + lint error) as an artifact for it to read.
|
||||
- if: always()
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
|
||||
run: |
|
||||
mkdir -p pr-title-result
|
||||
printf '%s' "$PR_NUMBER" > pr-title-result/number
|
||||
printf '%s' "$LINT_ERROR" > pr-title-result/error
|
||||
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr-title-result
|
||||
path: pr-title-result/
|
||||
retention-days: 1
|
||||
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
@@ -13,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Queue successive releases in order instead of dropping the extra pending run.
|
||||
queue: max
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -63,21 +65,29 @@ jobs:
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -89,13 +99,6 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
# tvOS uses credentialsSource: local — restore the gitignored
|
||||
# credentials.json + cert + provisioning profiles from secrets.
|
||||
- name: 🔐 Restore tvOS signing credentials
|
||||
@@ -184,7 +187,7 @@ jobs:
|
||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
show-progress: false
|
||||
|
||||
2
.github/workflows/trivy-scan.yml
vendored
2
.github/workflows/trivy-scan.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
security-events: write # upload SARIF to code scanning
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
# Trivy's own action caches the vulnerability DB + binary internally
|
||||
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
||||
|
||||
25
.github/workflows/update-issue-form.yml
vendored
25
.github/workflows/update-issue-form.yml
vendored
@@ -25,19 +25,20 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔢 Populate version dropdown from GitHub releases
|
||||
id: populate
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
||||
|
||||
## 🛣️ 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
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
||||
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
||||
import "tsx/cjs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||
|
||||
// Build metadata, injected into `extra.build` and read at runtime via
|
||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||
const git = (args) => {
|
||||
const git = (args: string[]): string | null => {
|
||||
try {
|
||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
@@ -42,16 +46,16 @@ const buildMeta = {
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
module.exports = ({ config }) => {
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
config.plugins?.push("expo-background-task");
|
||||
|
||||
config.plugins.push([
|
||||
config.plugins?.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
config.plugins.push([
|
||||
config.plugins?.push([
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission:
|
||||
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
const androidConfig = {};
|
||||
const androidConfig: { googleServicesFile?: string } = {};
|
||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
};
|
||||
} as ExpoConfig;
|
||||
};
|
||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
||||
],
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
"./plugins/withTVUserManagement.js",
|
||||
"./plugins/withExcludeMedia3Dash.ts",
|
||||
"./plugins/withTVUserManagement.ts",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -134,17 +134,17 @@
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidAlertColors.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVOSTopShelf.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
["./plugins/with-runtime-framework-headers.ts"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||
["./plugins/withAndroidAlertColors.ts"],
|
||||
["./plugins/withAndroidManifest.ts"],
|
||||
["./plugins/withTrustLocalCerts.ts"],
|
||||
["./plugins/withGradleProperties.ts"],
|
||||
["./plugins/withTVOSAppIcon.ts"],
|
||||
["./plugins/withTVOSTopShelf.ts"],
|
||||
["./plugins/withTVXcodeEnv.ts"],
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
"./plugins/withGitPod.ts",
|
||||
{
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
233
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
233
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Factory function to create skip options for a specific segment type
|
||||
* Reduces code duplication across all 5 segment types
|
||||
*/
|
||||
const useSkipOptions = (
|
||||
settingKey:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview",
|
||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||
t: TFunction<"translation", undefined>,
|
||||
) => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
selected: option.value === settings?.[settingKey],
|
||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||
);
|
||||
};
|
||||
|
||||
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 skipIntroOptions = useSkipOptions(
|
||||
"skipIntro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipOutroOptions = useSkipOptions(
|
||||
"skipOutro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipRecapOptions = useSkipOptions(
|
||||
"skipRecap",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipCommercialOptions = useSkipOptions(
|
||||
"skipCommercial",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipPreviewOptions = useSkipOptions(
|
||||
"skipPreview",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={false} className='px-4'>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
subtitle={t("home.settings.other.skip_intro_description")}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
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_${settings.skipIntro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
subtitle={t("home.settings.other.skip_outro_description")}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
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_${settings.skipOutro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
subtitle={t("home.settings.other.skip_recap_description")}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
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_${settings.skipRecap}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
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_${settings.skipCommercial}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
subtitle={t("home.settings.other.skip_preview_description")}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
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_${settings.skipPreview}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{
|
||||
label: string;
|
||||
value: "none" | "ask" | "auto";
|
||||
}> => [
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import {
|
||||
useFocusEffect,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -80,8 +85,9 @@ const Page = () => {
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
filterBy?: string;
|
||||
fromSeeAll?: string;
|
||||
};
|
||||
const { libraryId } = searchParams;
|
||||
const { libraryId, fromSeeAll } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
@@ -112,6 +118,22 @@ const Page = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
// When this library detail was opened from the home "See All" button, its
|
||||
// libraries stack is just [detail], so the default TV Back would exit to home.
|
||||
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
||||
// route to the library list instead, so the user can switch libraries. Normal
|
||||
// entries from the list keep their native pop-to-list behavior.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!Platform.isTV || fromSeeAll !== "true") return;
|
||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
router.replace("/(auth)/(tabs)/(libraries)");
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [fromSeeAll, router]),
|
||||
);
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
|
||||
// TV Filter queries
|
||||
@@ -269,6 +291,23 @@ const Page = () => {
|
||||
});
|
||||
}, [library]);
|
||||
|
||||
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
||||
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
||||
// which the native bottom tab reliably auto-pops back to the index (the detail
|
||||
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
||||
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
||||
const didCollapseRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
||||
return;
|
||||
const state = navigation.getState();
|
||||
if (state?.routes && state.routes.length > 1) {
|
||||
didCollapseRef.current = true;
|
||||
const top = state.routes[state.routes.length - 1];
|
||||
navigation.reset({ index: 0, routes: [top] } as any);
|
||||
}
|
||||
}, [navigation, fromSeeAll]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
|
||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
||||
},
|
||||
hideWhenScrolling: 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
|
||||
hintTextColor: "#fff",
|
||||
headerIconColor: "#fff",
|
||||
|
||||
@@ -3,16 +3,24 @@ import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
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 { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
isTabRoute,
|
||||
useTVHomeBackHandler,
|
||||
useTVTabRootBackHandler,
|
||||
} from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(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() {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle TV back button - prevent app exit when at root
|
||||
// Must be called before any conditional return (rules of hooks)
|
||||
useTVHomeBackHandler();
|
||||
|
||||
if (IS_ANDROID_TV) {
|
||||
return <TVTabLayout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroPlayback } from "@/hooks/useIntroPlayback";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||
@@ -60,8 +62,8 @@ import {
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToMs, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function DirectPlayerPage() {
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
@@ -99,6 +101,8 @@ export default function DirectPlayerPage() {
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
// Track whether we've already triggered completion for the current intro
|
||||
const introCompletionTriggered = useSharedValue(false);
|
||||
const VolumeManager = Platform.isTV
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
@@ -170,6 +174,14 @@ export default function DirectPlayerPage() {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Intro playback hook - manages intro video playback before main content
|
||||
const { intros, currentIntro, isPlayingIntro, skipAllIntros } =
|
||||
useIntroPlayback({
|
||||
api,
|
||||
itemId: item?.Id || null,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
// Playback manager for progress reporting and adjacent items
|
||||
const playbackManager = usePlaybackManager({ item, isOffline: offline });
|
||||
const { nextItem, previousItem } = playbackManager;
|
||||
@@ -307,9 +319,43 @@ export default function DirectPlayerPage() {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Intro stream state - separate from main content stream
|
||||
const [introStream, setIntroStream] = useState<Stream | null>(null);
|
||||
|
||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||
|
||||
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
|
||||
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
|
||||
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
|
||||
// handles a clean stop, but a channel switch (the player re-fetches in place)
|
||||
// or an unmount after an error bypass it, so track the open live stream and
|
||||
// release it on those paths too.
|
||||
const apiRef = useRef(api);
|
||||
useEffect(() => {
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
const releaseLiveStream = useCallback(
|
||||
(liveStreamId: string | null) => {
|
||||
if (!liveStreamId || !apiRef.current || offline) return;
|
||||
// Best effort: a failed close must not break teardown, and the slot is
|
||||
// also freed by the server's reap as a backstop.
|
||||
getMediaInfoApi(apiRef.current)
|
||||
.closeLiveStream({ liveStreamId })
|
||||
.catch(() => {});
|
||||
},
|
||||
[offline],
|
||||
);
|
||||
|
||||
// The effect cleanup releases the live stream both when it changes (channel
|
||||
// switch, which re-runs the effect) and when the player unmounts, so no
|
||||
// manual previous-id tracking is needed.
|
||||
useEffect(() => {
|
||||
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
|
||||
return () => releaseLiveStream(liveStreamId);
|
||||
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
@@ -402,6 +448,57 @@ export default function DirectPlayerPage() {
|
||||
offline,
|
||||
]);
|
||||
|
||||
// Fetch intro stream when current intro changes
|
||||
useEffect(() => {
|
||||
const fetchIntroStreamData = async () => {
|
||||
// Don't fetch intro stream if offline or no current intro
|
||||
if (offline || !currentIntro?.Id || !api || !user?.Id) {
|
||||
setIntroStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item: currentIntro,
|
||||
startTimeTicks: 0, // Always start from beginning for intros
|
||||
userId: user.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: undefined,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
console.error("Failed to get intro stream URL");
|
||||
return;
|
||||
}
|
||||
setIntroStream({ mediaSource, sessionId, url });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch intro stream:", error);
|
||||
}
|
||||
};
|
||||
fetchIntroStreamData();
|
||||
}, [
|
||||
currentIntro,
|
||||
api,
|
||||
user?.Id,
|
||||
audioIndex,
|
||||
bitrateValue,
|
||||
subtitleIndex,
|
||||
offline,
|
||||
]);
|
||||
|
||||
// Reset intro completion flag when a new intro starts playing
|
||||
useEffect(() => {
|
||||
if (isPlayingIntro) {
|
||||
introCompletionTriggered.value = false;
|
||||
}
|
||||
}, [isPlayingIntro, currentIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !api || offline) return;
|
||||
const reportPlaybackStart = async () => {
|
||||
@@ -445,6 +542,12 @@ export default function DirectPlayerPage() {
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
// Release the server-side live stream (and its tuner slot) on stop.
|
||||
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
|
||||
// the stop report carries its LiveStreamId; without it the stream leaks
|
||||
// and Live TV eventually fails for everyone with "M3U simultaneous
|
||||
// stream limit has been reached". Undefined for non-live items (no-op).
|
||||
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
|
||||
},
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
@@ -456,10 +559,23 @@ export default function DirectPlayerPage() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
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();
|
||||
// Resume inactivity timer when leaving player (TV only)
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -555,6 +671,21 @@ export default function DirectPlayerPage() {
|
||||
lastUrlUpdateTime.value = now;
|
||||
}
|
||||
|
||||
// Handle intro completion - check if intro has reached its end
|
||||
if (isPlayingIntro && currentIntro) {
|
||||
const introDuration = ticksToMs(currentIntro.RunTimeTicks || 0);
|
||||
// Check if we're near the end of the intro (within 1000ms buffer)
|
||||
// Use a larger buffer to ensure reliable detection even with short intros
|
||||
// or if MPV doesn't fire progress callbacks frequently
|
||||
if (currentTime >= introDuration - 1000) {
|
||||
// Only trigger once per intro to avoid multiple calls
|
||||
if (!introCompletionTriggered.value) {
|
||||
introCompletionTriggered.value = true;
|
||||
skipAllIntros();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
playbackManager.reportPlaybackProgress(
|
||||
@@ -571,6 +702,9 @@ export default function DirectPlayerPage() {
|
||||
isSeeking,
|
||||
isPlaybackStopped,
|
||||
isBuffering,
|
||||
isPlayingIntro,
|
||||
currentIntro,
|
||||
skipAllIntros,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -601,9 +735,11 @@ export default function DirectPlayerPage() {
|
||||
|
||||
/** Build video source config for MPV */
|
||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
// Use intro stream if playing intro, otherwise use main content stream
|
||||
const activeStream = isPlayingIntro ? introStream : stream;
|
||||
if (!activeStream?.url) return undefined;
|
||||
|
||||
const mediaSource = stream.mediaSource;
|
||||
const mediaSource = activeStream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
@@ -639,14 +775,17 @@ export default function DirectPlayerPage() {
|
||||
);
|
||||
|
||||
// Calculate start position directly here to avoid timing issues
|
||||
const startTicks = playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
// For intros, always start from 0
|
||||
const startTicks = isPlayingIntro
|
||||
? 0
|
||||
: playbackPositionFromUrl
|
||||
? Number.parseInt(playbackPositionFromUrl, 10)
|
||||
: (item?.UserData?.PlaybackPositionTicks ?? 0);
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// Build source config - headers only needed for online streaming
|
||||
const source: MpvVideoSource = {
|
||||
url: stream.url,
|
||||
url: activeStream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
@@ -692,6 +831,8 @@ export default function DirectPlayerPage() {
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
introStream?.url,
|
||||
introStream?.mediaSource,
|
||||
stream?.requiredHttpHeaders,
|
||||
item?.UserData?.PlaybackPositionTicks,
|
||||
playbackPositionFromUrl,
|
||||
@@ -700,6 +841,7 @@ export default function DirectPlayerPage() {
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
isPlayingIntro,
|
||||
settings.mpvCacheEnabled,
|
||||
settings.mpvCacheSeconds,
|
||||
settings.mpvDemuxerMaxBytes,
|
||||
@@ -880,6 +1022,27 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
// A transcoded stream only carries the audio track the server encoded
|
||||
// into it — switching requires re-negotiating the stream with the new
|
||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||
if (isTranscoding) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
subtitleIndex: String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
||||
// devices. Resume is preserved via the playbackPosition param.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -891,7 +1054,14 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
);
|
||||
|
||||
// TV subtitle track change handler
|
||||
@@ -1105,6 +1275,15 @@ export default function DirectPlayerPage() {
|
||||
nextItem.UserData?.PlaybackPositionTicks?.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);
|
||||
}, [
|
||||
nextItem,
|
||||
@@ -1115,6 +1294,7 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue,
|
||||
router,
|
||||
isPlaybackStopped,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
@@ -1267,7 +1447,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
@@ -1356,6 +1536,9 @@ export default function DirectPlayerPage() {
|
||||
getTechnicalInfo={getTechnicalInfo}
|
||||
playMethod={playMethod}
|
||||
transcodeReasons={transcodeReasons}
|
||||
isPlayingIntro={isPlayingIntro}
|
||||
skipAllIntros={skipAllIntros}
|
||||
intros={intros}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -75,6 +76,20 @@ export default function TVOptionModal() {
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (value: any) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// onSelect navigates (the transcode audio switch replacing the player);
|
||||
// a router.replace fired while this modal is the active route would be
|
||||
// swallowed. Close FIRST, apply after dismissal.
|
||||
const onSelect = modalState.onSelect;
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
||||
return;
|
||||
}
|
||||
// State-only callers (detail page, library filters, settings): run before
|
||||
// closing so the re-render happens while the modal is up. Deferring it until
|
||||
// after dismissal re-renders the page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
modalState?.onSelect(value);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Image } from "expo-image";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
@@ -84,7 +85,8 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -100,6 +102,22 @@ SplashScreen.setOptions({
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -333,9 +351,12 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
// Log only the title — serializing the whole notification touches
|
||||
// the deprecated dataString getter (deprecation warning) and dumps
|
||||
// noisy payloads into the console.
|
||||
console.log(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
streamType?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TrackSheet: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
streamType,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,119 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (Platform.isTV) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
const isAggregateType = (item: BaseItemDto) =>
|
||||
item.Type === "Series" || item.Type === "BoxSet";
|
||||
|
||||
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
||||
const tvBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: scaleSize(8),
|
||||
right: scaleSize(8),
|
||||
height: scaleSize(28),
|
||||
borderRadius: scaleSize(14),
|
||||
backgroundColor: "rgba(255,255,255,0.92)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// Mobile uses raw dp — no scaling.
|
||||
const mobileBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#9333ea",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||
* watched items, or items with no unplayed count, so it is safe to mount
|
||||
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||
* the watched checkmark is drawn natively and only the count needs RN).
|
||||
*/
|
||||
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
if (!isAggregateType(item)) return null;
|
||||
if (item.UserData?.Played) return null;
|
||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||
if (unplayed <= 0) return null;
|
||||
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
style={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||
const isAggregate = isAggregateType(item);
|
||||
const isPlayed = item.UserData?.Played === true;
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Fully watched → white checkmark badge (top-right)
|
||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
}
|
||||
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
return (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
|
||||
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||
{isAggregate && isPlayed && (
|
||||
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||
<Ionicons name='checkmark' size={13} color='white' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||
<UnplayedCountBadge item={item} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
||||
let isCancelled = false;
|
||||
|
||||
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 {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
const handleSeeAllPress = useCallback(() => {
|
||||
if (!parentId) return;
|
||||
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
||||
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
||||
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
||||
// (b) intercept Back to route to the library list so the user can switch
|
||||
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||
pathname: "/[libraryId]",
|
||||
params: {
|
||||
libraryId: parentId,
|
||||
sortBy: SortByOption.DateCreated,
|
||||
sortOrder: SortOrderOption.Descending,
|
||||
fromSeeAll: "true",
|
||||
},
|
||||
} as any);
|
||||
}, [router, parentId]);
|
||||
@@ -326,9 +332,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
initialNumToRender={4}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={false}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
style={{ overflow: "visible" }}
|
||||
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||
ListFooterComponent={
|
||||
// No fixed width: the footer must size to the "See All" card so the
|
||||
// FlatList's scrollable content extends to fully reveal it. A fixed
|
||||
// (narrow) width clipped the card at the right edge. Trailing space is
|
||||
// provided by contentContainerStyle.paddingRight.
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
|
||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
// For episodes, always use series thumb
|
||||
// For episodes, always use series thumb.
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
@@ -256,8 +257,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
let isCancelled = false;
|
||||
|
||||
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 {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
@@ -379,7 +383,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch the image before starting the crossfade
|
||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useState } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { WatchedIndicator } from "../WatchedIndicator";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -251,6 +253,15 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</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>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -136,9 +139,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
if (orientation === "horizontal") {
|
||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||
if (item.Type === "Episode") {
|
||||
// First try parent/series thumb (horizontal series artwork)
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// First try parent/series thumb (horizontal series artwork).
|
||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
// Fall back to episode's own primary image
|
||||
if (item.ImageTags?.Primary) {
|
||||
@@ -427,6 +431,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{/*
|
||||
The glass view draws the watched checkmark natively but cannot show
|
||||
an unplayed-episode count, so render it as an RN overlay on top.
|
||||
Returns null when not applicable (non-series / fully watched).
|
||||
*/}
|
||||
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -448,8 +458,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
<Image
|
||||
placeholder={{ blurhash }}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{ uri: imageUrl }}
|
||||
recyclingKey={item.Id}
|
||||
cachePolicy='memory-disk'
|
||||
contentFit='cover'
|
||||
style={{
|
||||
@@ -459,7 +469,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
<WatchedIndicator item={item} />
|
||||
{showWatchedIndicator && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
||||
export { TVLanguageCard } from "./TVLanguageCard";
|
||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||
export { TVNavBar } from "./TVNavBar";
|
||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||
|
||||
@@ -35,7 +35,9 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
@@ -75,6 +77,11 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Intro playback props
|
||||
isPlayingIntro?: boolean;
|
||||
skipAllIntros?: () => void;
|
||||
intros?: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -87,7 +94,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
@@ -108,6 +117,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
isPlayingIntro = false,
|
||||
skipAllIntros,
|
||||
intros = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
@@ -180,10 +192,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{/* Skip Intro button - shows when playing intro videos */}
|
||||
{isPlayingIntro && intros.length > 0 && skipAllIntros && (
|
||||
<SkipButton
|
||||
showButton={true}
|
||||
onPress={skipAllIntros}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
buttonText={skipButtonText}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -193,7 +213,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
buttonText={skipCreditButtonText}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -16,17 +24,17 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -43,6 +51,9 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -73,6 +84,10 @@ interface Props {
|
||||
getTechnicalInfo?: () => Promise<TechnicalInfo>;
|
||||
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
|
||||
transcodeReasons?: string[];
|
||||
// Intro playback props
|
||||
isPlayingIntro?: boolean;
|
||||
skipAllIntros?: () => void;
|
||||
intros?: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
@@ -102,6 +117,9 @@ export const Controls: FC<Props> = ({
|
||||
getTechnicalInfo,
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
isPlayingIntro = false,
|
||||
skipAllIntros,
|
||||
intros = [],
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
@@ -111,6 +129,18 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -316,27 +346,122 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Wrapper to convert segment skip from seconds to milliseconds
|
||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
play();
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Determine which segment button to show (priority order)
|
||||
// Commercial > Recap > Intro > Preview > Outro
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return { type: "Commercial", ...commercialSkipper };
|
||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||
if (previewSkipper.currentSegment)
|
||||
return { type: "Preview", ...previewSkipper };
|
||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
recapSkipper.currentSegment,
|
||||
introSkipper.currentSegment,
|
||||
previewSkipper.currentSegment,
|
||||
outroSkipper.currentSegment,
|
||||
commercialSkipper,
|
||||
recapSkipper,
|
||||
introSkipper,
|
||||
previewSkipper,
|
||||
outroSkipper,
|
||||
]);
|
||||
|
||||
// Legacy compatibility: map to old variable names
|
||||
const showSkipButton = !!(
|
||||
activeSegment &&
|
||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
// Get button text based on segment type using i18n
|
||||
const { t } = useTranslation();
|
||||
const skipButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
: t("player.skip_intro");
|
||||
const skipCreditButtonText = t("player.skip_outro");
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -571,7 +696,9 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
@@ -592,6 +719,9 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
isPlayingIntro={isPlayingIntro}
|
||||
skipAllIntros={skipAllIntros}
|
||||
intros={intros}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
@@ -564,6 +564,9 @@ export const Controls: FC<Props> = ({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
// In-player audio selection navigates (replacePlayer while transcoding);
|
||||
// apply it after the modal is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVLanguageCard,
|
||||
TVSubtitleResultCard,
|
||||
TVTabButton,
|
||||
TVTrackCard,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log(
|
||||
"[TVSubtitleSheet] visible:",
|
||||
visible,
|
||||
"tracks:",
|
||||
subtitleTracks.length,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchResults,
|
||||
search,
|
||||
downloadAsync,
|
||||
reset,
|
||||
} = useRemoteSubtitles({
|
||||
itemId: item.Id ?? "",
|
||||
item,
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
search({ language: code });
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadAsync(result);
|
||||
|
||||
if (downloadResult.type === "server") {
|
||||
onServerSubtitleDownloaded?.();
|
||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to download subtitle:", error);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
downloadAsync,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</Text>
|
||||
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.language") || "Language"}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
<TVLanguageCard
|
||||
key={lang.code}
|
||||
code={lang.code}
|
||||
name={lang.name}
|
||||
selected={selectedLanguage === lang.code}
|
||||
hasTVPreferredFocus={
|
||||
index === 0 &&
|
||||
(!searchResults || searchResults.length === 0)
|
||||
}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.results") || "Results"}
|
||||
{searchResults && ` (${searchResults.length})`}
|
||||
</Text>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={styles.loadingText}>
|
||||
{t("player.searching") || "Searching..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{searchError && !isSearching && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name='alert-circle-outline'
|
||||
size={32}
|
||||
color='rgba(255,100,100,0.8)'
|
||||
/>
|
||||
<Text style={styles.errorText}>
|
||||
{t("player.search_failed") || "Search failed"}
|
||||
</Text>
|
||||
<Text style={styles.errorHint}>
|
||||
{!hasOpenSubtitlesApiKey
|
||||
? t("player.no_subtitle_provider") ||
|
||||
"No subtitle provider configured on server"
|
||||
: String(searchError)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searchResults &&
|
||||
searchResults.length === 0 &&
|
||||
!isSearching &&
|
||||
!searchError && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name='document-text-outline'
|
||||
size={32}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{t("player.no_subtitles_found") ||
|
||||
"No subtitles found"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{searchResults &&
|
||||
searchResults.length > 0 &&
|
||||
!isSearching && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TVSubtitleResultCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isDownloading={downloadingId === result.id}
|
||||
onPress={() => handleDownload(result)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
name='information-circle-outline'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.apiKeyHintText}>
|
||||
{t("player.add_opensubtitles_key_hint") ||
|
||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={onClose}
|
||||
label={t("common.cancel") || "Cancel"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 10,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "rgba(255,100,100,0.9)",
|
||||
marginTop: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorHint: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
apiKeyHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 8,
|
||||
},
|
||||
apiKeyHintText: {
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
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>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</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 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
export default {
|
||||
const MediaTypes = {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
||||
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||
|
||||
46
hooks/useIntroPlayback.ts
Normal file
46
hooks/useIntroPlayback.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getIntros } from "@/utils/intros";
|
||||
|
||||
interface UseIntroPlaybackProps {
|
||||
api: Api | null;
|
||||
itemId: string | null;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function useIntroPlayback({
|
||||
api,
|
||||
itemId,
|
||||
userId,
|
||||
}: UseIntroPlaybackProps) {
|
||||
const [intros, setIntros] = useState<BaseItemDto[]>([]);
|
||||
const [isPlayingIntro, setIsPlayingIntro] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchIntros() {
|
||||
if (!api || !itemId) return;
|
||||
|
||||
const introItems = await getIntros(api, itemId, userId);
|
||||
setIntros(introItems);
|
||||
// Set isPlayingIntro to true when intros are available
|
||||
setIsPlayingIntro(introItems.length > 0);
|
||||
}
|
||||
|
||||
fetchIntros();
|
||||
}, [api, itemId, userId]);
|
||||
|
||||
// Only play the first intro if intros are available.. might be nice to configure this at some point with tags or something 🤷♂️
|
||||
const currentIntro = intros.length > 0 ? intros[0] : null;
|
||||
|
||||
const skipAllIntros = () => {
|
||||
setIsPlayingIntro(false);
|
||||
};
|
||||
|
||||
return {
|
||||
intros,
|
||||
currentIntro,
|
||||
isPlayingIntro,
|
||||
skipAllIntros,
|
||||
};
|
||||
}
|
||||
105
hooks/useSegmentSkipper.ts
Normal file
105
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
|
||||
interface UseSegmentSkipperProps {
|
||||
segments: MediaTimeSegment[];
|
||||
segmentType: SegmentType;
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
segmentType,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef(false);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
switch (segmentType) {
|
||||
case "Intro":
|
||||
return settings.skipIntro;
|
||||
case "Outro":
|
||||
return settings.skipOutro;
|
||||
case "Recap":
|
||||
return settings.skipRecap;
|
||||
case "Commercial":
|
||||
return settings.skipCommercial;
|
||||
case "Preview":
|
||||
return settings.skipPreview;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
})();
|
||||
|
||||
// Find current segment
|
||||
const currentSegment =
|
||||
segments.find(
|
||||
(segment) =>
|
||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||
) || null;
|
||||
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment) return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (segmentType === "Outro" && totalDuration) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
seek(currentSegment.endTime);
|
||||
}
|
||||
|
||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||
if (notifyOrUseHaptics) {
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSegment && !autoSkipTriggeredRef.current) {
|
||||
autoSkipTriggeredRef.current = true;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = false;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
// Return null segment if skip mode is 'none'
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
};
|
||||
};
|
||||
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
|
||||
import {
|
||||
disableTVMenuKeyInterception,
|
||||
enableTVMenuKeyInterception,
|
||||
useTVBackPress,
|
||||
} 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
|
||||
*/
|
||||
function isAtTabRoot(segments: string[]): boolean {
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const tabNames = [
|
||||
"(home)",
|
||||
"(search)",
|
||||
"(favorites)",
|
||||
"(libraries)",
|
||||
"(watchlists)",
|
||||
"(settings)",
|
||||
"(custom-links)",
|
||||
];
|
||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
||||
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tab name from segments
|
||||
*/
|
||||
function getCurrentTab(segments: string[]): string | undefined {
|
||||
return segments.find(
|
||||
(s) =>
|
||||
s === "(home)" ||
|
||||
s === "(search)" ||
|
||||
s === "(favorites)" ||
|
||||
s === "(libraries)" ||
|
||||
s === "(watchlists)" ||
|
||||
s === "(settings)" ||
|
||||
s === "(custom-links)",
|
||||
);
|
||||
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||
return segments.find(isTabRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
|
||||
export function useTVHomeBackHandler() {
|
||||
const segments = useSegments();
|
||||
|
||||
// Get current state
|
||||
const currentTab = getCurrentTab(segments);
|
||||
const atTabRoot = isAtTabRoot(segments);
|
||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
|
||||
enableTVMenuKeyInterception();
|
||||
}, [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]);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ShowOptionsParams<T> {
|
||||
onSelect: (value: T) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVOptionModal = () => {
|
||||
@@ -26,6 +27,7 @@ export const useTVOptionModal = () => {
|
||||
onSelect: params.onSelect,
|
||||
cardWidth: params.cardWidth,
|
||||
cardHeight: params.cardHeight,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-option-modal");
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -27,6 +28,7 @@ class DownloadService : Service() {
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
private var isForegroundStarted = false
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
@@ -36,6 +38,12 @@ class DownloadService : Service() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
|
||||
wakeLock?.acquire()
|
||||
|
||||
Log.d(TAG, "Wake lock acquired")
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
@@ -93,6 +101,8 @@ class DownloadService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
Log.d(TAG, "Wake lock released")
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -53,5 +53,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
// 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,13 +3,14 @@ package expo.modules.mpvplayer
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* MPV renderer that wraps libmpv for video playback.
|
||||
@@ -35,6 +36,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
|
||||
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
|
||||
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
|
||||
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
|
||||
* → ANR. We force software decoding here.
|
||||
*
|
||||
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
|
||||
* false positive would needlessly drop shipping hardware to software decoding. The
|
||||
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
|
||||
* emulator build fingerprint, none of which appear on real devices.
|
||||
*/
|
||||
private fun isEmulator(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase()
|
||||
if (hardware == "goldfish" || hardware == "ranchu") return true
|
||||
|
||||
val product = Build.PRODUCT
|
||||
if (product == "sdk" || product.startsWith("sdk_")) return true
|
||||
|
||||
val fingerprint = Build.FINGERPRINT
|
||||
return fingerprint.startsWith("generic") ||
|
||||
fingerprint.contains("emulator", ignoreCase = true)
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||
fun onPauseChanged(isPaused: Boolean)
|
||||
@@ -51,8 +76,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
private var surface: Surface? = null
|
||||
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
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
@@ -112,100 +144,108 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
|
||||
try {
|
||||
MPVLib.create(context)
|
||||
MPVLib.addObserver(this)
|
||||
|
||||
/**
|
||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||
*
|
||||
* Technical Background:
|
||||
* ====================
|
||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
// Per-instance handle — see class-level comment. Each player gets
|
||||
// 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
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||
// This needs to be named `subfont.ttf` else it won't work
|
||||
arrayOf("subfont.ttf").forEach { fileName ->
|
||||
val file = File(mpvDir, fileName)
|
||||
if (file.exists()) return@forEach
|
||||
context.assets
|
||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||
.copyTo(FileOutputStream(file))
|
||||
|
||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||
// persists its font index across runs instead of re-walking
|
||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||
// holds onto. Without this we see "No usable fontconfig
|
||||
// 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)
|
||||
this.voDriver = voDriver
|
||||
MPVLib.setOptionString("vo", voDriver)
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
mpv?.setOptionString("vo", voDriver)
|
||||
mpv?.setOptionString("gpu-context", "android")
|
||||
mpv?.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Hardware video decoding
|
||||
// TV: zero-copy (mediacodec) for better performance on low-power devices
|
||||
// Mobile: copy mode (mediacodec-copy) for better compatibility
|
||||
val isTV = isTvDevice()
|
||||
if (isTV) {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec")
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
} else {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||
// Hardware decoder codecs (shared)
|
||||
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).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTV -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
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 -> 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
|
||||
// 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
|
||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||
|
||||
// Subtitle settings
|
||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||
MPVLib.setOptionString("sub-use-margins", "no")
|
||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||
MPVLib.setOptionString("subs-fallback", "yes")
|
||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||
mpv?.setOptionString("sub-use-margins", "no")
|
||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||
mpv?.setOptionString("subs-fallback", "yes")
|
||||
|
||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
MPVLib.setOptionString("keep-open", "always")
|
||||
|
||||
MPVLib.initialize()
|
||||
|
||||
mpv?.setOptionString("force-window", "no")
|
||||
mpv?.setOptionString("keep-open", "always")
|
||||
|
||||
mpv.initialize()
|
||||
|
||||
// Observe properties
|
||||
observeProperties()
|
||||
|
||||
@@ -218,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (isStopping) return
|
||||
if (!isRunning) return
|
||||
|
||||
isStopping = true
|
||||
isRunning = false
|
||||
|
||||
try {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||
}
|
||||
|
||||
isStopping = false
|
||||
|
||||
val m = mpv
|
||||
mpv = null
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
MPVLib.attachSurface(surface)
|
||||
MPVLib.setOptionString("force-window", "yes")
|
||||
mpv?.attachSurface(surface)
|
||||
mpv?.setOptionString("force-window", "yes")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -270,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
MPVLib.detachSurface()
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
mpv?.detachSurface()
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
@@ -282,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
*/
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
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}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
@@ -298,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
MPVLib.command(arrayOf("frame-step"))
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,29 +397,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
startPosition: Double? = null,
|
||||
externalSubtitles: List<String>? = null,
|
||||
initialSubtitleId: Int? = null,
|
||||
initialAudioId: Int? = null
|
||||
initialAudioId: Int? = null,
|
||||
cacheEnabled: String? = null,
|
||||
cacheSeconds: Int? = null,
|
||||
demuxerMaxBytes: Int? = null,
|
||||
demuxerMaxBackBytes: Int? = null
|
||||
) {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
|
||||
_isLoading = true
|
||||
isReadyToSeek = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||
|
||||
|
||||
// Stop previous playback
|
||||
MPVLib.command(arrayOf("stop"))
|
||||
|
||||
mpv?.command(arrayOf("stop"))
|
||||
|
||||
// Set HTTP headers if provided
|
||||
updateHttpHeaders(headers)
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||
} else {
|
||||
MPVLib.setPropertyString("start", "0")
|
||||
mpv?.setPropertyString("start", "0")
|
||||
}
|
||||
|
||||
// Set initial audio track if specified
|
||||
@@ -352,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
// Load the file
|
||||
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||
}
|
||||
|
||||
fun reloadCurrentItem() {
|
||||
@@ -368,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
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() {
|
||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
// Video dimensions for PiP aspect ratio
|
||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
mpv?.setPropertyBoolean("pause", false)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
MPVLib.setPropertyBoolean("pause", true)
|
||||
mpv?.setPropertyBoolean("pause", true)
|
||||
}
|
||||
|
||||
fun togglePause() {
|
||||
@@ -400,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun seekTo(seconds: Double) {
|
||||
val clamped = maxOf(0.0, seconds)
|
||||
cachedPosition = clamped
|
||||
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
}
|
||||
|
||||
fun seekBy(seconds: Double) {
|
||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
_playbackSpeed = speed
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
mpv?.setPropertyDouble("speed", speed)
|
||||
}
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
@@ -423,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getSubtitleTracks(): List<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) {
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: 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)
|
||||
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -447,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||
if (trackId < 0) {
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
mpv?.setPropertyInt("sid", trackId)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return MPVLib.getPropertyInt("sid") ?: 0
|
||||
return mpv?.getPropertyInt("sid") ?: 0
|
||||
}
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
MPVLib.setPropertyInt("sub-pos", position)
|
||||
mpv?.setPropertyInt("sub-pos", position)
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||
mpv?.setPropertyDouble("sub-scale", scale)
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||
mpv?.setPropertyString("sub-align-x", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||
mpv?.setPropertyString("sub-align-y", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
MPVLib.setPropertyInt("sub-font-size", size)
|
||||
mpv?.setPropertyInt("sub-font-size", size)
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
MPVLib.setPropertyString("sub-border-style", style)
|
||||
mpv?.setPropertyString("sub-border-style", style)
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String) {
|
||||
MPVLib.setPropertyString("sub-back-color", color)
|
||||
mpv?.setPropertyString("sub-back-color", color)
|
||||
}
|
||||
|
||||
fun setSubtitleAssOverride(mode: String) {
|
||||
MPVLib.setPropertyString("sub-ass-override", mode)
|
||||
mpv?.setPropertyString("sub-ass-override", mode)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
@@ -509,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getAudioTracks(): List<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) {
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: 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)
|
||||
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = 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) {
|
||||
track["channels"] = channels
|
||||
}
|
||||
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -538,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
mpv?.setPropertyInt("aid", trackId)
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return MPVLib.getPropertyInt("aid") ?: 0
|
||||
return mpv?.getPropertyInt("aid") ?: 0
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
@@ -551,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
@@ -560,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// Video dimensions
|
||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
info["videoWidth"] = it
|
||||
}
|
||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
info["videoHeight"] = it
|
||||
}
|
||||
|
||||
// Video codec
|
||||
MPVLib.getPropertyString("video-format")?.let {
|
||||
mpv?.getPropertyString("video-format")?.let {
|
||||
info["videoCodec"] = it
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||
info["audioCodec"] = it
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
info["fps"] = it
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["videoBitrate"] = it
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["audioBitrate"] = it
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
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
|
||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||
info["droppedFrames"] = it
|
||||
}
|
||||
|
||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||
MPVLib.getPropertyString("vo")?.let {
|
||||
mpv?.getPropertyString("vo")?.let {
|
||||
info["voDriver"] = it
|
||||
}
|
||||
|
||||
// Active hardware decoder
|
||||
MPVLib.getPropertyString("hwdec-active")?.let {
|
||||
// Active hardware decoder.
|
||||
// hwdec-current yields e.g. "mediacodec",
|
||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||
mpv?.getPropertyString("hwdec-current")?.let {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -704,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||
// "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()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||
|
||||
/**
|
||||
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
* This provides a consistent interface for the rest of the app.
|
||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
*
|
||||
* 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 {
|
||||
private const val TAG = "MPVLib"
|
||||
|
||||
private var initialized = false
|
||||
|
||||
// Event observer interface
|
||||
class MPVLib private constructor(private val instance: LibMPV) {
|
||||
|
||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||
interface EventObserver {
|
||||
fun eventProperty(property: String)
|
||||
fun eventProperty(property: String, value: Long)
|
||||
@@ -23,198 +32,144 @@ object MPVLib {
|
||||
fun eventProperty(property: String, value: Double)
|
||||
fun event(eventId: Int)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
override fun eventProperty(property: String) {
|
||||
override fun eventProperty(property: String) =
|
||||
dispatch { it.eventProperty(property) }
|
||||
|
||||
override fun eventProperty(property: String, value: Long) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: String) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Double) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun event(eventId: Int) =
|
||||
dispatch { it.event(eventId) }
|
||||
|
||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.event(eventId)
|
||||
}
|
||||
observers.forEach(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addObserver(observer: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.add(observer)
|
||||
}
|
||||
synchronized(observers) { observers.add(observer) }
|
||||
}
|
||||
|
||||
|
||||
fun removeObserver(observer: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.remove(observer)
|
||||
}
|
||||
synchronized(observers) { observers.remove(observer) }
|
||||
}
|
||||
|
||||
// MPV Event IDs
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
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()
|
||||
instance.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 attachSurface(surface: android.view.Surface) {
|
||||
instance.attachSurface(surface)
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean = initialized
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
LibMPV.attachSurface(surface)
|
||||
}
|
||||
|
||||
|
||||
fun detachSurface() {
|
||||
LibMPV.detachSurface()
|
||||
instance.detachSurface()
|
||||
}
|
||||
|
||||
fun command(cmd: Array<String?>) {
|
||||
LibMPV.command(cmd)
|
||||
|
||||
fun command(cmd: Array<String>) {
|
||||
instance.command(cmd)
|
||||
}
|
||||
|
||||
|
||||
fun setOptionString(name: String, value: String): Int {
|
||||
return LibMPV.setOptionString(name, value)
|
||||
return instance.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 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) {
|
||||
LibMPV.setPropertyInt(name, value)
|
||||
instance.setPropertyInt(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyDouble(name: String, value: Double) {
|
||||
LibMPV.setPropertyDouble(name, value)
|
||||
instance.setPropertyDouble(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||
LibMPV.setPropertyBoolean(name, value)
|
||||
instance.setPropertyBoolean(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyString(name: String, value: String) {
|
||||
LibMPV.setPropertyString(name, value)
|
||||
instance.setPropertyString(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun observeProperty(name: String, format: Int) {
|
||||
LibMPV.observeProperty(name, format)
|
||||
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_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ class MpvPlayerModule : Module() {
|
||||
if (source == null) 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")
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] 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)
|
||||
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
|
||||
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
|
||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
|
||||
@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
@@ -26,15 +24,30 @@ data class VideoLoadConfig(
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: 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.
|
||||
* Uses TextureView for reliable Picture-in-Picture support.
|
||||
*
|
||||
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
||||
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
||||
* app's window surface. This matches mpv-android's architecture and
|
||||
* gives mpv a standalone surface.
|
||||
*
|
||||
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
||||
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
||||
* can be stale until the next layout pass. We push dimension updates to
|
||||
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
||||
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
||||
* passes on the view itself) reaches mpv promptly.
|
||||
*/
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
@@ -48,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private var textureView: TextureView
|
||||
private var surfaceView: SurfaceView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
private var pipController: PiPController? = null
|
||||
|
||||
@@ -59,30 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var surfaceReady: Boolean = false
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
private var activeSurface: Surface? = null
|
||||
|
||||
// PiP state tracking
|
||||
private var isWaitingForPiPTransition: Boolean = false
|
||||
private var isPiPSurfaceForced: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||
textureView = TextureView(context).apply {
|
||||
// SurfaceView for video rendering. Routes the surface directly to
|
||||
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
||||
// surface. TextureView composites into the app's window surface
|
||||
// which is less efficient and breaks PiP transitions.
|
||||
surfaceView = SurfaceView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
surfaceTextureListener = this@MpvPlayerView
|
||||
}
|
||||
addView(textureView)
|
||||
surfaceView.holder.addCallback(this@MpvPlayerView)
|
||||
addView(surfaceView)
|
||||
|
||||
// Push dimension updates to mpv on every view bounds change. This
|
||||
// is the primary PiP black-screen fix: entering PiP fires a layout
|
||||
// pass on the SurfaceView itself, and we proactively tell mpv the
|
||||
// new size so it resizes its EGL swapchain before rendering.
|
||||
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
||||
oldLeft, oldTop, oldRight, oldBottom ->
|
||||
val w = right - left
|
||||
val h = bottom - top
|
||||
val oldW = oldRight - oldLeft
|
||||
val oldH = oldBottom - oldTop
|
||||
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||
pipController = PiPController(context, appContext)
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.setPlayerView(surfaceView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
play()
|
||||
@@ -98,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||
if (isInPiP) {
|
||||
if (!isWaitingForPiPTransition) {
|
||||
isWaitingForPiPTransition = true
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isWaitingForPiPTransition = false
|
||||
// Post size syncs after the PiP layout settles. Two passes
|
||||
// catch both the immediate surface re-attach and the
|
||||
// post-animation layout pass. Replaces the old TextureView
|
||||
// measure/layout polling hack (forcePiPBufferSize).
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
restoreFromPiP()
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||
} else {
|
||||
// Restore from PiP: surface resized back to fullscreen.
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
}
|
||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||
}
|
||||
@@ -121,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
/**
|
||||
* Start the renderer with the given VO driver.
|
||||
* Called lazily on first loadVideo so the voDriver setting is available.
|
||||
* Called lazily on first loadVideo so user settings are available.
|
||||
*/
|
||||
private fun ensureRendererStarted(voDriver: String?) {
|
||||
if (rendererStarted) return
|
||||
@@ -130,9 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
renderer?.start(voDriver ?: "gpu-next")
|
||||
rendererStarted = true
|
||||
|
||||
pendingSurface?.let { surface ->
|
||||
// If the surface is already alive (surfaceCreated fired before
|
||||
// loadVideo), attach it now. With SurfaceView the surface is
|
||||
// owned by the holder, so we read it from there directly rather
|
||||
// than stashing it on the side.
|
||||
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
pendingSurface = null
|
||||
syncSurfaceSizeToView()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||
@@ -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) {
|
||||
this.surfaceTexture = surfaceTexture
|
||||
val surface = Surface(surfaceTexture)
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
val surface = holder.surface
|
||||
surfaceReady = true
|
||||
|
||||
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)
|
||||
} else {
|
||||
pendingSurface = surface
|
||||
// Push the actual view dimensions immediately so mpv doesn't
|
||||
// render against stale full-screen geometry during PiP transitions.
|
||||
syncSurfaceSizeToView()
|
||||
}
|
||||
|
||||
// If we have a pending load, execute it now
|
||||
@@ -162,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
this.surfaceTexture = null
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surfaceReady = false
|
||||
renderer?.detachSurface()
|
||||
return false // mpv manages the SurfaceTexture
|
||||
// Do NOT issue mpv "stop" here. Playback continues against the
|
||||
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
||||
// background/foreground), we re-attach and frames resume. This
|
||||
// matches the keep-open=always setting in MPVLayerRenderer.
|
||||
//
|
||||
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
||||
activeSurface = null
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called every frame — no action needed, mpv drives rendering directly
|
||||
/**
|
||||
* Read the actual SurfaceView width/height and push them to mpv.
|
||||
* The PiP transition can fire surfaceCreated before the view's layout
|
||||
* has settled to PiP dimensions, so we re-sync after layout passes.
|
||||
*/
|
||||
private fun syncSurfaceSizeToView() {
|
||||
if (!surfaceReady) return
|
||||
val w = surfaceView.width
|
||||
val h = surfaceView.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
@@ -207,7 +258,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
startPosition = config.startPosition,
|
||||
externalSubtitles = config.externalSubtitles,
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId
|
||||
initialAudioId = config.initialAudioId,
|
||||
cacheEnabled = config.cacheEnabled,
|
||||
cacheSeconds = config.cacheSeconds,
|
||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||
)
|
||||
|
||||
if (config.autoplay) {
|
||||
@@ -236,6 +291,50 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
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) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
@@ -267,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
fun startPictureInPicture() {
|
||||
isWaitingForPiPTransition = true
|
||||
pipController?.startPictureInPicture()
|
||||
|
||||
// Resize buffer to match PiP window after animation settles
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||
*/
|
||||
private fun forcePiPBufferSize() {
|
||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||
|
||||
val rect = Rect()
|
||||
textureView.getGlobalVisibleRect(rect)
|
||||
val visW = rect.width()
|
||||
val visH = rect.height()
|
||||
val vw = textureView.width
|
||||
val vh = textureView.height
|
||||
|
||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||
|
||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||
renderer?.updateSurfaceSize(visW, visH)
|
||||
|
||||
// Force TextureView layout to match PiP visible area.
|
||||
// layoutParams alone doesn't work during PiP because the parent
|
||||
// never re-lays out its children.
|
||||
textureView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||
)
|
||||
textureView.layout(0, 0, visW, visH)
|
||||
isPiPSurfaceForced = true
|
||||
}
|
||||
|
||||
private fun restoreFromPiP() {
|
||||
if (!isPiPSurfaceForced) return
|
||||
isPiPSurfaceForced = false
|
||||
|
||||
val lp = textureView.layoutParams
|
||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
textureView.layoutParams = lp
|
||||
textureView.requestLayout()
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
@@ -479,13 +529,24 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
// 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() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
surfaceTexture = null
|
||||
renderer?.delegate = null
|
||||
|
||||
// SurfaceView owns the Surface via its holder — do NOT release it.
|
||||
activeSurface = null
|
||||
surfaceReady = false
|
||||
currentUrl = null
|
||||
rendererStarted = false
|
||||
}
|
||||
|
||||
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 currentDuration: Double = 0.0
|
||||
private var playbackRate: Double = 1.0
|
||||
// Independently tracks whether the system should auto-enter PiP on home
|
||||
// press. Decoupled from playbackRate so that disabling auto-enter
|
||||
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
||||
// state that buildPiPActions() derives from playbackRate.
|
||||
private var autoEnterEnabled: Boolean = false
|
||||
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
// Disable auto-enter eligibility without touching playbackRate.
|
||||
// playbackRate drives the play/pause icon in buildPiPActions();
|
||||
// mutating it here would cause a stale icon if PiP is re-entered
|
||||
// before the next playback state callback corrects it.
|
||||
autoEnterEnabled = false
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
unregisterLifecycleCallbacks()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
activity.moveTaskToBack(false)
|
||||
|
||||
val activity = getActivity() ?: return
|
||||
|
||||
// Push minimal params with just auto-enter disabled. Do NOT call
|
||||
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
||||
// setActions(), which would re-register the broadcast receiver
|
||||
// (just unregistered above) and attach play/pause/skip actions to
|
||||
// params being torn down. That leaves a live receiver + stale
|
||||
// actions after the player has unmounted.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (activity.isInPictureInPictureMode) {
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
|
||||
fun setPlaybackRate(rate: Double) {
|
||||
playbackRate = rate
|
||||
autoEnterEnabled = rate > 0
|
||||
|
||||
if (rate > 0) {
|
||||
registerLifecycleCallbacks()
|
||||
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
builder.setActions(buildPiPActions())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
|
||||
@@ -1020,12 +1020,44 @@ final class MPVLayerRenderer {
|
||||
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
|
||||
var droppedFrames: Int64 = 0
|
||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,13 @@ public class MpvPlayerModule: Module {
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
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
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
|
||||
@@ -289,6 +289,49 @@ class MpvPlayerView: ExpoView {
|
||||
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) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
cachedPosition = position
|
||||
|
||||
@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => 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>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
@@ -154,9 +162,17 @@ export type TechnicalInfo = {
|
||||
videoBitrate?: number;
|
||||
audioBitrate?: 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;
|
||||
/** Active video output driver (read from MPV at runtime) */
|
||||
voDriver?: string;
|
||||
/** Active hardware decoder (read from MPV at runtime) */
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
|
||||
15
package.json
15
package.json
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
@@ -39,7 +39,7 @@
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.28",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "2.0.3",
|
||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
@@ -126,16 +126,17 @@
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "^18.19.130",
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.20.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"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_END = "## <<< runtime-framework headers";
|
||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||
];
|
||||
|
||||
function buildPatch() {
|
||||
function buildPatch(): string {
|
||||
return [
|
||||
PATCH_START,
|
||||
" extra_hdrs = [",
|
||||
@@ -91,7 +91,7 @@ function buildPatch() {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
||||
return withPodfile(config, (config) => {
|
||||
let podfile = config.modResults.contents;
|
||||
|
||||
@@ -125,3 +125,5 @@ end
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
export default withRuntimeFrameworkHeaders;
|
||||
@@ -1,10 +1,20 @@
|
||||
const {
|
||||
import {
|
||||
type ConfigPlugin,
|
||||
withAndroidColors,
|
||||
withAndroidColorsNight,
|
||||
} = require("expo/config-plugins");
|
||||
} from "expo/config-plugins";
|
||||
|
||||
const withAndroidAlertColors = (config) => {
|
||||
const setColor = (colorsList, name, value) => {
|
||||
interface ColorResourceItem {
|
||||
$: { name: string };
|
||||
_: string;
|
||||
}
|
||||
|
||||
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||
const setColor = (
|
||||
colorsList: ColorResourceItem[],
|
||||
name: string,
|
||||
value: string,
|
||||
) => {
|
||||
const existingColor = colorsList.find(
|
||||
(item) => item.$ && item.$.name === name,
|
||||
);
|
||||
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
|
||||
|
||||
config = withAndroidColors(config, (config) => {
|
||||
const colors = config.modResults;
|
||||
const colorsList = colors.resources.color || [];
|
||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||
setColor(colorsList, "colorPrimary", "#000000");
|
||||
colors.resources.color = colorsList;
|
||||
return config;
|
||||
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
|
||||
|
||||
config = withAndroidColorsNight(config, (config) => {
|
||||
const colors = config.modResults;
|
||||
const colorsList = colors.resources.color || [];
|
||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||
colors.resources.color = colorsList;
|
||||
return config;
|
||||
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withAndroidAlertColors;
|
||||
export default withAndroidAlertColors;
|
||||
@@ -1,8 +1,12 @@
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
||||
|
||||
const _withGoogleCastAndroidManifest = (config) =>
|
||||
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
||||
withAndroidManifest(config, async (mod) => {
|
||||
const mainApplication = mod.modResults.manifest.application[0];
|
||||
const mainApplication = mod.modResults.manifest.application?.[0];
|
||||
|
||||
if (!mainApplication) {
|
||||
return mod;
|
||||
}
|
||||
|
||||
// Initialize activity array if it doesn't exist
|
||||
if (!mainApplication.activity) {
|
||||
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
|
||||
return mod;
|
||||
});
|
||||
|
||||
module.exports = _withGoogleCastAndroidManifest;
|
||||
export default withGoogleCastAndroidManifest;
|
||||
@@ -1,8 +1,8 @@
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { withDangerousMod } = require("expo/config-plugins");
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||
|
||||
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
||||
withDangerousMod(expoConfig, [
|
||||
"android",
|
||||
(modConfig) => {
|
||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withChangeNativeAndroidTextToWhite;
|
||||
export default withChangeNativeAndroidTextToWhite;
|
||||
@@ -1,6 +1,6 @@
|
||||
const { withAppBuildGradle } = require("expo/config-plugins");
|
||||
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
||||
|
||||
module.exports = function withExcludeMedia3Dash(config) {
|
||||
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
||||
return withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
@@ -32,3 +32,5 @@ configurations.all {
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
export default withExcludeMedia3Dash;
|
||||
@@ -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) => {
|
||||
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) => {
|
||||
const props = exportedConfig.modResults;
|
||||
const keyIdx = props.findIndex(
|
||||
(item) => item.type === "property" && item.key === key,
|
||||
);
|
||||
const property = {
|
||||
const property: AndroidConfig.Properties.PropertiesItem = {
|
||||
type: "property",
|
||||
key,
|
||||
value,
|
||||
@@ -22,11 +31,14 @@ function setGradlePropertiesValue(config, key, value) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function withCustomPlugin(config) {
|
||||
const withCustomGradleProperties: ConfigPlugin = (config) => {
|
||||
// Expo 52 is not setting this
|
||||
// https://github.com/expo/expo/issues/32558
|
||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||
|
||||
// NDK version required by libmpv 1.0.0
|
||||
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
||||
|
||||
// Increase memory
|
||||
config = setGradlePropertiesValue(
|
||||
config,
|
||||
@@ -35,3 +47,5 @@ module.exports = function withCustomPlugin(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
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
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,
|
||||
withInfoPlist,
|
||||
withXcodeProject,
|
||||
} = require("@expo/config-plugins");
|
||||
} from "expo/config-plugins";
|
||||
|
||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||
@@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||
"StreamyfinKeychainAccessGroupIdentifier";
|
||||
|
||||
function getBundleIdentifier(config) {
|
||||
interface AppExtensionConfig {
|
||||
targetName: string;
|
||||
bundleIdentifier: string;
|
||||
entitlements: {
|
||||
"com.apple.security.application-groups": string[];
|
||||
"keychain-access-groups": string[];
|
||||
};
|
||||
}
|
||||
|
||||
function getBundleIdentifier(config: ExpoConfig): string {
|
||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||
}
|
||||
|
||||
function getAppGroupIdentifier(config) {
|
||||
function getAppGroupIdentifier(config: ExpoConfig): string {
|
||||
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
||||
}
|
||||
|
||||
function getKeychainAccessGroupIdentifier(config) {
|
||||
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
|
||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||
}
|
||||
|
||||
function getBuildConfigurations(project, configurationListId) {
|
||||
// The xcode project object has no usable typings — keep `any` here.
|
||||
function getBuildConfigurations(project: any, configurationListId: string) {
|
||||
const configurationList =
|
||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||
|
||||
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
|
||||
|
||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||
return configurationList.buildConfigurations
|
||||
.map((config) => buildConfigurations[config.value])
|
||||
.map((config: { value: string }) => buildConfigurations[config.value])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureAppGroup(value, appGroupIdentifier) {
|
||||
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(appGroupIdentifier)
|
||||
? groups
|
||||
: [...groups, appGroupIdentifier];
|
||||
}
|
||||
|
||||
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
||||
function ensureKeychainAccessGroup(
|
||||
value: unknown,
|
||||
keychainAccessGroupIdentifier: string,
|
||||
): string[] {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(keychainAccessGroupIdentifier)
|
||||
? groups
|
||||
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
||||
}
|
||||
|
||||
function ensureAppExtension(
|
||||
appExtensions,
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
appGroupIdentifier,
|
||||
keychainAccessGroupIdentifier,
|
||||
) {
|
||||
const extensionConfig = {
|
||||
appExtensions: unknown,
|
||||
targetName: string,
|
||||
bundleIdentifier: string,
|
||||
appGroupIdentifier: string,
|
||||
keychainAccessGroupIdentifier: string,
|
||||
): AppExtensionConfig[] {
|
||||
const extensionConfig: AppExtensionConfig = {
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
entitlements: {
|
||||
@@ -63,7 +78,9 @@ function ensureAppExtension(
|
||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||
},
|
||||
};
|
||||
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
||||
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
|
||||
? appExtensions
|
||||
: [];
|
||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||
const existingIndex = extensions.findIndex(
|
||||
(appExtension) => appExtension?.targetName === targetName,
|
||||
@@ -78,7 +95,7 @@ function ensureAppExtension(
|
||||
);
|
||||
}
|
||||
|
||||
const withTVOSTopShelf = (config) => {
|
||||
const withTVOSTopShelf: ConfigPlugin = (config) => {
|
||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||
const keychainAccessGroupIdentifier =
|
||||
getKeychainAccessGroupIdentifier(config);
|
||||
@@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withTVOSTopShelf;
|
||||
export default withTVOSTopShelf;
|
||||
@@ -1,9 +1,9 @@
|
||||
const { withEntitlementsPlist } = require("expo/config-plugins");
|
||||
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
|
||||
|
||||
/**
|
||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||
*/
|
||||
const withTVUserManagement = (config) => {
|
||||
const withTVUserManagement: ConfigPlugin = (config) => {
|
||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||
// be present in the provisioning profile, so injecting it into mobile builds
|
||||
// breaks signing ("Entitlement ... not found and could not be included in
|
||||
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withTVUserManagement;
|
||||
export default withTVUserManagement;
|
||||
@@ -1,7 +1,7 @@
|
||||
const { withDangerousMod } = require("@expo/config-plugins");
|
||||
const { execSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||
|
||||
/**
|
||||
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
||||
@@ -12,7 +12,7 @@ const path = require("node:path");
|
||||
*
|
||||
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
||||
*/
|
||||
const withTVXcodeEnv = (config) => {
|
||||
const withTVXcodeEnv: ConfigPlugin = (config) => {
|
||||
// Only apply for TV builds
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
return config;
|
||||
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
|
||||
/**
|
||||
* Get the actual node binary path, handling nvm installations.
|
||||
*/
|
||||
function getNodeBinaryPath() {
|
||||
function getNodeBinaryPath(): string | null {
|
||||
try {
|
||||
// First try to get node path directly (works for non-nvm installs)
|
||||
const directPath = execSync("which node 2>/dev/null", {
|
||||
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = withTVXcodeEnv;
|
||||
export default withTVXcodeEnv;
|
||||
@@ -1,18 +1,29 @@
|
||||
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AndroidConfig,
|
||||
type ConfigPlugin,
|
||||
type ExportedConfigWithProps,
|
||||
withAndroidManifest,
|
||||
} from "expo/config-plugins";
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
||||
|
||||
const withTrustLocalCerts = (config) => {
|
||||
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
||||
|
||||
const withTrustLocalCerts: ConfigPlugin = (config) => {
|
||||
return withAndroidManifest(config, async (mod) => {
|
||||
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
||||
return mod;
|
||||
});
|
||||
};
|
||||
|
||||
async function setCustomConfigAsync(config, androidManifest) {
|
||||
async function setCustomConfigAsync(
|
||||
config: ExportedConfigWithProps<AndroidManifest>,
|
||||
androidManifest: AndroidManifest,
|
||||
): Promise<AndroidManifest> {
|
||||
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
||||
const res_file_path = path.join(
|
||||
await AndroidConfig.Paths.getResourceFolderAsync(
|
||||
@@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) {
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
module.exports = withTrustLocalCerts;
|
||||
export default withTrustLocalCerts;
|
||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
return items.reduce((sum, item) => {
|
||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||
// as video + trickplay, the total must match.
|
||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||
// Read the live file size on disk so the total reflects actual usage and
|
||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||
// value if the file can't be stat'd.
|
||||
if (item.videoFilePath) {
|
||||
try {
|
||||
const file = new File(filePathToUri(item.videoFilePath));
|
||||
if (file.exists) {
|
||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to stat downloaded file for size:", error);
|
||||
}
|
||||
}
|
||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
let totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
// Also count in-progress downloads (they write straight to their final
|
||||
// path) so the growing file shows up as app usage instead of drifting
|
||||
// into the generic device share until completion.
|
||||
for (const process of processes) {
|
||||
try {
|
||||
const file = new File(
|
||||
Paths.document,
|
||||
`${generateFilename(process.item)}.mp4`,
|
||||
);
|
||||
if (file.exists) {
|
||||
totalSize += file.size ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// File not created yet — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, [processes]);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
@@ -56,6 +50,12 @@ export interface DownloadedItem {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
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. */
|
||||
userData: UserData;
|
||||
}
|
||||
@@ -144,6 +144,12 @@ export type JobStatus = {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
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 */
|
||||
audioStreamIndex?: number;
|
||||
/** The subtitle stream index selected for this download */
|
||||
|
||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
default:
|
||||
throw new Error(
|
||||
t(
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -34,9 +34,20 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- 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)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
else out[key] = v as string;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
const walk = (dir: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
let st: ReturnType<typeof statSync>;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
const stripComments = (src: string): string =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// the code (covers t("k"), <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
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
const literalUsed = (key: string): boolean =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
const isUsed = (key: string): boolean =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
@@ -21,8 +21,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name, def) => {
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s) =>
|
||||
const tokens = (s: string | null): string[] =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o) =>
|
||||
const score = (o: Issue): number =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues;
|
||||
let issues: Issue[];
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t) =>
|
||||
const safeTitle = (t: string): string =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
@@ -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");
|
||||
const process = require("node:process");
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message, color = "") {
|
||||
function log(message: string, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine) {
|
||||
function formatError(errorLine: string): string {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
|
||||
let currentError = [];
|
||||
let currentError: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck() {
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
||||
"false",
|
||||
...extraArgs,
|
||||
];
|
||||
let execArgs = null;
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
} catch {
|
||||
// fallback to PATH tsc
|
||||
execArgs = {
|
||||
cmd: "tsc",
|
||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
||||
};
|
||||
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = [execError.stdout, execError.stderr]
|
||||
.filter((chunk): chunk is string => Boolean(chunk))
|
||||
.join("\n");
|
||||
|
||||
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||
// launch failure fall through to the "passed" branch and green-light CI.
|
||||
if (!errorOutput) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||
colors.red,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
@@ -65,7 +65,7 @@ final class TopShelfProvider: TVTopShelfContentProvider {
|
||||
|
||||
let item = TVTopShelfSectionedItem(identifier: cacheItem.id)
|
||||
item.title = cacheItem.title
|
||||
item.imageShape = .poster
|
||||
item.imageShape = .hdtv
|
||||
item.displayAction = TVTopShelfAction(url: route)
|
||||
|
||||
if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) {
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "حسنًا",
|
||||
"connection_failed": "فشل الاتصال",
|
||||
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
||||
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
|
||||
"an_unexpected_error_occurred": "حدث خطأ غير متوقع",
|
||||
"change_server": "تغيير الخادم",
|
||||
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
|
||||
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صَلاحِيَة تسجيل الدخول",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"there_is_a_server_error": "هناك خطأ في الخادم",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
||||
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
|
||||
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
|
||||
},
|
||||
@@ -188,7 +188,7 @@
|
||||
"authorize_button": "تفويض الدخول السريع",
|
||||
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
|
||||
"success": "نجاح",
|
||||
"quick_connect_autorized": "تم تفويض الدخول السريع",
|
||||
"quick_connect_authorized": "تم تفويض الدخول السريع",
|
||||
"error": "خطأ",
|
||||
"invalid_code": "رمز غير صالح",
|
||||
"authorize": "تفويض"
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "لا شيء",
|
||||
"OnlyForced": "فقط الإجبارية"
|
||||
},
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"bold_text": "خط عريض",
|
||||
"colors": {
|
||||
"Black": "أسود",
|
||||
"Gray": "رمادي",
|
||||
"Silver": "فضي",
|
||||
"White": "أبيض",
|
||||
"Maroon": "أحمر داكن",
|
||||
"Red": "أحمر",
|
||||
"Fuchsia": "وردي",
|
||||
"Yellow": "أصفر",
|
||||
"Olive": "أخضر زيتوني",
|
||||
"Green": "أخضر",
|
||||
"Teal": "أزرق مخضر",
|
||||
"Lime": "ليموني",
|
||||
"Purple": "بنفسجي",
|
||||
"Navy": "كحلي",
|
||||
"Blue": "أزرق",
|
||||
"Aqua": "أزرق بحري"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "لا شيء",
|
||||
"Thin": "نحيف",
|
||||
"Normal": "عادي",
|
||||
"Thick": "سميك"
|
||||
},
|
||||
"subtitle_color": "لون الترجمة",
|
||||
"subtitle_background_color": "لون الخلفية",
|
||||
"subtitle_font": "خط الترجمة",
|
||||
"ksplayer_title": "إعدادات KSPlayer",
|
||||
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
||||
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "إعدادات ترجمة VLC",
|
||||
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"bold": "خط عريض",
|
||||
"margin": "الهامش السفلي"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "مشغل الفيديو",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "أخرى",
|
||||
"video_orientation": "اتجاه الفيديو",
|
||||
@@ -351,15 +295,10 @@
|
||||
"UNKNOWN": "غير معروف"
|
||||
},
|
||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||
},
|
||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||
"hide_libraries": "إخفاء المكتبات",
|
||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||
"default_quality": "الجودة الافتراضية",
|
||||
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||
"disabled": "معطل"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات"
|
||||
},
|
||||
"music": {
|
||||
"title": "الموسيقى",
|
||||
"playback_title": "التشغيل",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "الإضافات",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "هذا الربط في مراحله الأولى. توقع حدوث تغييرات.",
|
||||
"server_url": "رابط الخادم",
|
||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
||||
"server_url_placeholder": "رابط Seerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||
"save_button": "حفظ",
|
||||
"toasts": {
|
||||
"saved": "تم الحفظ",
|
||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
"saved": "تم الحفظ"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "تفعيل Streamystats",
|
||||
"disable_streamystats": "تعطيل Streamystats",
|
||||
"enable_search": "استخدم للبحث",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
||||
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
||||
"save_button": "حفظ",
|
||||
"save": "حفظ",
|
||||
"features_title": "المميزات",
|
||||
"home_sections_title": "أقسام الرئيسية",
|
||||
"enable_movie_recommendations": "توصيات الأفلام",
|
||||
"enable_series_recommendations": "توصيات المسلسلات",
|
||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -455,9 +384,10 @@
|
||||
"device_usage": "الجهاز {{availableSpace}}%",
|
||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||
@@ -467,8 +397,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +418,12 @@
|
||||
"system": "النظام"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "خطأ في حذف الملفات",
|
||||
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
||||
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
||||
"error_deleting_files": "خطأ في حذف الملفات"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -518,10 +443,7 @@
|
||||
"downloads_title": "التنزيلات",
|
||||
"series": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"other_media": "وسائط أخرى",
|
||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_series_button": "حذف جميع المسلسلات",
|
||||
@@ -546,13 +468,8 @@
|
||||
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||
"download_deleted": "تم حذف التنزيل",
|
||||
"download_cancelled": "تم إلغاء التنزيل",
|
||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
||||
"download_resumed": "تم استئناف التنزيل",
|
||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
||||
"download_completed": "اكتمل التنزيل",
|
||||
"download_failed": "فشل التنزيل",
|
||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||
@@ -562,17 +479,13 @@
|
||||
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
||||
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
||||
"files_deleted_by_type": "تم حذف {{count}} {{type}}",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
||||
"failed_to_clean_cache_directory": "فشل تنظيف مجلد ذاكرة التخزين المؤقت",
|
||||
"could_not_get_download_url_for_item": "تعذر الحصول على عنوان URL للتنزيل لـ{{itemName}}",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||
"file_deleted": "تم حذف {{item}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "اختر",
|
||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||
"video": "فيديو",
|
||||
"audio": "الصوت",
|
||||
@@ -583,16 +496,17 @@
|
||||
"none": "لا شيء",
|
||||
"track": "أغنية",
|
||||
"cancel": "إلغاء",
|
||||
"stop": "Stop",
|
||||
"delete": "حذف",
|
||||
"ok": "حسناً",
|
||||
"remove": "إزالة",
|
||||
"next": "التالي",
|
||||
"back": "رجوع",
|
||||
"continue": "متابعة",
|
||||
"verifying": "جارٍ التحقق...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "بحث...",
|
||||
@@ -686,15 +600,11 @@
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "خطأ",
|
||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||
"an_error_occurred_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||
"client_error": "خطأ في المشغّل",
|
||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||
"next_episode": "الحلقة التالية",
|
||||
"refresh_tracks": "تحديث المسارات",
|
||||
"audio_tracks": "مسارات الصوت:",
|
||||
"playback_state": "حالة التشغيل:",
|
||||
"index": "الفِهْرِس:",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"go_back": "رجوع",
|
||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||
@@ -712,7 +622,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "التالي",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "قوائم التشغيل",
|
||||
"tracks": "الأغاني"
|
||||
},
|
||||
"filters": {
|
||||
"all": "الكل"
|
||||
},
|
||||
"recently_added": "أضيف مؤخرًا",
|
||||
"recently_played": "تم تشغيله مؤخرًا",
|
||||
"frequently_played": "الأكثر تشغيلاً",
|
||||
"explore": "اكتشف",
|
||||
"top_tracks": "أفضل الأغاني",
|
||||
"play": "تشغيل",
|
||||
"shuffle": "ترتيب عشوائي",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Entesos",
|
||||
"connection_failed": "Ha fallat la connexió",
|
||||
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
|
||||
"an_unexpected_error_occured": "S'ha produït un error inesperat",
|
||||
"an_unexpected_error_occurred": "S'ha produït un error inesperat",
|
||||
"change_server": "Canvia el servidor",
|
||||
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
|
||||
"user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard",
|
||||
"server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.",
|
||||
"there_is_a_server_error": "Error del servidor",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?",
|
||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?",
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
@@ -188,7 +188,7 @@
|
||||
"authorize_button": "Autoritza connexió ràpida",
|
||||
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
|
||||
"success": "Èxit",
|
||||
"quick_connect_autorized": "Connexió ràpida autoritzada",
|
||||
"quick_connect_authorized": "Connexió ràpida autoritzada",
|
||||
"error": "Error",
|
||||
"invalid_code": "Codi invàlid",
|
||||
"authorize": "Autoritza"
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Cap",
|
||||
"OnlyForced": "Només els forçats"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Black",
|
||||
"Gray": "Gray",
|
||||
"Silver": "Silver",
|
||||
"White": "White",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Red",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Yellow",
|
||||
"Olive": "Olive",
|
||||
"Green": "Green",
|
||||
"Teal": "Teal",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Purple",
|
||||
"Navy": "Navy",
|
||||
"Blue": "Blue",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Cap",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Altres",
|
||||
"video_orientation": "Orientació del vídeo",
|
||||
@@ -351,15 +295,10 @@
|
||||
"UNKNOWN": "Desconeguda"
|
||||
},
|
||||
"safe_area_in_controls": "Àrea segura als controls",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Oculta biblioteques",
|
||||
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||
"select_libraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
||||
"default_quality": "Qualitat per defecte",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||
"disabled": "Desactivat"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descàrregues"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Connectors",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Aquesta integració es troba en una versió primerenca. Espereu que les coses canviïn.",
|
||||
"server_url": "URL del servidor",
|
||||
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
|
||||
"server_url_placeholder": "URL de Jellyseerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||
"save_button": "Desa",
|
||||
"toasts": {
|
||||
"saved": "Desat",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Desat"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -455,9 +384,10 @@
|
||||
"device_usage": "Dispositiu {{availableSpace}}%",
|
||||
"size_used": "{{used}} de {{total}} utilitzat",
|
||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +397,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +418,12 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error en suprimir fitxers",
|
||||
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
||||
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
|
||||
"error_deleting_files": "Error en suprimir fitxers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -518,10 +443,7 @@
|
||||
"downloads_title": "Descàrregues",
|
||||
"series": "Sèries",
|
||||
"movies": "Pel·lícules",
|
||||
"queue": "Cua",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
|
||||
"no_items_in_queue": "No hi ha elements a la cua",
|
||||
"no_downloaded_items": "No hi ha elements descarregats",
|
||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||
"delete_all_series_button": "Suprimeix totes les sèries",
|
||||
@@ -546,13 +468,8 @@
|
||||
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Descàrrega cancel·lada",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Descàrrega completada",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||
@@ -562,17 +479,13 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Ves a les descàrregues",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Vídeo",
|
||||
"audio": "Àudio",
|
||||
@@ -583,16 +496,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -686,15 +600,11 @@
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
||||
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
||||
"an_error_occurred_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
||||
"client_error": "Error del client",
|
||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||
"message_from_server": "Missatge del servidor: {{message}}",
|
||||
"next_episode": "Episodi següent",
|
||||
"refresh_tracks": "Actualitzar pistes",
|
||||
"audio_tracks": "Pistes d'àudio:",
|
||||
"playback_state": "Estat de reproducció:",
|
||||
"index": "Índex:",
|
||||
"continue_watching": "Continuar veient",
|
||||
"go_back": "Enrere",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -712,7 +622,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Mostra més",
|
||||
"show_less": "Mostra menys",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Següent",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Rozumím",
|
||||
"connection_failed": "Připojení se nezdařilo",
|
||||
"could_not_connect_to_server": "Nelze se připojit k serveru. Zkontrolujte adresu URL a síťové připojení.",
|
||||
"an_unexpected_error_occured": "Došlo k neočekávané chybě",
|
||||
"an_unexpected_error_occurred": "Došlo k neočekávané chybě",
|
||||
"change_server": "Změnit server",
|
||||
"invalid_username_or_password": "Neplatné uživatelské jméno nebo heslo",
|
||||
"user_does_not_have_permission_to_log_in": "Uživatel nemá oprávnění k přihlášení",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Reakce na server trvá příliš dlouho, zkuste to znovu později",
|
||||
"server_received_too_many_requests_try_again_later": "Server obdržel příliš mnoho požadavků, opakujte akci později.",
|
||||
"there_is_a_server_error": "Došlo k chybě serveru",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
|
||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
|
||||
"too_old_server_text": "Objeven nepodporovaný Jellyfin server",
|
||||
"too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi"
|
||||
},
|
||||
@@ -188,7 +188,7 @@
|
||||
"authorize_button": "Autorizovat rychlé připojení",
|
||||
"enter_the_quick_connect_code": "Zadejte kód rychlého připojení...",
|
||||
"success": "Úspěšně",
|
||||
"quick_connect_autorized": "Oprávněné Rychlé připojení",
|
||||
"quick_connect_authorized": "Oprávněné Rychlé připojení",
|
||||
"error": "Chyba",
|
||||
"invalid_code": "Neplatný kód",
|
||||
"authorize": "Autorizovat"
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Nic",
|
||||
"OnlyForced": "Pouze vynucené"
|
||||
},
|
||||
"text_color": "Barva textu",
|
||||
"background_color": "Barva pozadí",
|
||||
"outline_color": "Barva obrysu",
|
||||
"outline_thickness": "Obrys tloušťky",
|
||||
"background_opacity": "Průhlednost pozadí",
|
||||
"outline_opacity": "Průhlednost obrysu",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Černý",
|
||||
"Gray": "Šedá",
|
||||
"Silver": "Stříbro",
|
||||
"White": "Bílý",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Červená",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Žlutá",
|
||||
"Olive": "Olivy",
|
||||
"Green": "Zelená",
|
||||
"Teal": "Modrozelený",
|
||||
"Lime": "Světle zelená",
|
||||
"Purple": "Fialová",
|
||||
"Navy": "Námořní loď",
|
||||
"Blue": "Modrá",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nic",
|
||||
"Thin": "Tenké",
|
||||
"Normal": "Normální",
|
||||
"Thick": "Tlustá"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Ostatní",
|
||||
"video_orientation": "Orientace videa",
|
||||
@@ -351,15 +295,10 @@
|
||||
"UNKNOWN": "Neznámý"
|
||||
},
|
||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
||||
"video_player": "Video přehrávač",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (experimentální + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||
"hide_libraries": "Skrýt knihovny",
|
||||
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
||||
"select_libraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
||||
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
|
||||
"default_quality": "Výchozí kvalita",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
||||
"disabled": "Zakázáno"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Stahování"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Pluginy",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Tato integrace je v raných fázích. Očekávejte změnu situace.",
|
||||
"server_url": "URL serveru",
|
||||
"server_url_hint": "Příklad: http(s)://your-host.url\n(přidat port, pokud je vyžadováno)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
||||
"save_button": "Uložit",
|
||||
"toasts": {
|
||||
"saved": "Uloženo",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Uloženo"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -455,9 +384,10 @@
|
||||
"device_usage": "Zařízení {{availableSpace}}%",
|
||||
"size_used": "{{used}} z {{total}} využito",
|
||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +397,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +418,12 @@
|
||||
"system": "Systém"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Chyba při mazání souborů",
|
||||
"background_downloads_enabled": "Stahování na pozadí povoleno",
|
||||
"background_downloads_disabled": "Stahování na pozadí zakázáno"
|
||||
"error_deleting_files": "Chyba při mazání souborů"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -518,10 +443,7 @@
|
||||
"downloads_title": "Stahování",
|
||||
"series": "Televizní série",
|
||||
"movies": "Filmy",
|
||||
"queue": "Fronta",
|
||||
"other_media": "Ostatní média",
|
||||
"queue_hint": "Fronta a stahování budou ztraceny při restartu aplikace",
|
||||
"no_items_in_queue": "Žádné položky ve frontě",
|
||||
"no_downloaded_items": "Žádné stažené položky",
|
||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||
"delete_all_series_button": "Odstranit všechny TV-série",
|
||||
@@ -546,13 +468,8 @@
|
||||
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||
"download_deleted": "Stahování smazáno",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Stahování nelze odstranit",
|
||||
"download_paused": "Stahování pozastaveno",
|
||||
"could_not_pause_download": "Nelze pozastavit stahování",
|
||||
"download_resumed": "Stahování obnoveno",
|
||||
"could_not_resume_download": "Nelze pokračovat v stahování",
|
||||
"download_completed": "Stahování dokončeno",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
||||
@@ -562,17 +479,13 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Všechny soubory, složky a úlohy byly úspěšně odstraněny",
|
||||
"failed_to_clean_cache_directory": "Nepodařilo se vyčistit adresář mezipaměti",
|
||||
"could_not_get_download_url_for_item": "Nelze získat URL pro stažení {{itemName}}",
|
||||
"go_to_downloads": "Přejít na stahování",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vybrat",
|
||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||
"video": "Video",
|
||||
"audio": "Zvuk",
|
||||
@@ -583,16 +496,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Hledat...",
|
||||
@@ -686,15 +600,11 @@
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Chyba",
|
||||
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
|
||||
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
||||
"an_error_occurred_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
||||
"client_error": "Chyba klienta",
|
||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||
"next_episode": "Další epizoda",
|
||||
"refresh_tracks": "Obnovit skladby",
|
||||
"audio_tracks": "Zvukové stopy:",
|
||||
"playback_state": "Stav přehrávání:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Pokračovat ve sledování",
|
||||
"go_back": "Zpět",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -712,7 +622,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Zobrazit více",
|
||||
"show_less": "Zobrazit méně",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Další",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Forstået",
|
||||
"connection_failed": "Forbindelsen mislykkedes",
|
||||
"could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.",
|
||||
"an_unexpected_error_occured": "Der opstod en uventet fejl",
|
||||
"an_unexpected_error_occurred": "Der opstod en uventet fejl",
|
||||
"change_server": "Skift server",
|
||||
"invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode",
|
||||
"user_does_not_have_permission_to_log_in": "Brugeren har ikke tilladelse til at logge ind",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Serveren svarer for langsomt, prøv igen senere",
|
||||
"server_received_too_many_requests_try_again_later": "Serveren modtog for mange forespørgsler, prøv igen senere.",
|
||||
"there_is_a_server_error": "Der er en serverfejl",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
|
||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
|
||||
"too_old_server_text": "Ikke Understøttet Jellyfin Server Opdaget",
|
||||
"too_old_server_description": "Opdater venligst Jellyfin til den seneste version"
|
||||
},
|
||||
@@ -188,7 +188,7 @@
|
||||
"authorize_button": "Autoriser Hurtigforbindelse",
|
||||
"enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...",
|
||||
"success": "Succes",
|
||||
"quick_connect_autorized": "Hurtigforbindelse autoriseret",
|
||||
"quick_connect_authorized": "Hurtigforbindelse autoriseret",
|
||||
"error": "Fejl",
|
||||
"invalid_code": "Ugyldig kode",
|
||||
"authorize": "Autoriser"
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Ingen",
|
||||
"OnlyForced": "Kun tvungne undertekster"
|
||||
},
|
||||
"text_color": "Tekst Farve",
|
||||
"background_color": "Baggrunds Farve",
|
||||
"outline_color": "Omrids Farve",
|
||||
"outline_thickness": "Omrids Tykkelse",
|
||||
"background_opacity": "Baggrunds Gennemsigtighed",
|
||||
"outline_opacity": "Omrids Gennemsigtighed",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Sort",
|
||||
"Gray": "Grå",
|
||||
"Silver": "Sølv",
|
||||
"White": "Hvid",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Rød",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gul",
|
||||
"Olive": "Oliven",
|
||||
"Green": "Grøn",
|
||||
"Teal": "Grønblåt",
|
||||
"Lime": "Limegrøn",
|
||||
"Purple": "Lilla",
|
||||
"Navy": "Flåden",
|
||||
"Blue": "Blå",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ingen",
|
||||
"Thin": "Tynd",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Tyk"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Andet",
|
||||
"video_orientation": "Videoorientering",
|
||||
@@ -351,15 +295,10 @@
|
||||
"UNKNOWN": "Ukendt"
|
||||
},
|
||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
||||
"video_player": "Videospiller",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentel + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Skjul biblioteker",
|
||||
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
||||
"default_quality": "Standard kvalitet",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Denne integration er i en tidlig fase. Forvent, at tingene ændres.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Eksempel: http(s)://din-host.url\n(tilføj port hvis nødvendigt)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Læs mere om Marlin.",
|
||||
"save_button": "Gem",
|
||||
"toasts": {
|
||||
"saved": "Gemt",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Gemt"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -455,9 +384,10 @@
|
||||
"device_usage": "Enhedsforbrug: {{availableSpace}}%",
|
||||
"size_used": "{{used}} af {{total}} brugt",
|
||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +397,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +418,12 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fejl ved sletning af filer",
|
||||
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
||||
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
||||
"error_deleting_files": "Fejl ved sletning af filer"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -518,10 +443,7 @@
|
||||
"downloads_title": "Downloads",
|
||||
"series": "TV-serier",
|
||||
"movies": "Film",
|
||||
"queue": "Kø",
|
||||
"other_media": "Andre medier",
|
||||
"queue_hint": "Kø og downloads vil gå tabt ved genstart af appen",
|
||||
"no_items_in_queue": "Ingen elementer i køen",
|
||||
"no_downloaded_items": "Ingen downloadede elementer",
|
||||
"delete_all_movies_button": "Slet alle film",
|
||||
"delete_all_series_button": "Slet alle TV-serier",
|
||||
@@ -546,13 +468,8 @@
|
||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||
"download_deleted": "Download Slettet",
|
||||
"download_cancelled": "Download afbrudt",
|
||||
"could_not_delete_download": "Kunne Ikke Slette Download",
|
||||
"download_paused": "Download Pauset",
|
||||
"could_not_pause_download": "Kunne Ikke Pause Download",
|
||||
"download_resumed": "Download Genoprettet",
|
||||
"could_not_resume_download": "Kunne Ikke Genoptage Download",
|
||||
"download_completed": "Download fuldført",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
||||
@@ -562,17 +479,13 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobs blev slettet med succes",
|
||||
"failed_to_clean_cache_directory": "Kunne ikke rense cache-mappe",
|
||||
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
||||
"go_to_downloads": "Gå til downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vælg",
|
||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -583,16 +496,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Søg...",
|
||||
@@ -686,15 +600,11 @@
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Fejl",
|
||||
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
|
||||
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
||||
"an_error_occurred_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
||||
"client_error": "Klientfejl",
|
||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||
"message_from_server": "Besked fra server: {{message}}",
|
||||
"next_episode": "Næste episode",
|
||||
"refresh_tracks": "Opdater spor",
|
||||
"audio_tracks": "Lydspor:",
|
||||
"playback_state": "Afspilningstilstand:",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Fortsæt med at se",
|
||||
"go_back": "Gå Tilbage",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -712,7 +622,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Vis mere",
|
||||
"show_less": "Vis mindre",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Næste",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"error_title": "Fehler",
|
||||
"login_title": "Anmelden",
|
||||
"login_to_title": "Anmelden bei",
|
||||
"select_user": "Select a user to log in",
|
||||
"add_user_to_login": "Add a user to log in",
|
||||
"select_user": "Benutzer zum Anmelden auswählen",
|
||||
"add_user_to_login": "Zum Anmelden einen Benutzer hinzufügen",
|
||||
"add_user": "Add User",
|
||||
"username_placeholder": "Benutzername",
|
||||
"password_placeholder": "Passwort",
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Verstanden",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
|
||||
"an_unexpected_error_occured": "Ein unerwarteter Fehler ist aufgetreten",
|
||||
"an_unexpected_error_occurred": "Ein unerwarteter Fehler ist aufgetreten",
|
||||
"change_server": "Server wechseln",
|
||||
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
|
||||
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut",
|
||||
"server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.",
|
||||
"there_is_a_server_error": "Es gibt einen Serverfehler",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
|
||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
|
||||
"too_old_server_text": "Nicht unterstützter Jellyfin Server entdeckt",
|
||||
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
|
||||
},
|
||||
@@ -47,9 +47,9 @@
|
||||
"add_account": "Konto hinzufügen",
|
||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
||||
"remove_server": "Remove Server",
|
||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||
"remove_server_description": "Dies wird {{server}} und alle gespeicherten Konten aus Ihrer Liste entfernen.",
|
||||
"select_your_server": "Select Your Server",
|
||||
"add_server_to_get_started": "Add a server to get started",
|
||||
"add_server_to_get_started": "Füge einen Server hinzu, um loszulegen",
|
||||
"add_server": "Add Server",
|
||||
"change_server": "Change Server"
|
||||
},
|
||||
@@ -95,7 +95,7 @@
|
||||
"oops": "Ups!",
|
||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"continue": "Continue",
|
||||
"continue": "Weiter",
|
||||
"next_up": "Als nächstes",
|
||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
@@ -121,9 +121,9 @@
|
||||
"log_out_button": "Abmelden",
|
||||
"switch_user": {
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
"account": "Benutzerkonto",
|
||||
"switch_user": "Switch User on This Server",
|
||||
"current": "current"
|
||||
"current": "aktuell"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien"
|
||||
@@ -143,9 +143,9 @@
|
||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||
"theme_music": "Theme Music",
|
||||
"display_size": "Display Size",
|
||||
"display_size_small": "Small",
|
||||
"display_size_default": "Default",
|
||||
"display_size_large": "Large",
|
||||
"display_size_small": "Klein",
|
||||
"display_size_default": "Standard",
|
||||
"display_size_large": "Groß",
|
||||
"display_size_extra_large": "Extra Large"
|
||||
},
|
||||
"network": {
|
||||
@@ -188,7 +188,7 @@
|
||||
"authorize_button": "Quick Connect autorisieren",
|
||||
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
|
||||
"success": "Erfolgreich verbunden",
|
||||
"quick_connect_autorized": "Quick Connect autorisiert",
|
||||
"quick_connect_authorized": "Quick Connect autorisiert",
|
||||
"error": "Fehler",
|
||||
"invalid_code": "Ungültiger Code",
|
||||
"authorize": "Autorisieren"
|
||||
@@ -203,8 +203,8 @@
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
"cache_auto": "Auto",
|
||||
"cache_yes": "Enabled",
|
||||
"cache_no": "Disabled",
|
||||
"cache_yes": "Aktiviert",
|
||||
"cache_no": "Deaktiviert",
|
||||
"buffer_duration": "Buffer Duration",
|
||||
"max_cache_size": "Max Cache Size",
|
||||
"max_backward_cache": "Max Backward Cache"
|
||||
@@ -212,7 +212,7 @@
|
||||
"vo_driver": {
|
||||
"title": "Video Output",
|
||||
"vo_mode": "VO Driver",
|
||||
"gpu_next": "gpu-next (Recommended)",
|
||||
"gpu_next": "gpu-next (empfohlen)",
|
||||
"gpu": "gpu"
|
||||
},
|
||||
"gesture_controls": {
|
||||
@@ -261,79 +261,23 @@
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungene"
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"bold_text": "Fettgedruckter Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Rotbraun",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Magenta",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
"Teal": "Türkis",
|
||||
"Lime": "Hellgrün",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Marineblau",
|
||||
"Blue": "Blau",
|
||||
"Aqua": "Himmelblau"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Keine",
|
||||
"Thin": "Dünn",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Untertitelfarbe",
|
||||
"subtitle_background_color": "Hintergrundfarbe",
|
||||
"subtitle_font": "Untertitel-Schriftart",
|
||||
"ksplayer_title": "KSPlayer Einstellungen",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_hint": "Geben Sie Ihren OpenSubtitles API-Schlüssel ein, um die Client-seitige Untertitelsuche als Fallback zu aktivieren, wenn Ihr Jellyfin-Server keinen Untertitelanbieter konfiguriert hat.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"opensubtitles_api_key_placeholder": "API-Schüssel eingeben ...",
|
||||
"opensubtitles_get_key": "Holen Sie sich Ihren kostenlosen API-Schlüssel unter opensubtitles.com/de/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
"left": "Links",
|
||||
"center": "Mittig",
|
||||
"right": "Rechts",
|
||||
"top": "Oben",
|
||||
"bottom": "Unten"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Untertitel-Einstellungen",
|
||||
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
|
||||
"text_color": "Schriftfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"bold": "Fettgedruckter Text",
|
||||
"margin": "Unterer Abstand"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videoplayer",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Sonstiges",
|
||||
"video_orientation": "Videoausrichtung",
|
||||
@@ -351,15 +295,10 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Videoplayer",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität",
|
||||
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"playback_title": "Wiedergabe",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||
},
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
"saved": "Gespeichert"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats aktivieren",
|
||||
"disable_streamystats": "Streamystats deaktivieren",
|
||||
"enable_search": "Zum Suchen verwenden",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||
"save_button": "Speichern",
|
||||
"save": "Gespeichert",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Startseitenbereiche",
|
||||
"enable_movie_recommendations": "Filmempfehlungen",
|
||||
"enable_series_recommendations": "Serienempfehlungen",
|
||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||
"watchlist_button": "Merklisten-Integration umschalten"
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -455,9 +384,10 @@
|
||||
"device_usage": "Gerät {{availableSpace}}%",
|
||||
"size_used": "{{used}} von {{total}} genutzt",
|
||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Musik-Cache",
|
||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
||||
"enable_music_cache": "Musik-Cache aktivieren",
|
||||
"clear_music_cache": "Musik-Cache leeren",
|
||||
"music_cache_size": "{{size}} gechached",
|
||||
"music_cache_cleared": "Musik-Cache geleert",
|
||||
@@ -466,10 +396,8 @@
|
||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
"clear_all_cache_confirm_desc": "Sind Sie sicher, dass Sie alle zwischengespeicherten Daten löschen möchten? Dadurch werden alle zwischengespeicherten Bilder, Musikdateien, Untertitel und Abfrage-Caches gelöscht. Ihre Einstellungen und Login-Sitzung werden beibehalten.",
|
||||
"clear_all_cache_error_desc": "Beim Löschen des Caches ist ein Fehler aufgetreten."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Einführung",
|
||||
@@ -490,23 +418,20 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"title": "Sicherheit",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
"disabled": "Deaktiviert",
|
||||
"1_minute": "1 Minute",
|
||||
"5_minutes": "5 Minuten",
|
||||
"15_minutes": "15 Minuten",
|
||||
"30_minutes": "30 Minuten",
|
||||
"1_hour": "1 Stunde",
|
||||
"4_hours": "4 Stunden",
|
||||
"24_hours": "24 Stunden"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -518,10 +443,7 @@
|
||||
"downloads_title": "Downloads",
|
||||
"series": "Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||
"delete_all_movies_button": "Alle Filme löschen",
|
||||
"delete_all_series_button": "Alle Serien löschen",
|
||||
@@ -546,13 +468,8 @@
|
||||
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
"download_cancelled": "Download abgebrochen",
|
||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
||||
"download_paused": "Download pausiert",
|
||||
"could_not_pause_download": "Download konnte nicht angehalten werden",
|
||||
"download_resumed": "Download fortgesetzt",
|
||||
"could_not_resume_download": "Download konnte nicht fortgesetzt werden",
|
||||
"download_completed": "Download abgeschlossen",
|
||||
"download_failed": "Download fehlgeschlagen",
|
||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||
@@ -562,17 +479,13 @@
|
||||
"item_already_downloading": "{{item}} Lädt",
|
||||
"all_files_deleted": "Alle Downloads gelöscht",
|
||||
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||
"go_to_downloads": "Zu Downloads gehen",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Auswählen",
|
||||
"no_trailer_available": "Kein Trailer verfügbar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -583,16 +496,17 @@
|
||||
"none": "Keine",
|
||||
"track": "Spur",
|
||||
"cancel": "Abbrechen",
|
||||
"stop": "Stop",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"remove": "Entfernen",
|
||||
"next": "Weiter",
|
||||
"back": "Zurück",
|
||||
"continue": "Fortsetzen",
|
||||
"verifying": "Verifiziere...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"login": "Anmelden",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Suchen...",
|
||||
@@ -641,7 +555,7 @@
|
||||
"movies": "Filme",
|
||||
"series": "Serien",
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Playlists",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"items": "Elemente"
|
||||
},
|
||||
"options": {
|
||||
@@ -653,7 +567,7 @@
|
||||
"cover": "Cover",
|
||||
"show_titles": "Titel anzeigen",
|
||||
"show_stats": "Statistiken anzeigen",
|
||||
"options_title": "Options"
|
||||
"options_title": "Optionen"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Genres",
|
||||
@@ -662,10 +576,10 @@
|
||||
"filter_by": "Filtern nach",
|
||||
"sort_order": "Sortierreihenfolge",
|
||||
"tags": "Tags",
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
"all": "Alle",
|
||||
"reset": "Zurücksetzen",
|
||||
"asc": "Aufsteigend",
|
||||
"desc": "Absteigend"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -686,15 +600,11 @@
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Fehler",
|
||||
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||
"an_error_occurred_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||
"client_error": "Client-Fehler",
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
"next_episode": "Nächste Episode",
|
||||
"refresh_tracks": "Spuren aktualisieren",
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsetzen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||
@@ -702,34 +612,34 @@
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nein",
|
||||
"downloaded_file_cancel": "Abbrechen",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"swipe_down_settings": "Für Einstellungen nach unten wischen",
|
||||
"ends_at": "Endet um {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_tracks": "Titel",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"download": "Herunterladen",
|
||||
"subtitle_download_hint": "Heruntergeladene Untertitel werden in Ihrer Bibliothek gespeichert",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"language": "Sprache",
|
||||
"results": "Ergebnisse",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||
"add_opensubtitles_key_hint": "OpenSubtitles API-Schlüssel in den Einstellungen für Client-seitigen Fallback hinzufügen",
|
||||
"settings": "Einstellungen",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"stopPlayingTitle": "Wiedergabe von \"{{title}}\" beenden?",
|
||||
"stopPlayingConfirm": "Bist du sicher, dass du die Wiedergabe beenden möchtest?",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
"title": "Kapitel",
|
||||
"chapter_number": "Kapitel {{number}}",
|
||||
"open": "Kapitel öffnen",
|
||||
"close": "Kapitel schließen"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
@@ -754,20 +664,19 @@
|
||||
"quality": "Qualität",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
"label": "Untertitel",
|
||||
"none": "Keine",
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"left": "übrig",
|
||||
"director": "Regisseur*in",
|
||||
"cast": "Besetzung",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Erschien in",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"movies": "Filme",
|
||||
"shows": "Serien",
|
||||
"could_not_load_item": "Konnte Element nicht laden",
|
||||
"none": "Keine",
|
||||
"download": {
|
||||
@@ -782,9 +691,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"resume_playback_description": "Möchtest du dort fortfahren, wo du aufgehört hast oder von Anfang anfangen?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Weiter ab {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Nächste",
|
||||
@@ -796,16 +706,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Für Kinder",
|
||||
"news": "Nachrichten",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"page_of": "Seite {{current}} von {{total}}",
|
||||
"no_programs": "Keine Programme verfügbar",
|
||||
"no_channels": "Keine Kanäle verfügbar",
|
||||
"tabs": {
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
"programs": "Programme",
|
||||
"guide": "Führer",
|
||||
"channels": "Kanäle",
|
||||
"recordings": "Aufzeichnungen",
|
||||
"schedule": "Planung",
|
||||
"series": "Serien"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -851,12 +761,12 @@
|
||||
"decline": "Ablehnen",
|
||||
"requested_by": "Angefragt von {{user}}",
|
||||
"unknown_user": "Unbekannter Nutzer",
|
||||
"select": "Select",
|
||||
"select": "Auswählen",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"n_selected": "{{count}} ausgewählt",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||
@@ -877,7 +787,7 @@
|
||||
"library": "Bibliothek",
|
||||
"custom_links": "Links",
|
||||
"favorites": "Favoriten",
|
||||
"settings": "Settings"
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
},
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recently_played": "Vor kurzem gehört",
|
||||
"frequently_played": "Oft gehört",
|
||||
"explore": "Entdecken",
|
||||
"top_tracks": "Top-Titel",
|
||||
"play": "Abspielen",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1004,34 +910,33 @@
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"title": "Mit TV koppeln",
|
||||
"align_qr": "Den QR-Code innerhalb des Rahmens ausrichten",
|
||||
"enter_code_manually": "Code manuell eingeben",
|
||||
"pairing_enter_credentials": "Anmeldedaten für TV eingeben",
|
||||
"pairing_code_label": "Kopplungscode",
|
||||
"server": "Server",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"authorize_button": "Autorisieren",
|
||||
"authorizing": "Autorisieren...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Done",
|
||||
"done": "Fertig",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"pairing_tv_connecting": "Der Fernseher verbindet sich mit Ihrem Konto",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
"error_invalid_qr": "Ungültiger QR-Code. Bitte scannen Sie den TV-Kopplungscode.",
|
||||
"error_generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"error_permission_denied": "Kameraberechtigung erforderlich zum Scannen von QR-Codes.",
|
||||
"login_as": "Als {{username}} anmelden?",
|
||||
"on_server": "auf {{server}}",
|
||||
"use_different_user": "Verwende einen anderen Benutzer",
|
||||
"open_settings": "Einstellungen öffnen"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
"waiting_for_phone": "Warte auf Telefon...",
|
||||
"scan_with_phone": "Scanne mit der Streamyfin-App auf deinem Handy",
|
||||
"logging_in": "Anmeldung...",
|
||||
"logging_in_description": "Verbinde mit deinem Server"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user