mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-02 10:32:50 +01:00
Compare commits
24 Commits
renovate/p
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f31cd2b32 | ||
|
|
faa250bfdd | ||
|
|
28a75a2b8c | ||
|
|
aa0eb0a655 | ||
|
|
286a3cad47 | ||
|
|
97b6a912e0 | ||
|
|
cc0007926d | ||
|
|
9e29305e28 | ||
|
|
ae9c05637b | ||
|
|
f820bedf6e | ||
|
|
47c5d61f28 | ||
|
|
517bc7bbb5 | ||
|
|
b256e99fc8 | ||
|
|
e660b98871 | ||
|
|
ce66f0256e | ||
|
|
2ec6594462 | ||
|
|
18f01fa4ab | ||
|
|
df56d62acd | ||
|
|
872d14786e | ||
|
|
5bf07b4798 | ||
|
|
c3dceedad0 | ||
|
|
bf3dc4a366 | ||
|
|
3e81291843 | ||
|
|
7703a1c76f |
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
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
128
.github/workflows/artifact-comment.yml
vendored
128
.github/workflows/artifact-comment.yml
vendored
@@ -144,7 +144,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
.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
|
// Log current status of each build for debugging
|
||||||
buildRuns.forEach(run => {
|
buildRuns.forEach(run => {
|
||||||
@@ -184,21 +184,35 @@ jobs:
|
|||||||
const latestAndroidRun = findBestRun('Android APK Build');
|
const latestAndroidRun = findBestRun('Android APK Build');
|
||||||
const latestIOSRun = findBestRun('iOS IPA 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
|
// For the consolidated workflow, get individual job statuses
|
||||||
if (latestAppsRun) {
|
if (latestAppsRun) {
|
||||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
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 {
|
try {
|
||||||
// Get all jobs for this workflow run
|
// Get all jobs for this workflow run
|
||||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
@@ -229,9 +243,7 @@ jobs:
|
|||||||
|
|
||||||
// Create individual status for each job
|
// Create individual status for each job
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||||
const job = jobs.jobs.find(j =>
|
const job = findJobForTarget(jobs.jobs, jobNames);
|
||||||
jobNames.some(name => j.name.includes(name) || j.name === name)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (job) {
|
if (job) {
|
||||||
buildStatuses[platform] = {
|
buildStatuses[platform] = {
|
||||||
@@ -358,6 +370,43 @@ jobs:
|
|||||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
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
|
// Build comment body with progressive status for individual builds
|
||||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
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
|
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 = [
|
const buildTargets = [
|
||||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
{ 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: '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: '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 }
|
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -407,9 +456,7 @@ jobs:
|
|||||||
let durationInfo = '';
|
let durationInfo = '';
|
||||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||||
const durationMin = Math.floor(durationMs / 60000);
|
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
||||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
|
||||||
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||||
@@ -421,10 +468,16 @@ jobs:
|
|||||||
downloadLink = '*Build cancelled*';
|
downloadLink = '*Build cancelled*';
|
||||||
} else if (matchingStatus.status === 'in_progress') {
|
} else if (matchingStatus.status === 'in_progress') {
|
||||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
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') {
|
} else if (matchingStatus.status === 'queued') {
|
||||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
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) {
|
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||||
// Workflow completed but conclusion not yet available (rare edge case)
|
// Workflow completed but conclusion not yet available (rare edge case)
|
||||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||||
@@ -445,26 +498,27 @@ jobs:
|
|||||||
|
|
||||||
commentBody += `\n`;
|
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) {
|
if (allArtifacts.length > 0) {
|
||||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\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 += `- **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`;
|
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 {
|
} else {
|
||||||
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
|
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
59
.github/workflows/build-apps.yml
vendored
59
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
# run (artifacts + logs) without needing Expo access.
|
# run (artifacts + logs) without needing Expo access.
|
||||||
@@ -27,6 +27,7 @@ jobs:
|
|||||||
name: 🤖 Build Android APK (Phone)
|
name: 🤖 Build Android APK (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
@@ -42,7 +43,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -56,7 +57,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -72,13 +73,13 @@ jobs:
|
|||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
# 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
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -91,7 +92,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -117,12 +118,16 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 🔄 Refresh PR build comment
|
||||||
|
uses: ./.github/actions/refresh-pr-comment
|
||||||
|
|
||||||
build-android-tv:
|
build-android-tv:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
name: 🤖 Build Android APK (TV)
|
name: 🤖 Build Android APK (TV)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
@@ -138,7 +143,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -152,7 +157,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -168,13 +173,13 @@ jobs:
|
|||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
# 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
|
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches/modules-2
|
||||||
@@ -187,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -212,16 +217,20 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 🔄 Refresh PR build comment
|
||||||
|
uses: ./.github/actions/refresh-pr-comment
|
||||||
|
|
||||||
build-ios-phone:
|
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'))
|
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
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -235,7 +244,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -280,16 +289,20 @@ jobs:
|
|||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 🔄 Refresh PR build comment
|
||||||
|
uses: ./.github/actions/refresh-pr-comment
|
||||||
|
|
||||||
build-ios-phone-unsigned:
|
build-ios-phone-unsigned:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -303,7 +316,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -339,6 +352,9 @@ jobs:
|
|||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 🔄 Refresh PR build comment
|
||||||
|
uses: ./.github/actions/refresh-pr-comment
|
||||||
|
|
||||||
build-ios-tv:
|
build-ios-tv:
|
||||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
||||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
||||||
@@ -349,10 +365,11 @@ jobs:
|
|||||||
name: 🍎 Build tvOS IPA
|
name: 🍎 Build tvOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -366,7 +383,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -418,10 +435,11 @@ jobs:
|
|||||||
name: 🍎 Build tvOS IPA (Unsigned)
|
name: 🍎 Build tvOS IPA (Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -435,7 +453,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -470,3 +488,6 @@ jobs:
|
|||||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 🔄 Refresh PR build comment
|
||||||
|
uses: ./.github/actions/refresh-pr-comment
|
||||||
|
|||||||
4
.github/workflows/check-lockfile.yml
vendored
4
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
2
.github/workflows/ci-codeql.yml
vendored
2
.github/workflows/ci-codeql.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
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:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
4
.github/workflows/detect-duplicate.yml
vendored
4
.github/workflows/detect-duplicate.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
8
.github/workflows/linting.yml
vendored
8
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
# renovate: datasource=node-version depName=node versioning=node
|
||||||
node-version: "24.16.0"
|
node-version: "24.18.0"
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
@@ -184,7 +184,7 @@ jobs:
|
|||||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
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
|
security-events: write # upload SARIF to code scanning
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- 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
|
# Trivy's own action caches the vulnerability DB + binary internally
|
||||||
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
# 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
|
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
|
||||||
/iosmobile
|
|
||||||
/androidmobile
|
|
||||||
/androidtv
|
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js)
|
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use functional React components with hooks
|
- Use functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
// Registers the tsx require hook so the TypeScript config plugins referenced
|
||||||
|
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
||||||
|
import "tsx/cjs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
const git = (args) => {
|
const git = (args: string[]): string | null => {
|
||||||
try {
|
try {
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
@@ -42,16 +46,16 @@ const buildMeta = {
|
|||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ({ config }) => {
|
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins?.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
cameraPermission:
|
cameraPermission:
|
||||||
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
const androidConfig = {};
|
const androidConfig: { googleServicesFile?: string } = {};
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
|
|||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
};
|
} as ExpoConfig;
|
||||||
};
|
};
|
||||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.ts",
|
||||||
"./plugins/withTVUserManagement.js",
|
"./plugins/withTVUserManagement.ts",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -134,17 +134,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
["./plugins/with-runtime-framework-headers.ts"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||||
["./plugins/withAndroidAlertColors.js"],
|
["./plugins/withAndroidAlertColors.ts"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.ts"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.ts"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.ts"],
|
||||||
["./plugins/withTVOSAppIcon.js"],
|
["./plugins/withTVOSAppIcon.ts"],
|
||||||
["./plugins/withTVOSTopShelf.js"],
|
["./plugins/withTVOSTopShelf.ts"],
|
||||||
["./plugins/withTVXcodeEnv.js"],
|
["./plugins/withTVXcodeEnv.ts"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.ts",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
@@ -33,13 +33,16 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
AudioTranscodeMode,
|
AudioTranscodeMode,
|
||||||
|
getActiveVideoPlayer,
|
||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
|
VideoPlayer,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import {
|
import {
|
||||||
getPreviousServers,
|
getPreviousServers,
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
@@ -262,6 +265,25 @@ export default function SettingsTV() {
|
|||||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||||
const currentLanguage = settings.preferedLanguage;
|
const currentLanguage = settings.preferedLanguage;
|
||||||
|
|
||||||
|
// Video player selection. MPV is the default; ExoPlayer is only offered
|
||||||
|
// as an opt-in alternative on Android TV. The selector is hidden on
|
||||||
|
// other platforms.
|
||||||
|
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
|
||||||
|
const currentVideoPlayer = getActiveVideoPlayer(settings);
|
||||||
|
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
|
||||||
|
|
||||||
|
// Shared style for the ExoPlayer / MPV limitation notes shown under the
|
||||||
|
// selector when the respective player is active. All pixel values scaled
|
||||||
|
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
|
||||||
|
const playerNoteStyle = {
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginTop: scaleSize(4),
|
||||||
|
marginBottom: scaleSize(12),
|
||||||
|
marginLeft: scaleSize(8),
|
||||||
|
marginRight: scaleSize(8),
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Audio transcoding options
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -391,6 +413,23 @@ export default function SettingsTV() {
|
|||||||
[t, currentVoDriver],
|
[t, currentVoDriver],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Video player backend options (Android TV only)
|
||||||
|
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.video_player.exoplayer"),
|
||||||
|
value: VideoPlayer.ExoPlayer,
|
||||||
|
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.video_player.mpv"),
|
||||||
|
value: VideoPlayer.MPV,
|
||||||
|
selected: currentVideoPlayer === VideoPlayer.MPV,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentVideoPlayer],
|
||||||
|
);
|
||||||
|
|
||||||
// Typography scale options
|
// Typography scale options
|
||||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -522,6 +561,11 @@ export default function SettingsTV() {
|
|||||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||||
}, [voDriverOptions, t]);
|
}, [voDriverOptions, t]);
|
||||||
|
|
||||||
|
const videoPlayerLabel = useMemo(() => {
|
||||||
|
const option = videoPlayerOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "MPV";
|
||||||
|
}, [videoPlayerOptions]);
|
||||||
|
|
||||||
const languageLabel = useMemo(() => {
|
const languageLabel = useMemo(() => {
|
||||||
if (!currentLanguage) return t("home.settings.languages.system");
|
if (!currentLanguage) return t("home.settings.languages.system");
|
||||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||||
@@ -586,6 +630,34 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
{/* Audio Section */}
|
{/* Audio Section */}
|
||||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
|
||||||
|
{/* Video Player selector — Android TV only */}
|
||||||
|
{isAndroidTv && (
|
||||||
|
<>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.video_player.title")}
|
||||||
|
value={videoPlayerLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.video_player.title"),
|
||||||
|
options: videoPlayerOptions,
|
||||||
|
onSelect: (value) => updateSettings({ videoPlayer: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!isMpv && (
|
||||||
|
<Text style={playerNoteStyle}>
|
||||||
|
{t("home.settings.video_player.exoplayer_note")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isMpv && (
|
||||||
|
<Text style={playerNoteStyle}>
|
||||||
|
{t("home.settings.video_player.mpv_note")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.audio.transcode_mode.title")}
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
value={audioTranscodeLabel}
|
value={audioTranscodeLabel}
|
||||||
@@ -662,20 +734,23 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
{isMpv && (
|
||||||
label='Horizontal Alignment'
|
<TVSettingsOptionButton
|
||||||
value={alignXLabel}
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
onPress={() =>
|
value={alignXLabel}
|
||||||
showOptions({
|
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||||
title: "Horizontal Alignment",
|
onPress={() =>
|
||||||
options: alignXOptions,
|
showOptions({
|
||||||
onSelect: (value) =>
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
updateSettings({
|
options: alignXOptions,
|
||||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
onSelect: (value) =>
|
||||||
}),
|
updateSettings({
|
||||||
})
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
}
|
}),
|
||||||
/>
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
@@ -748,19 +823,24 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Output Section */}
|
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
||||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
{isMpv && (
|
||||||
<TVSettingsOptionButton
|
<>
|
||||||
label={t("home.settings.vo_driver.vo_mode")}
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
value={voDriverLabel}
|
<TVSettingsOptionButton
|
||||||
onPress={() =>
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
showOptions({
|
value={voDriverLabel}
|
||||||
title: t("home.settings.vo_driver.vo_mode"),
|
onPress={() =>
|
||||||
options: voDriverOptions,
|
showOptions({
|
||||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
title: t("home.settings.vo_driver.vo_mode"),
|
||||||
})
|
options: voDriverOptions,
|
||||||
}
|
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||||
/>
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.buffer.buffer_duration")}
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
BackHandler,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -80,8 +85,9 @@ const Page = () => {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
filterBy?: string;
|
filterBy?: string;
|
||||||
|
fromSeeAll?: string;
|
||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId, fromSeeAll } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -112,6 +118,22 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// When this library detail was opened from the home "See All" button, its
|
||||||
|
// libraries stack is just [detail], so the default TV Back would exit to home.
|
||||||
|
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
||||||
|
// route to the library list instead, so the user can switch libraries. Normal
|
||||||
|
// entries from the list keep their native pop-to-list behavior.
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!Platform.isTV || fromSeeAll !== "true") return;
|
||||||
|
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
|
router.replace("/(auth)/(tabs)/(libraries)");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [fromSeeAll, router]),
|
||||||
|
);
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
@@ -269,6 +291,23 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
|
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
||||||
|
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
||||||
|
// which the native bottom tab reliably auto-pops back to the index (the detail
|
||||||
|
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
||||||
|
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
||||||
|
const didCollapseRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
||||||
|
return;
|
||||||
|
const state = navigation.getState();
|
||||||
|
if (state?.routes && state.routes.length > 1) {
|
||||||
|
didCollapseRef.current = true;
|
||||||
|
const top = state.routes[state.routes.length - 1];
|
||||||
|
navigation.reset({ index: 0, routes: [top] } as any);
|
||||||
|
}
|
||||||
|
}, [navigation, fromSeeAll]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
|
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
||||||
|
textColor: "#fff",
|
||||||
// Android: placeholder and icon color
|
// Android: placeholder and icon color
|
||||||
hintTextColor: "#fff",
|
hintTextColor: "#fff",
|
||||||
headerIconColor: "#fff",
|
headerIconColor: "#fff",
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
||||||
|
import { TVNavBar } from "@/components/tv/TVNavBar";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
isTabRoute,
|
||||||
|
useTVHomeBackHandler,
|
||||||
|
useTVTabRootBackHandler,
|
||||||
|
} from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
|
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
||||||
|
|
||||||
|
function TVTabLayout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const currentTab = segments.find(isTabRoute);
|
||||||
|
const lastSegment = segments[segments.length - 1] ?? "";
|
||||||
|
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
|
|
||||||
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "(home)", label: t("tabs.home") },
|
||||||
|
{ key: "(search)", label: t("tabs.search") },
|
||||||
|
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||||
|
? null
|
||||||
|
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||||
|
{ key: "(libraries)", label: t("tabs.library") },
|
||||||
|
!settings?.showCustomMenuLinks
|
||||||
|
? null
|
||||||
|
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||||
|
{ key: "(settings)", label: t("tabs.settings") },
|
||||||
|
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||||
|
[
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
settings?.hideWatchlistsTab,
|
||||||
|
settings?.showCustomMenuLinks,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTabKey = currentTab ?? "(home)";
|
||||||
|
|
||||||
|
const visibleKeys = useMemo(
|
||||||
|
() => new Set(tabs.map((tab) => tab.key)),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === currentTab) return;
|
||||||
|
|
||||||
|
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||||
|
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/${key}`);
|
||||||
|
},
|
||||||
|
[currentTab, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateHome = useCallback(() => {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}, [router]);
|
||||||
|
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||||
|
|
||||||
|
// If current tab is no longer visible (setting changed), navigate to home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}
|
||||||
|
}, [visibleKeys, activeTabKey, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<SystemBars hidden={false} style='light' />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{ headerShown: false, animation: "none" }}
|
||||||
|
initialRouteName='(home)'
|
||||||
|
>
|
||||||
|
<Stack.Screen name='index' redirect />
|
||||||
|
</Stack>
|
||||||
|
<TVNavBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabKey={activeTabKey}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Handle TV back button - prevent app exit when at root
|
// Must be called before any conditional return (rules of hooks)
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
|
if (IS_ANDROID_TV) {
|
||||||
|
return <TVTabLayout />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
|
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
@@ -40,7 +41,6 @@ import {
|
|||||||
type MpvOnErrorEventPayload,
|
type MpvOnErrorEventPayload,
|
||||||
type MpvOnPlaybackStateChangePayload,
|
type MpvOnPlaybackStateChangePayload,
|
||||||
type MpvOnProgressEventPayload,
|
type MpvOnProgressEventPayload,
|
||||||
MpvPlayerView,
|
|
||||||
type MpvPlayerViewRef,
|
type MpvPlayerViewRef,
|
||||||
type MpvVideoSource,
|
type MpvVideoSource,
|
||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: generateDeviceProfile(),
|
// Match the device profile to the player that will render the
|
||||||
|
// stream so the server picks a codec/container the player can
|
||||||
|
// actually decode.
|
||||||
|
deviceProfile: generateDeviceProfile({
|
||||||
|
player: getActivePlayerType(settings),
|
||||||
|
audioMode: settings.audioTranscodeMode,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
@@ -456,10 +462,23 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||||
|
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
||||||
|
// episode's player) mounts while the old 4K decoder is still alive,
|
||||||
|
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
||||||
|
// later React unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
// Pre-libmpv-1.0 used `stop()`:
|
||||||
|
// videoRef.current?.stop();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
|
// Release the keep-awake wakelock acquired during playback so it
|
||||||
|
// doesn't follow us back to the home screen and block the TV
|
||||||
|
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
||||||
|
// and only released on the "paused" event; without this, navigating
|
||||||
|
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
||||||
|
deactivateKeepAwake();
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1105,6 +1124,15 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
||||||
|
// decoder + surface buffers are freed before the new player screen
|
||||||
|
// mounts. Without this, Expo Router briefly holds two simultaneous
|
||||||
|
// mpv instances during the transition (~768 MB of surface buffers
|
||||||
|
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
||||||
|
// devices. Native stop() is idempotent so the subsequent React
|
||||||
|
// unmount cleanup is still safe.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1115,6 +1143,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
|
videoRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1254,7 +1283,7 @@ export default function DirectPlayerPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MpvPlayerView
|
<VideoPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
@@ -100,6 +101,22 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
||||||
|
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
||||||
|
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
||||||
|
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
||||||
|
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
||||||
|
try {
|
||||||
|
Image.configureCache({
|
||||||
|
maxMemoryCost: Platform.isTV
|
||||||
|
? 8 * 1024 * 1024 // ~8 MB on TV
|
||||||
|
: 128 * 1024 * 1024, // ~128 MB on mobile
|
||||||
|
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
||||||
|
}
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
65
bun.lock
65
bun.lock
@@ -16,7 +16,7 @@
|
|||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@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/query-sync-storage-persister": "^5.100.14",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
@@ -111,8 +111,9 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -294,6 +295,58 @@
|
|||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||||
|
|
||||||
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
||||||
@@ -536,7 +589,7 @@
|
|||||||
|
|
||||||
"@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
|
"@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
|
||||||
|
|
||||||
"@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="],
|
"@shopify/flash-list": ["@shopify/flash-list@2.0.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA=="],
|
||||||
|
|
||||||
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
|
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
|
||||||
|
|
||||||
@@ -908,6 +961,8 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
@@ -1270,7 +1325,7 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="],
|
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
|
||||||
|
|
||||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||||
|
|
||||||
@@ -1808,6 +1863,8 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||||
|
// decoded ARGB) is too large to pin in the memory cache on every
|
||||||
|
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleSeeAllPress = useCallback(() => {
|
const handleSeeAllPress = useCallback(() => {
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
|
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
||||||
|
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
||||||
|
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
||||||
|
// (b) intercept Back to route to the library list so the user can switch
|
||||||
|
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
pathname: "/[libraryId]",
|
||||||
params: {
|
params: {
|
||||||
libraryId: parentId,
|
libraryId: parentId,
|
||||||
sortBy: SortByOption.DateCreated,
|
sortBy: SortByOption.DateCreated,
|
||||||
sortOrder: SortOrderOption.Descending,
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
fromSeeAll: "true",
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
@@ -326,9 +332,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={5}
|
initialNumToRender={4}
|
||||||
maxToRenderPerBatch={3}
|
maxToRenderPerBatch={2}
|
||||||
windowSize={5}
|
windowSize={3}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
|
// No fixed width: the footer must size to the "See All" card so the
|
||||||
|
// FlatList's scrollable content extends to fully reveal it. A fixed
|
||||||
|
// (narrow) width clipped the card at the right edge. Trailing space is
|
||||||
|
// provided by contentContainerStyle.paddingRight.
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: sizes.padding.horizontal,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|||||||
@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
|
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
||||||
|
// out of the memory cache avoids bloat when the user cycles through
|
||||||
|
// hero items quickly.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
@@ -379,7 +382,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
const tvosTopPadding = scaleSize(145);
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch the image before starting the crossfade
|
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl);
|
await Image.prefetch(backdropUrl, "disk");
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,8 +44,10 @@ export interface TVNextEpisodeCountdownProps {
|
|||||||
playButtonRef?: RNView | null;
|
playButtonRef?: RNView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants
|
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
||||||
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
||||||
|
// rationale (220 sits just above the controls bar; 300 floated too high).
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
|||||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
placeholder={{ blurhash }}
|
placeholder={{ blurhash }}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
|
||||||
source={{ uri: imageUrl }}
|
source={{ uri: imageUrl }}
|
||||||
|
recyclingKey={item.Id}
|
||||||
cachePolicy='memory-disk'
|
cachePolicy='memory-disk'
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
|
|||||||
playButtonRef?: View | null;
|
playButtonRef?: View | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
||||||
const BOTTOM_WITH_CONTROLS = 300;
|
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
||||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
// card that floats far above the controls.
|
||||||
|
//
|
||||||
|
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
|
||||||
|
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
|
||||||
|
// left the card hovering ~100px above the controls.
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
|
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||||
|
export { TVNavBar } from "./TVNavBar";
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
30
components/video-player/VideoPlayerView.tsx
Normal file
30
components/video-player/VideoPlayerView.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
|
||||||
|
import { MpvPlayerView } from "@/modules";
|
||||||
|
import { ExoPlayerView } from "@/modules/exoplayer-player";
|
||||||
|
import {
|
||||||
|
getActiveVideoPlayer,
|
||||||
|
useSettings,
|
||||||
|
VideoPlayer,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified video player view. MPV is the default on every platform; users
|
||||||
|
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
|
||||||
|
* children conform to the same `MpvPlayerViewRef` interface, so the ref
|
||||||
|
* is forwarded transparently regardless of which player is rendered.
|
||||||
|
*
|
||||||
|
* The Android-TV capability gate lives in getActiveVideoPlayer so that
|
||||||
|
* the same resolver used for device-profile advertisement guarantees the
|
||||||
|
* rendered backend matches what Jellyfin was told to stream for.
|
||||||
|
*/
|
||||||
|
export const VideoPlayerView = React.forwardRef<
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
MpvPlayerViewProps
|
||||||
|
>(function VideoPlayerView(props, ref) {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||||
|
|
||||||
|
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||||
|
return <Player ref={ref} {...props} />;
|
||||||
|
});
|
||||||
@@ -1129,7 +1129,16 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Skip intro card */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={skipIntro}
|
onPress={() => {
|
||||||
|
// After the seek lands, showSkipButton flips false and this card
|
||||||
|
// unmounts. With controls visible the focus-stealing overlay is
|
||||||
|
// disabled, so without an explicit handoff the focus engine is
|
||||||
|
// stranded. Prime the play button to receive focus on the next
|
||||||
|
// render — when controls are hidden the focus overlay takes over
|
||||||
|
// naturally and this is a harmless no-op.
|
||||||
|
if (showControls) setFocusPlayButton(true);
|
||||||
|
skipIntro();
|
||||||
|
}}
|
||||||
type='intro'
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
@@ -1144,7 +1153,11 @@ export const Controls: FC<Props> = ({
|
|||||||
(hasContentAfterCredits || !nextItem) &&
|
(hasContentAfterCredits || !nextItem) &&
|
||||||
!isCountdownActive
|
!isCountdownActive
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={() => {
|
||||||
|
// See the intro card above for the focus-handoff rationale.
|
||||||
|
if (showControls) setFocusPlayButton(true);
|
||||||
|
skipCredit();
|
||||||
|
}}
|
||||||
type='credits'
|
type='credits'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
|
|||||||
@@ -213,13 +213,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
container: mediaSource.Container,
|
|
||||||
videoRange: videoStream?.VideoRangeType,
|
videoRange: videoStream?.VideoRangeType,
|
||||||
bitDepth: videoStream?.BitDepth,
|
bitDepth: videoStream?.BitDepth,
|
||||||
audioChannels: audioStream?.Channels,
|
audioChannels: audioStream?.Channels,
|
||||||
audioCodecFromSource: audioStream?.Codec,
|
|
||||||
subtitleCodec: subtitleStream?.Codec,
|
subtitleCodec: subtitleStream?.Codec,
|
||||||
subtitleTitle: subtitleStream?.DisplayTitle,
|
|
||||||
};
|
};
|
||||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||||
|
|
||||||
@@ -305,9 +302,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{info.videoWidth}x{info.videoHeight}
|
{info.videoWidth}x{info.videoHeight}
|
||||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||||
{formatVideoRange(streamInfo?.videoRange)
|
{/* Prefer the player-reported HDR format (authoritative —
|
||||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
what's actually being decoded) over Jellyfin metadata. */}
|
||||||
: ""}
|
{info?.hdrFormat
|
||||||
|
? ` ${info.hdrFormat}`
|
||||||
|
: (() => {
|
||||||
|
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
||||||
|
return videoRange ? ` ${videoRange}` : "";
|
||||||
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
@@ -319,8 +321,17 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{/* Prefer player-reported channel count; fall back to
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
Jellyfin metadata for MPV which doesn't populate it. */}
|
||||||
|
{(() => {
|
||||||
|
const audioChannels =
|
||||||
|
info.audioChannels ?? streamInfo?.audioChannels;
|
||||||
|
return audioChannels
|
||||||
|
? ` ${formatAudioChannels(audioChannels)}`
|
||||||
|
: "";
|
||||||
|
})()}
|
||||||
|
{info.audioSampleRate
|
||||||
|
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -339,9 +350,26 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Color:{" "}
|
||||||
|
{[info.colorSpace, info.colorRange, info.colorTransfer]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{info?.videoCodecs && (
|
||||||
|
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
|
||||||
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
|
{info?.demuxerMaxBytes !== undefined
|
||||||
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
|
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
||||||
|
")"
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
@@ -350,6 +378,18 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{info?.decoderName && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Decoder: {info.decoderName}
|
||||||
|
{info.decoderType ? ` (${info.decoderType})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{info?.estimatedVfFps !== undefined && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||||
|
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
Dropped: {info.droppedFrames} frames
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
export default {
|
const MediaTypes = {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||||
|
|
||||||
|
export default MediaTypes;
|
||||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
|||||||
|
|
||||||
Relevant files:
|
Relevant files:
|
||||||
|
|
||||||
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
||||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||||
|
|||||||
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
|
|||||||
import {
|
import {
|
||||||
disableTVMenuKeyInterception,
|
disableTVMenuKeyInterception,
|
||||||
enableTVMenuKeyInterception,
|
enableTVMenuKeyInterception,
|
||||||
|
useTVBackPress,
|
||||||
} from "./useTVBackPress";
|
} from "./useTVBackPress";
|
||||||
|
|
||||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||||
|
|
||||||
|
/** All tab route names used in the bottom tab navigator. */
|
||||||
|
export const TAB_ROUTES = [
|
||||||
|
"(home)",
|
||||||
|
"(search)",
|
||||||
|
"(favorites)",
|
||||||
|
"(libraries)",
|
||||||
|
"(watchlists)",
|
||||||
|
"(custom-links)",
|
||||||
|
"(settings)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TabRoute = (typeof TAB_ROUTES)[number];
|
||||||
|
|
||||||
|
/** Check if a segment string is a tab route. */
|
||||||
|
export function isTabRoute(s: string): s is TabRoute {
|
||||||
|
return (TAB_ROUTES as readonly string[]).includes(s);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're at the root of a tab
|
* Check if we're at the root of a tab
|
||||||
*/
|
*/
|
||||||
function isAtTabRoot(segments: string[]): boolean {
|
function isAtTabRoot(segments: string[]): boolean {
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
const tabNames = [
|
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
"(home)",
|
|
||||||
"(search)",
|
|
||||||
"(favorites)",
|
|
||||||
"(libraries)",
|
|
||||||
"(watchlists)",
|
|
||||||
"(settings)",
|
|
||||||
"(custom-links)",
|
|
||||||
];
|
|
||||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current tab name from segments
|
* Get the current tab name from segments
|
||||||
*/
|
*/
|
||||||
function getCurrentTab(segments: string[]): string | undefined {
|
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||||
return segments.find(
|
return segments.find(isTabRoute);
|
||||||
(s) =>
|
|
||||||
s === "(home)" ||
|
|
||||||
s === "(search)" ||
|
|
||||||
s === "(favorites)" ||
|
|
||||||
s === "(libraries)" ||
|
|
||||||
s === "(watchlists)" ||
|
|
||||||
s === "(settings)" ||
|
|
||||||
s === "(custom-links)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
|
|||||||
export function useTVHomeBackHandler() {
|
export function useTVHomeBackHandler() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
// Get current state
|
|
||||||
const currentTab = getCurrentTab(segments);
|
const currentTab = getCurrentTab(segments);
|
||||||
const atTabRoot = isAtTabRoot(segments);
|
const atTabRoot = isAtTabRoot(segments);
|
||||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||||
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
|
|||||||
enableTVMenuKeyInterception();
|
enableTVMenuKeyInterception();
|
||||||
}, [isOnHomeRoot]);
|
}, [isOnHomeRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
||||||
|
*
|
||||||
|
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
||||||
|
* built-in tab-level back handling — pressing back at a tab root would pop the
|
||||||
|
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
||||||
|
* to Home instead.
|
||||||
|
*/
|
||||||
|
export function useTVTabRootBackHandler(
|
||||||
|
onNavigateHome: () => void,
|
||||||
|
isAtTabRoot: boolean,
|
||||||
|
currentTab: string | undefined,
|
||||||
|
) {
|
||||||
|
useTVBackPress(() => {
|
||||||
|
if (!Platform.isTV || Platform.OS !== "android") return false;
|
||||||
|
if (!isAtTabRoot || currentTab === "(home)") return false;
|
||||||
|
onNavigateHome();
|
||||||
|
return true;
|
||||||
|
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.content.pm.ServiceInfo
|
|||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@@ -27,6 +28,7 @@ class DownloadService : Service() {
|
|||||||
private var currentDownloadTitle = "Preparing download..."
|
private var currentDownloadTitle = "Preparing download..."
|
||||||
private var currentProgress = 0
|
private var currentProgress = 0
|
||||||
private var isForegroundStarted = false
|
private var isForegroundStarted = false
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
inner class DownloadServiceBinder : Binder() {
|
inner class DownloadServiceBinder : Binder() {
|
||||||
fun getService(): DownloadService = this@DownloadService
|
fun getService(): DownloadService = this@DownloadService
|
||||||
@@ -36,6 +38,12 @@ class DownloadService : Service() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d(TAG, "DownloadService created")
|
Log.d(TAG, "DownloadService created")
|
||||||
createNotificationChannel()
|
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 {
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
@@ -93,6 +101,8 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
wakeLock?.let { if (it.isHeld) it.release() }
|
||||||
|
Log.d(TAG, "Wake lock released")
|
||||||
Log.d(TAG, "DownloadService destroyed")
|
Log.d(TAG, "DownloadService destroyed")
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|||||||
68
modules/exoplayer-player/android/build.gradle
Normal file
68
modules/exoplayer-player/android/build.gradle
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.exoplayerplayer'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.exoplayerplayer"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Media3 (ExoPlayer). The default tracks react-native-track-player's
|
||||||
|
// pinned version (currently 1.10.1) so we don't end up with two media3
|
||||||
|
// versions on the classpath and duplicate-class errors. The
|
||||||
|
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
|
||||||
|
// globally via plugins/withExcludeMedia3Dash.js.
|
||||||
|
def media3Version = safeExtGet('media3Version', '1.10.1')
|
||||||
|
implementation "androidx.media3:media3-exoplayer:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-ui:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-common:${media3Version}"
|
||||||
|
|
||||||
|
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
|
||||||
|
// codecs that MediaCodec doesn't ship with on most Android TVs.
|
||||||
|
//
|
||||||
|
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
|
||||||
|
// with prebuilt native libraries (the upstream androidx artifact is a
|
||||||
|
// stub that requires building FFmpeg yourself). RNTP already pulls
|
||||||
|
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
|
||||||
|
// it guarantees we still get it if RNTP ever drops the dep.
|
||||||
|
//
|
||||||
|
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
|
||||||
|
// resolve media3 core to 1.10.1. RNTP ships the same combination in
|
||||||
|
// production, and Media3 maintains binary compat for Renderer /
|
||||||
|
// RenderersFactory APIs across minor versions, so this works in
|
||||||
|
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
|
||||||
|
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExoPlayerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExoPlayer")
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view.
|
||||||
|
View(ExoPlayerView::class) {
|
||||||
|
// All video load options are passed via a single "source" prop,
|
||||||
|
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
|
||||||
|
// cacheConfig fields) are silently ignored.
|
||||||
|
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
|
||||||
|
if (source == null) return@Prop
|
||||||
|
|
||||||
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||||
|
|
||||||
|
val config = VideoLoadConfig(
|
||||||
|
url = urlString,
|
||||||
|
headers = source["headers"] as? Map<String, String>,
|
||||||
|
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||||
|
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||||
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
|
||||||
|
// no Control Center equivalent — Android handles media sessions
|
||||||
|
// via MediaSessionCompat which we don't wire up for TV).
|
||||||
|
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("play") { view: ExoPlayerView ->
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pause") { view: ExoPlayerView ->
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("destroy") { view: ExoPlayerView ->
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
|
||||||
|
view.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
|
||||||
|
view.seekBy(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
|
||||||
|
view.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getSpeed") { view: ExoPlayerView ->
|
||||||
|
view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPaused") { view: ExoPlayerView ->
|
||||||
|
view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getDuration") { view: ExoPlayerView ->
|
||||||
|
view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture — TV does not use PiP; safe no-ops.
|
||||||
|
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
|
||||||
|
view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
|
||||||
|
view.addSubtitleFile(url, select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning / styling
|
||||||
|
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — SubtitleView follows authored cue alignment.
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
|
||||||
|
view.setSubtitleBorderStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
|
||||||
|
view.setSubtitleBackgroundColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — libass-specific, no Media3 equivalent.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
|
||||||
|
view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video scaling
|
||||||
|
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
|
||||||
|
view.setZoomedToFill(zoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
|
||||||
|
view.isZoomedToFill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical info
|
||||||
|
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
|
||||||
|
view.getTechnicalInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events that the view can send to JavaScript — same set as MPV.
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
|
||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.ColorInfo
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
|
||||||
|
* MPV-only fields are accepted and ignored.
|
||||||
|
*/
|
||||||
|
data class VideoLoadConfig(
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String>? = null,
|
||||||
|
val externalSubtitles: List<String>? = null,
|
||||||
|
val startPosition: Double? = null,
|
||||||
|
val autoplay: Boolean = true,
|
||||||
|
val initialSubtitleId: Int? = null,
|
||||||
|
val initialAudioId: Int? = null,
|
||||||
|
val cacheEnabled: String? = null,
|
||||||
|
val cacheSeconds: Int? = null,
|
||||||
|
val demuxerMaxBytes: Int? = null,
|
||||||
|
val demuxerMaxBackBytes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
|
||||||
|
*
|
||||||
|
* Implements the same JS contract (events, ref methods, 1-based track IDs)
|
||||||
|
* as MpvPlayerView so the React layer can swap between the two without
|
||||||
|
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
|
||||||
|
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
|
||||||
|
*/
|
||||||
|
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ExoPlayerView"
|
||||||
|
private const val PROGRESS_INTERVAL_MS = 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event dispatchers — names must match the Events() declaration in the module.
|
||||||
|
val onLoad by EventDispatcher()
|
||||||
|
val onPlaybackStateChange by EventDispatcher()
|
||||||
|
val onProgress by EventDispatcher()
|
||||||
|
val onError by EventDispatcher()
|
||||||
|
val onTracksReady by EventDispatcher()
|
||||||
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private val playerView: PlayerView
|
||||||
|
private val subtitleView: SubtitleView?
|
||||||
|
|
||||||
|
private var currentUrl: String? = null
|
||||||
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
|
private var tracksReadyFired: Boolean = false
|
||||||
|
|
||||||
|
// Side-loaded subtitle configurations accumulated across loadVideo and
|
||||||
|
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
|
||||||
|
// list on a playing MediaItem, so we shadow it here to preserve prior
|
||||||
|
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
|
||||||
|
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||||
|
|
||||||
|
// 1-based track ID mappings (matching MPV's contract).
|
||||||
|
// Each list is rebuilt on Tracks changed.
|
||||||
|
private var subtitleTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var audioTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var currentSubtitleId: Int = 0
|
||||||
|
private var currentAudioId: Int = 0
|
||||||
|
|
||||||
|
// Subtitle styling state — applied to the embedded SubtitleView.
|
||||||
|
private var subtitleScale: Float = 1f
|
||||||
|
private var subtitleFontSizePct: Int? = null // 0-100
|
||||||
|
// Last-write-wins override of the vertical position fraction
|
||||||
|
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
|
||||||
|
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
|
||||||
|
// (px) funnel into this single SubtitleView API.
|
||||||
|
private var subtitleBottomFraction: Float? = null
|
||||||
|
private var subtitleAlignY: String = "bottom"
|
||||||
|
// Background color carries its own alpha (parsed from #RRGGBBAA in
|
||||||
|
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
|
||||||
|
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
|
||||||
|
private var subtitleBorderStyle: String = "outline-and-shadow"
|
||||||
|
|
||||||
|
private var isZoomedToFill: Boolean = false
|
||||||
|
|
||||||
|
// Captured by analyticsListener; surfaced via getTechnicalInfo().
|
||||||
|
// Reset on destroy() and (for decoder names) on track changes.
|
||||||
|
private var videoDecoderName: String? = null
|
||||||
|
private var audioDecoderName: String? = null
|
||||||
|
private var cumulativeDroppedFrames: Int = 0
|
||||||
|
|
||||||
|
private val analyticsListener = object : AnalyticsListener {
|
||||||
|
override fun onVideoDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
videoDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
audioDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDroppedVideoFrames(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
droppedFrames: Int,
|
||||||
|
elapsedMs: Long,
|
||||||
|
) {
|
||||||
|
// Incremental count since last call; accumulate for a cumulative
|
||||||
|
// total that matches MPV's droppedFrames semantics.
|
||||||
|
cumulativeDroppedFrames += droppedFrames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
onPlaybackStateChange(mapOf("isLoading" to true))
|
||||||
|
}
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isLoading" to false,
|
||||||
|
"isReadyToSeek" to true
|
||||||
|
))
|
||||||
|
if (!tracksReadyFired) {
|
||||||
|
tracksReadyFired = true
|
||||||
|
rebuildTrackMaps(player?.currentTracks)
|
||||||
|
onTracksReady(emptyMap<String, Any>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to false,
|
||||||
|
"isPaused" to true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to isPlaying,
|
||||||
|
"isPaused" to !isPlaying
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
|
||||||
|
val message = error?.message ?: "Unknown playback error"
|
||||||
|
Log.e(TAG, "Player error: $message", error)
|
||||||
|
onError(mapOf("error" to message))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
rebuildTrackMaps(tracks)
|
||||||
|
applyInitialTrackSelections()
|
||||||
|
// A track change can re-initialize the codec under a different
|
||||||
|
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
|
||||||
|
// decoder names so getTechnicalInfo() doesn't report the
|
||||||
|
// previous codec until the next onVideoDecoderInitialized fires.
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val progressRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val p = player ?: return
|
||||||
|
val positionMs = p.currentPosition
|
||||||
|
val durationMs = p.duration
|
||||||
|
val bufferedMs = p.bufferedPosition
|
||||||
|
|
||||||
|
val positionSec = positionMs / 1000.0
|
||||||
|
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
|
||||||
|
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
|
||||||
|
|
||||||
|
onProgress(mapOf(
|
||||||
|
"position" to positionSec,
|
||||||
|
"duration" to durationSec,
|
||||||
|
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
|
||||||
|
"cacheSeconds" to cacheSec
|
||||||
|
))
|
||||||
|
|
||||||
|
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
|
playerView = PlayerView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
// SurfaceView-backed for parity with MPV (direct surface to
|
||||||
|
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
|
||||||
|
// explicit setSurfaceType() call is needed; the int constants
|
||||||
|
// backing it are @IntDef private in Media3.
|
||||||
|
setUseController(false)
|
||||||
|
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||||
|
}
|
||||||
|
subtitleView = playerView.subtitleView
|
||||||
|
addView(playerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Video Loading
|
||||||
|
|
||||||
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
|
if (currentUrl == config.url) return
|
||||||
|
currentUrl = config.url
|
||||||
|
pendingConfig = config
|
||||||
|
ensurePlayer(config)
|
||||||
|
loadInternal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensurePlayer(config: VideoLoadConfig) {
|
||||||
|
if (player != null) return
|
||||||
|
|
||||||
|
val loadControl = buildLoadControl(config)
|
||||||
|
|
||||||
|
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
|
||||||
|
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
|
||||||
|
// hardware decoder for the format. MediaCodec remains the fallback.
|
||||||
|
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
|
||||||
|
.setExtensionRendererMode(
|
||||||
|
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||||
|
.setLoadControl(loadControl)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build(),
|
||||||
|
/* handleAudioFocus = */ true
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
exo.addListener(playerListener)
|
||||||
|
exo.addAnalyticsListener(analyticsListener)
|
||||||
|
exo.repeatMode = Player.REPEAT_MODE_OFF
|
||||||
|
player = exo
|
||||||
|
playerView.player = exo
|
||||||
|
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
|
||||||
|
// Map MPV-style cache config to ExoPlayer's LoadControl.
|
||||||
|
val cacheEnabled = when (config.cacheEnabled) {
|
||||||
|
"no" -> false
|
||||||
|
"yes" -> true
|
||||||
|
else -> true // "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer thresholds used as fallbacks when the user's cache config
|
||||||
|
// doesn't override them. Media3's own defaults changed in 1.6.0
|
||||||
|
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
|
||||||
|
// faster start; we intentionally keep the older 2500/5000 here
|
||||||
|
// because low-RAM Android TVs with slow tuners benefit from the
|
||||||
|
// extra headroom before playback kicks in. Media3's DEFAULT_*
|
||||||
|
// IntDef fields are private, hence the literals.
|
||||||
|
val defaultMinBufferMs = 15000
|
||||||
|
val defaultBufferForPlaybackMs = 2500
|
||||||
|
val defaultBufferForPlaybackAfterRebufferMs = 5000
|
||||||
|
|
||||||
|
val targetBufferMs = if (!cacheEnabled) {
|
||||||
|
50000
|
||||||
|
} else {
|
||||||
|
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
|
||||||
|
seconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
val backBufferMs = if (!cacheEnabled) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
val mb = config.demuxerMaxBackBytes ?: 50
|
||||||
|
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
|
||||||
|
(mb * 1000).coerceAtLeast(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = DefaultLoadControl.Builder()
|
||||||
|
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
/* minBufferMs = */ defaultMinBufferMs,
|
||||||
|
/* maxBufferMs = */ targetBufferMs,
|
||||||
|
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
|
||||||
|
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
|
||||||
|
)
|
||||||
|
if (cacheEnabled) {
|
||||||
|
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInternal(config: VideoLoadConfig) {
|
||||||
|
val p = player ?: return
|
||||||
|
|
||||||
|
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
|
||||||
|
.setDefaultRequestProperties(config.headers ?: emptyMap())
|
||||||
|
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||||
|
|
||||||
|
val mediaItem = buildMediaItem(config)
|
||||||
|
|
||||||
|
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
|
.createMediaSource(mediaItem)
|
||||||
|
|
||||||
|
p.setMediaSource(mediaSource)
|
||||||
|
p.prepare()
|
||||||
|
|
||||||
|
// Apply initial playback position
|
||||||
|
config.startPosition?.let { startPosSec ->
|
||||||
|
if (startPosSec > 0) {
|
||||||
|
p.seekTo((startPosSec * 1000).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.autoplay) {
|
||||||
|
p.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(mapOf("url" to config.url))
|
||||||
|
startProgressLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
|
||||||
|
val builder = MediaItem.Builder().setUri(config.url)
|
||||||
|
|
||||||
|
// External subtitles: add as side-loaded SubtitleConfigurations.
|
||||||
|
// MIME-type sniffed from the file extension.
|
||||||
|
val subs = config.externalSubtitles
|
||||||
|
if (!subs.isNullOrEmpty()) {
|
||||||
|
val subtitleConfigs = subs.mapNotNull { subUrl ->
|
||||||
|
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
if (subtitleConfigs.isNotEmpty()) {
|
||||||
|
sideLoadedSubs = subtitleConfigs
|
||||||
|
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeForSubtitleUrl(url: String): String? {
|
||||||
|
val lower = url.substringBeforeLast('?').lowercase()
|
||||||
|
return when {
|
||||||
|
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
|
||||||
|
lower.endsWith(".srt") -> "application/x-subrip"
|
||||||
|
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
|
||||||
|
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stopProgressLoop()
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
playerView.player = null
|
||||||
|
tracksReadyFired = false
|
||||||
|
currentUrl = null
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
subtitleTrackList = emptyList()
|
||||||
|
audioTrackList = emptyList()
|
||||||
|
currentSubtitleId = 0
|
||||||
|
currentAudioId = 0
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
cumulativeDroppedFrames = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionSec: Double) {
|
||||||
|
player?.seekTo((positionSec * 1000).toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBy(offsetSec: Double) {
|
||||||
|
val p = player ?: return
|
||||||
|
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
|
||||||
|
p.seekTo(target.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(speed: Double) {
|
||||||
|
player?.playbackParameters = PlaybackParameters(speed.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Float {
|
||||||
|
return player?.playbackParameters?.speed ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPaused(): Boolean {
|
||||||
|
return player?.isPlaying == false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentPosition(): Double {
|
||||||
|
return (player?.currentPosition ?: 0L) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDuration(): Double {
|
||||||
|
val d = player?.duration ?: 0L
|
||||||
|
return if (d > 0) d / 1000.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
|
||||||
|
|
||||||
|
data class TrackEntry(
|
||||||
|
val id: Int, // 1-based JS-facing ID
|
||||||
|
val trackGroupIndex: Int,
|
||||||
|
val trackIndex: Int,
|
||||||
|
val format: Format,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun rebuildTrackMaps(tracks: Tracks?) {
|
||||||
|
if (tracks == null) return
|
||||||
|
|
||||||
|
val subtitles = mutableListOf<TrackEntry>()
|
||||||
|
val audios = mutableListOf<TrackEntry>()
|
||||||
|
|
||||||
|
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||||
|
val rendererType = group.type
|
||||||
|
// Skip groups that have no tracks the player supports
|
||||||
|
for (trackIdx in 0 until group.length) {
|
||||||
|
if (!group.isTrackSupported(trackIdx)) continue
|
||||||
|
val format = group.getTrackFormat(trackIdx)
|
||||||
|
val entry = TrackEntry(
|
||||||
|
id = 0, // assigned per-list below
|
||||||
|
trackGroupIndex = groupIndex,
|
||||||
|
trackIndex = trackIdx,
|
||||||
|
format = format
|
||||||
|
)
|
||||||
|
when (rendererType) {
|
||||||
|
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
|
||||||
|
C.TRACK_TYPE_AUDIO -> audios.add(entry)
|
||||||
|
else -> { /* video / metadata ignored */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign 1-based IDs per track kind.
|
||||||
|
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
|
||||||
|
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
|
||||||
|
|
||||||
|
subtitleTrackList = subtitles
|
||||||
|
audioTrackList = audios
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyInitialTrackSelections() {
|
||||||
|
val p = player ?: return
|
||||||
|
val cfg = pendingConfig ?: return
|
||||||
|
|
||||||
|
// Initial subtitle/audio selection by 1-based ID.
|
||||||
|
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
|
||||||
|
setAudioTrack(cfg.initialAudioId)
|
||||||
|
}
|
||||||
|
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
|
||||||
|
disableSubtitles()
|
||||||
|
} else {
|
||||||
|
setSubtitleTrack(cfg.initialSubtitleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply once per source load.
|
||||||
|
pendingConfig = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
|
return subtitleTrackList.map { entry ->
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
// setOverrideForType replaces any existing override of the same
|
||||||
|
// track type — exactly what we want for single-track subtitle pickers.
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableSubtitles() {
|
||||||
|
val p = player ?: return
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
|
||||||
|
|
||||||
|
fun addSubtitleFile(url: String, select: Boolean) {
|
||||||
|
val p = player ?: return
|
||||||
|
val mime = mimeTypeForSubtitleUrl(url) ?: return
|
||||||
|
val currentMediaItem = p.currentMediaItem ?: return
|
||||||
|
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Rebuild with the full accumulated list so previously loaded
|
||||||
|
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
|
||||||
|
// earlier addSubtitleFile calls) survive.
|
||||||
|
val combined = sideLoadedSubs + newSubConfig
|
||||||
|
sideLoadedSubs = combined
|
||||||
|
|
||||||
|
val rebuilt = currentMediaItem.buildUpon()
|
||||||
|
.setSubtitleConfigurations(combined)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val wasPlaying = p.isPlaying
|
||||||
|
val pos = p.currentPosition
|
||||||
|
p.setMediaItem(rebuilt, pos)
|
||||||
|
p.prepare()
|
||||||
|
if (wasPlaying) p.play()
|
||||||
|
|
||||||
|
// If text tracks were disabled (e.g. disableSubtitles was called
|
||||||
|
// earlier, or playback started with subtitles off), the new
|
||||||
|
// subtitle — even with SELECTION_FLAG_DEFAULT — won't render.
|
||||||
|
// Re-enable the text track type when the caller asks us to select.
|
||||||
|
if (select) {
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning / Styling
|
||||||
|
|
||||||
|
fun setSubtitlePosition(position: Int) {
|
||||||
|
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
|
||||||
|
// Map to SubtitleView's bottom-padding fraction. Reserve a small
|
||||||
|
// margin so 100 doesn't hug the very bottom edge.
|
||||||
|
val clamped = position.coerceIn(0, 100)
|
||||||
|
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleScale(scale: Double) {
|
||||||
|
subtitleScale = scale.toFloat()
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
|
// Margin in px (approximate). SubtitleView only accepts a single
|
||||||
|
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
|
||||||
|
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
|
||||||
|
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
|
||||||
|
subtitleBottomFraction = fraction
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
|
subtitleAlignY = alignment
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleFontSize(size: Int) {
|
||||||
|
subtitleFontSizePct = size
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBackgroundColor(colorHex: String) {
|
||||||
|
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
|
subtitleBorderStyle = style
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(hex: String, fallback: Int): Int {
|
||||||
|
return try {
|
||||||
|
when {
|
||||||
|
hex.startsWith("#") && hex.length == 9 -> {
|
||||||
|
// #RRGGBBAA
|
||||||
|
val r = hex.substring(1, 3).toInt(16)
|
||||||
|
val g = hex.substring(3, 5).toInt(16)
|
||||||
|
val b = hex.substring(5, 7).toInt(16)
|
||||||
|
val a = hex.substring(7, 9).toInt(16)
|
||||||
|
Color.argb(a, r, g, b)
|
||||||
|
}
|
||||||
|
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySubtitleStyle() {
|
||||||
|
val sv = subtitleView ?: return
|
||||||
|
|
||||||
|
// Text size: explicit % wins; otherwise scale the default.
|
||||||
|
val textSizeFraction = if (subtitleFontSizePct != null) {
|
||||||
|
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
|
||||||
|
} else {
|
||||||
|
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
|
||||||
|
}
|
||||||
|
sv.setFractionalTextSize(textSizeFraction)
|
||||||
|
|
||||||
|
// Vertical position: explicit fraction (from setSubtitlePosition /
|
||||||
|
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
|
||||||
|
val alignYFraction = when (subtitleAlignY) {
|
||||||
|
"top" -> 0.9f
|
||||||
|
"center" -> 0.5f
|
||||||
|
else -> 0.08f // bottom
|
||||||
|
}
|
||||||
|
val bottomFraction = subtitleBottomFraction ?: alignYFraction
|
||||||
|
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
|
||||||
|
|
||||||
|
// Edge / background style.
|
||||||
|
val foreground = Color.WHITE
|
||||||
|
val edgeType: Int
|
||||||
|
val backgroundColor: Int
|
||||||
|
when (subtitleBorderStyle) {
|
||||||
|
"background-box" -> {
|
||||||
|
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
|
||||||
|
// subtitleBackgroundColor already carries its own alpha
|
||||||
|
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
|
||||||
|
// Alpha 0 → transparent, matching user intent.
|
||||||
|
backgroundColor = subtitleBackgroundColor
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// "outline-and-shadow"
|
||||||
|
edgeType = if (subtitleAlignY == "center")
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||||
|
else
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||||
|
backgroundColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val style = CaptionStyleCompat(
|
||||||
|
foreground,
|
||||||
|
backgroundColor,
|
||||||
|
Color.TRANSPARENT,
|
||||||
|
edgeType,
|
||||||
|
Color.BLACK,
|
||||||
|
Typeface.SANS_SERIF
|
||||||
|
)
|
||||||
|
sv.setApplyEmbeddedStyles(false)
|
||||||
|
sv.setApplyEmbeddedFontSizes(false)
|
||||||
|
sv.setStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
|
return audioTrackList.map { entry ->
|
||||||
|
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
|
||||||
|
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
|
||||||
|
else entry.format.channelCount
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: ""),
|
||||||
|
"codec" to (entry.format.sampleMimeType ?: ""),
|
||||||
|
"channels" to channels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentAudioId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAudioTrack(): Int = currentAudioId
|
||||||
|
|
||||||
|
// MARK: - Video Scaling
|
||||||
|
|
||||||
|
fun setZoomedToFill(zoomed: Boolean) {
|
||||||
|
isZoomedToFill = zoomed
|
||||||
|
val resizeMode = if (zoomed) {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
|
} else {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
}
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isZoomedToFill(): Boolean = isZoomedToFill
|
||||||
|
|
||||||
|
// MARK: - Technical Info
|
||||||
|
|
||||||
|
fun getTechnicalInfo(): Map<String, Any> {
|
||||||
|
val p = player ?: return emptyMap()
|
||||||
|
val tracks = p.currentTracks
|
||||||
|
|
||||||
|
// Prefer the currently-selected track within each renderer group;
|
||||||
|
// fall back to the first supported track if none is selected yet.
|
||||||
|
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
|
||||||
|
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
|
||||||
|
|
||||||
|
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
|
||||||
|
(p.bufferedPosition - p.currentPosition) / 1000.0
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
val info = LinkedHashMap<String, Any>()
|
||||||
|
info["cacheSeconds"] = cacheSec
|
||||||
|
|
||||||
|
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
|
||||||
|
if (cumulativeDroppedFrames > 0) {
|
||||||
|
info["droppedFrames"] = cumulativeDroppedFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
|
||||||
|
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
|
||||||
|
// FFmpeg extension reports names beginning with "FFmpeg", which we
|
||||||
|
// classify as software; everything else is MediaCodec (hardware).
|
||||||
|
videoDecoderName?.let { name ->
|
||||||
|
info["decoderName"] = name
|
||||||
|
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
|
||||||
|
"software"
|
||||||
|
} else {
|
||||||
|
"hardware"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoFormat?.let { f ->
|
||||||
|
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
|
||||||
|
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
|
||||||
|
f.sampleMimeType?.let { info["videoCodec"] = it }
|
||||||
|
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
|
||||||
|
// overlay skips the row instead of showing "-1".
|
||||||
|
if (f.frameRate > 0f) {
|
||||||
|
info["fps"] = f.frameRate.toDouble()
|
||||||
|
}
|
||||||
|
// Bitrate: prefer average, fall back to peak. Both can be
|
||||||
|
// NO_VALUE for adaptive HLS renditions — omit when unknown
|
||||||
|
// rather than reporting 0 Kbps.
|
||||||
|
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
|
||||||
|
info["videoBitrate"] = vBitrate.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
|
||||||
|
// Carries profile / tier / level / constraint bytes — power
|
||||||
|
// users can decode it manually to see why a stream hit our
|
||||||
|
// HEVC level cap.
|
||||||
|
f.codecs?.let { info["videoCodecs"] = it }
|
||||||
|
|
||||||
|
// HDR / color metadata. Format.colorInfo is the authoritative
|
||||||
|
// source — the file/Jellyfin may claim HDR but the player is
|
||||||
|
// what decides whether the decoder+surface path is HDR-capable.
|
||||||
|
f.colorInfo?.let { ci ->
|
||||||
|
val hdr = deriveHdrFormat(ci)
|
||||||
|
if (hdr != null) info["hdrFormat"] = hdr
|
||||||
|
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
|
||||||
|
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
|
||||||
|
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFormat?.let { f ->
|
||||||
|
f.sampleMimeType?.let { info["audioCodec"] = it }
|
||||||
|
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
|
||||||
|
info["audioBitrate"] = aBitrate.toDouble()
|
||||||
|
}
|
||||||
|
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
|
||||||
|
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the active color transfer to a human-readable HDR format string.
|
||||||
|
* Returns null for SDR / unknown so the overlay can skip the row.
|
||||||
|
*
|
||||||
|
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
|
||||||
|
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
|
||||||
|
* exposed on Format. Both report as "HDR10" here; that matches what
|
||||||
|
* Media3 actually decodes (no HDR10+ tone-mapping).
|
||||||
|
*/
|
||||||
|
private fun deriveHdrFormat(ci: ColorInfo): String? {
|
||||||
|
return when (ci.colorTransfer) {
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "HDR10"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorSpaceName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_SPACE_BT709 -> "BT.709"
|
||||||
|
C.COLOR_SPACE_BT601 -> "BT.601"
|
||||||
|
C.COLOR_SPACE_BT2020 -> "BT.2020"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorRangeName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_RANGE_LIMITED -> "Limited"
|
||||||
|
C.COLOR_RANGE_FULL -> "Full"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorTransferName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_TRANSFER_SDR -> "SDR"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickFormat(tracks: Tracks, type: Int): Format? {
|
||||||
|
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
|
||||||
|
// Selected track wins.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
// Otherwise the first supported track.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Loop
|
||||||
|
|
||||||
|
private fun startProgressLoop() {
|
||||||
|
stopProgressLoop()
|
||||||
|
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopProgressLoop() {
|
||||||
|
mainHandler.removeCallbacks(progressRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
6
modules/exoplayer-player/expo-module.config.json
Normal file
6
modules/exoplayer-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
modules/exoplayer-player/index.ts
Normal file
19
modules/exoplayer-player/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Re-export the shared player contract from mpv-player so ExoPlayer
|
||||||
|
// and MPV present identical surfaces to React. The MPV-prefixed setting
|
||||||
|
// keys keep their names to avoid migrating existing installs.
|
||||||
|
export type {
|
||||||
|
AudioTrack,
|
||||||
|
MpvPlayerViewProps,
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
NowPlayingMetadata,
|
||||||
|
OnErrorEventPayload,
|
||||||
|
OnLoadEventPayload,
|
||||||
|
OnPictureInPictureChangePayload,
|
||||||
|
OnPlaybackStateChangePayload,
|
||||||
|
OnProgressEventPayload,
|
||||||
|
OnTracksReadyEventPayload,
|
||||||
|
SubtitleTrack,
|
||||||
|
TechnicalInfo,
|
||||||
|
VideoSource,
|
||||||
|
} from "../mpv-player/src/MpvPlayer.types";
|
||||||
|
export { default as ExoPlayerView } from "./src/ExoPlayerView";
|
||||||
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { requireNativeView } from "expo";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MpvPlayerViewProps,
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
} from "../../mpv-player/src/MpvPlayer.types";
|
||||||
|
|
||||||
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
|
requireNativeView("ExoPlayer");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
|
||||||
|
* `MpvPlayerView` so callers can swap between the two players without
|
||||||
|
* changing code. PiP / ASS-override methods are forwarded to the native
|
||||||
|
* module which implements them as no-ops.
|
||||||
|
*/
|
||||||
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
|
function ExoPlayerView(props, ref) {
|
||||||
|
const nativeRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: async () => {
|
||||||
|
await nativeRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: async () => {
|
||||||
|
await nativeRef.current?.pause();
|
||||||
|
},
|
||||||
|
destroy: async () => {
|
||||||
|
await nativeRef.current?.destroy();
|
||||||
|
},
|
||||||
|
seekTo: async (position: number) => {
|
||||||
|
await nativeRef.current?.seekTo(position);
|
||||||
|
},
|
||||||
|
seekBy: async (offset: number) => {
|
||||||
|
await nativeRef.current?.seekBy(offset);
|
||||||
|
},
|
||||||
|
setSpeed: async (speed: number) => {
|
||||||
|
await nativeRef.current?.setSpeed(speed);
|
||||||
|
},
|
||||||
|
getSpeed: async () => {
|
||||||
|
return await nativeRef.current?.getSpeed();
|
||||||
|
},
|
||||||
|
isPaused: async () => {
|
||||||
|
return await nativeRef.current?.isPaused();
|
||||||
|
},
|
||||||
|
getCurrentPosition: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentPosition();
|
||||||
|
},
|
||||||
|
getDuration: async () => {
|
||||||
|
return await nativeRef.current?.getDuration();
|
||||||
|
},
|
||||||
|
startPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
},
|
||||||
|
stopPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
},
|
||||||
|
isPictureInPictureSupported: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureSupported();
|
||||||
|
},
|
||||||
|
isPictureInPictureActive: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureActive();
|
||||||
|
},
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
},
|
||||||
|
setSubtitleTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: async () => {
|
||||||
|
await nativeRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
getCurrentSubtitleTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||||
|
},
|
||||||
|
addSubtitleFile: async (url: string, select = true) => {
|
||||||
|
await nativeRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
setSubtitlePosition: async (position: number) => {
|
||||||
|
await nativeRef.current?.setSubtitlePosition(position);
|
||||||
|
},
|
||||||
|
setSubtitleScale: async (scale: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleScale(scale);
|
||||||
|
},
|
||||||
|
setSubtitleMarginY: async (margin: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||||
|
},
|
||||||
|
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleFontSize: async (size: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleFontSize(size);
|
||||||
|
},
|
||||||
|
setSubtitleBackgroundColor: async (color: string) => {
|
||||||
|
await nativeRef.current?.setSubtitleBackgroundColor(color);
|
||||||
|
},
|
||||||
|
setSubtitleBorderStyle: async (
|
||||||
|
style: "outline-and-shadow" | "background-box",
|
||||||
|
) => {
|
||||||
|
await nativeRef.current?.setSubtitleBorderStyle(style);
|
||||||
|
},
|
||||||
|
setSubtitleAssOverride: async (mode: "no" | "force") => {
|
||||||
|
await nativeRef.current?.setSubtitleAssOverride(mode);
|
||||||
|
},
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return await nativeRef.current?.getAudioTracks();
|
||||||
|
},
|
||||||
|
setAudioTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
getCurrentAudioTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentAudioTrack();
|
||||||
|
},
|
||||||
|
setZoomedToFill: async (zoomed: boolean) => {
|
||||||
|
await nativeRef.current?.setZoomedToFill(zoomed);
|
||||||
|
},
|
||||||
|
isZoomedToFill: async () => {
|
||||||
|
return await nativeRef.current?.isZoomedToFill();
|
||||||
|
},
|
||||||
|
getTechnicalInfo: async () => {
|
||||||
|
return await nativeRef.current?.getTechnicalInfo();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <NativeView ref={nativeRef} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -7,6 +7,8 @@ export type {
|
|||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
// ExoPlayer (Android TV)
|
||||||
|
export { ExoPlayerView } from "./exoplayer-player";
|
||||||
// Glass Poster (tvOS 26+)
|
// Glass Poster (tvOS 26+)
|
||||||
export type { GlassPosterViewProps } from "./glass-poster";
|
export type { GlassPosterViewProps } from "./glass-poster";
|
||||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// libmpv from Maven Central
|
// libmpv from Maven Central
|
||||||
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -3,13 +3,14 @@ package expo.modules.mpvplayer
|
|||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.AssetManager
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.system.Os
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPV renderer that wraps libmpv for video playback.
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
@@ -35,6 +36,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
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 {
|
interface Delegate {
|
||||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||||
fun onPauseChanged(isPaused: Boolean)
|
fun onPauseChanged(isPaused: Boolean)
|
||||||
@@ -51,7 +76,14 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
private var surface: Surface? = null
|
private var surface: Surface? = null
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var isStopping = false
|
|
||||||
|
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
||||||
|
// player screen gets a fresh mpv handle and drops the reference on stop.
|
||||||
|
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
||||||
|
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
||||||
|
// so we mirror Findroid and let the JVM GC + native finalization path
|
||||||
|
// reclaim resources. Only one player is alive at a time in this app.
|
||||||
|
private var mpv: MPVLib? = null
|
||||||
|
|
||||||
// Cached state
|
// Cached state
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
@@ -114,97 +146,105 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MPVLib.create(context)
|
// Per-instance handle — see class-level comment. Each player gets
|
||||||
MPVLib.addObserver(this)
|
// its own mpv; we drop the reference in stop().
|
||||||
|
val mpv = MPVLib.create(context)
|
||||||
|
this.mpv = mpv
|
||||||
|
mpv.addObserver(this)
|
||||||
|
|
||||||
/**
|
// Resolved once — TV gets the memory-pressure customizations
|
||||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||||
*
|
// audio-buffer) that would be counterproductive on higher-RAM
|
||||||
* Technical Background:
|
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||||
* ====================
|
// those come from user settings via load().
|
||||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
val isTV = isTvDevice()
|
||||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
|
||||||
* even when subtitle tracks are properly detected and loaded.
|
// mpv config directory — used by the config-dir option below and
|
||||||
*
|
// as XDG_CONFIG_HOME for fontconfig.
|
||||||
* Why This Is Necessary:
|
|
||||||
* =====================
|
|
||||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
|
||||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
|
||||||
*
|
|
||||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
|
||||||
* configured directory, mpv either:
|
|
||||||
* - Fails silently (subtitles don't appear)
|
|
||||||
* - Falls back to a default font that may not support the required character set
|
|
||||||
* - Crashes or produces rendering errors
|
|
||||||
*
|
|
||||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
|
||||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
|
||||||
*
|
|
||||||
* Reference:
|
|
||||||
* =========
|
|
||||||
* This workaround is documented in the mpv-android project:
|
|
||||||
* https://github.com/mpv-android/mpv-android/issues/96
|
|
||||||
*
|
|
||||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
|
||||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
|
||||||
*/
|
|
||||||
// Create mpv config directory and copy font files
|
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
|
||||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||||
// This needs to be named `subfont.ttf` else it won't work
|
|
||||||
arrayOf("subfont.ttf").forEach { fileName ->
|
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||||
val file = File(mpvDir, fileName)
|
// persists its font index across runs instead of re-walking
|
||||||
if (file.exists()) return@forEach
|
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||||
context.assets
|
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
// holds onto. Without this we see "No usable fontconfig
|
||||||
.copyTo(FileOutputStream(file))
|
// configuration file found, using fallback" on every re-init.
|
||||||
|
try {
|
||||||
|
val cacheDir = context.cacheDir.absolutePath
|
||||||
|
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
||||||
|
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
||||||
|
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
||||||
|
Os.setenv("HOME", configDir, true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
||||||
}
|
}
|
||||||
MPVLib.setOptionString("config", "yes")
|
|
||||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
mpv?.setOptionString("config", "yes")
|
||||||
|
mpv?.setOptionString("config-dir", mpvDir.path)
|
||||||
|
|
||||||
// Configure mpv options before initialization (based on Findroid)
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
this.voDriver = voDriver
|
this.voDriver = voDriver
|
||||||
MPVLib.setOptionString("vo", voDriver)
|
mpv?.setOptionString("vo", voDriver)
|
||||||
MPVLib.setOptionString("gpu-context", "android")
|
mpv?.setOptionString("gpu-context", "android")
|
||||||
MPVLib.setOptionString("opengl-es", "yes")
|
mpv?.setOptionString("opengl-es", "yes")
|
||||||
|
|
||||||
// Hardware video decoding
|
// Hardware decoder codecs (shared)
|
||||||
// TV: zero-copy (mediacodec) for better performance on low-power devices
|
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||||
// Mobile: copy mode (mediacodec-copy) for better compatibility
|
|
||||||
val isTV = isTvDevice()
|
// Pause on initial cache fill (shared default). The actual
|
||||||
if (isTV) {
|
// cache mode, cache-secs, and demuxer cache sizes come from
|
||||||
MPVLib.setOptionString("hwdec", "mediacodec")
|
// user preferences and are applied per-load in load().
|
||||||
MPVLib.setOptionString("profile", "fast")
|
mpv?.setOptionString("cache-pause-initial", "yes")
|
||||||
} else {
|
|
||||||
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
// 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
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
// Use keyframe seeking by default (much faster for network streams)
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
MPVLib.setOptionString("hr-seek", "no")
|
mpv?.setOptionString("hr-seek", "no")
|
||||||
// Drop frames during seeking for faster response
|
// Drop frames during seeking for faster response
|
||||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
// Subtitle settings
|
// Subtitle settings
|
||||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||||
MPVLib.setOptionString("sub-use-margins", "no")
|
mpv?.setOptionString("sub-use-margins", "no")
|
||||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||||
MPVLib.setOptionString("subs-fallback", "yes")
|
mpv?.setOptionString("subs-fallback", "yes")
|
||||||
|
|
||||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||||
MPVLib.setOptionString("force-window", "no")
|
mpv?.setOptionString("force-window", "no")
|
||||||
MPVLib.setOptionString("keep-open", "always")
|
mpv?.setOptionString("keep-open", "always")
|
||||||
|
|
||||||
MPVLib.initialize()
|
mpv.initialize()
|
||||||
|
|
||||||
// Observe properties
|
// Observe properties
|
||||||
observeProperties()
|
observeProperties()
|
||||||
@@ -218,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (isStopping) return
|
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
|
|
||||||
isStopping = true
|
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
try {
|
val m = mpv
|
||||||
MPVLib.removeObserver(this)
|
mpv = null
|
||||||
MPVLib.detachSurface()
|
|
||||||
MPVLib.destroy()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
isStopping = false
|
// Clear cached media state on the main thread so the next player
|
||||||
|
// screen doesn't observe stale position/duration values during the
|
||||||
|
// (async) teardown below.
|
||||||
|
currentUrl = null
|
||||||
|
currentHeaders = null
|
||||||
|
pendingExternalSubtitles = emptyList()
|
||||||
|
initialSubtitleId = null
|
||||||
|
initialAudioId = null
|
||||||
|
cachedPosition = 0.0
|
||||||
|
cachedDuration = 0.0
|
||||||
|
cachedCacheSeconds = 0.0
|
||||||
|
|
||||||
|
if (m == null) return
|
||||||
|
|
||||||
|
// Teardown runs on a background daemon thread. mpv's "stop" command
|
||||||
|
// flushes the demuxer queue and releases the MediaCodec hardware
|
||||||
|
// decoder — synchronous JNI work that can block for hundreds of ms
|
||||||
|
// on TV hardware. Running it on the main thread produced a visible
|
||||||
|
// delay/stutter between pressing "exit" and the confirm alert
|
||||||
|
// appearing. The local `m` keeps the MPVLib instance alive for the
|
||||||
|
// lifetime of this thread even though we've already nulled `mpv`.
|
||||||
|
Thread {
|
||||||
|
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
||||||
|
// force-window=yes, mpv tears down the decoder at stop time but
|
||||||
|
// tries to keep the VO alive — which fires an internal
|
||||||
|
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
||||||
|
// reconfig path crashes with "Missing surface pointer" because we
|
||||||
|
// detach the Surface below before mpv's worker reaches the
|
||||||
|
// reconfig step (command() is async). Setting force-window=no
|
||||||
|
// first makes mpv tear VO down cleanly instead of attempting a
|
||||||
|
// doomed re-init, eliminating the fatal VO error and the
|
||||||
|
// "playback won't restart" aftermath.
|
||||||
|
try {
|
||||||
|
m.setOptionString("force-window", "no")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Stop playback — flushes demuxer queue and signals MediaCodec
|
||||||
|
// to release its hardware decoders. This is the bulk of what
|
||||||
|
// we can reclaim without calling destroy().
|
||||||
|
m.command(arrayOf("stop"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
m.removeObserver(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
m.detachSurface()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
||||||
|
}
|
||||||
|
}.also { it.isDaemon = true }.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = surface
|
this.surface = surface
|
||||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.attachSurface(surface)
|
mpv?.attachSurface(surface)
|
||||||
MPVLib.setOptionString("force-window", "yes")
|
mpv?.setOptionString("force-window", "yes")
|
||||||
// Read back vo to confirm it's still active
|
// Read back vo to confirm it's still active
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = null
|
this.surface = null
|
||||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.detachSurface()
|
mpv?.detachSurface()
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
@@ -298,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
val pos = cachedPosition
|
val pos = cachedPosition
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
MPVLib.command(arrayOf("frame-step"))
|
mpv?.command(arrayOf("frame-step"))
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +397,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
startPosition: Double? = null,
|
startPosition: Double? = null,
|
||||||
externalSubtitles: List<String>? = null,
|
externalSubtitles: List<String>? = null,
|
||||||
initialSubtitleId: Int? = null,
|
initialSubtitleId: Int? = null,
|
||||||
initialAudioId: Int? = null
|
initialAudioId: Int? = null,
|
||||||
|
cacheEnabled: String? = null,
|
||||||
|
cacheSeconds: Int? = null,
|
||||||
|
demuxerMaxBytes: Int? = null,
|
||||||
|
demuxerMaxBackBytes: Int? = null
|
||||||
) {
|
) {
|
||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
@@ -323,16 +414,26 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
|
||||||
// Stop previous playback
|
// Stop previous playback
|
||||||
MPVLib.command(arrayOf("stop"))
|
mpv?.command(arrayOf("stop"))
|
||||||
|
|
||||||
// Set HTTP headers if provided
|
// Set HTTP headers if provided
|
||||||
updateHttpHeaders(headers)
|
updateHttpHeaders(headers)
|
||||||
|
|
||||||
// Set start position
|
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
||||||
|
// These override the conservative defaults applied in start() so the
|
||||||
|
// TV/mobile settings screen actually takes effect on Android.
|
||||||
|
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
||||||
|
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
||||||
|
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
||||||
|
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
||||||
|
|
||||||
|
// Set start position. mpv's time parser requires '.' as the decimal
|
||||||
|
// separator; use Locale.US so devices with other default locales
|
||||||
|
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
||||||
if (startPosition != null && startPosition > 0) {
|
if (startPosition != null && startPosition > 0) {
|
||||||
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||||
} else {
|
} else {
|
||||||
MPVLib.setPropertyString("start", "0")
|
mpv?.setPropertyString("start", "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial audio track if specified
|
// Set initial audio track if specified
|
||||||
@@ -352,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load the file
|
// Load the file
|
||||||
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadCurrentItem() {
|
fun reloadCurrentItem() {
|
||||||
@@ -368,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||||
MPVLib.setPropertyString("http-header-fields", headerString)
|
mpv?.setPropertyString("http-header-fields", headerString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeProperties() {
|
private fun observeProperties() {
|
||||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||||
// Video dimensions for PiP aspect ratio
|
// Video dimensions for PiP aspect ratio
|
||||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
MPVLib.setPropertyBoolean("pause", false)
|
mpv?.setPropertyBoolean("pause", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
MPVLib.setPropertyBoolean("pause", true)
|
mpv?.setPropertyBoolean("pause", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePause() {
|
fun togglePause() {
|
||||||
@@ -400,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun seekTo(seconds: Double) {
|
fun seekTo(seconds: Double) {
|
||||||
val clamped = maxOf(0.0, seconds)
|
val clamped = maxOf(0.0, seconds)
|
||||||
cachedPosition = clamped
|
cachedPosition = clamped
|
||||||
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(seconds: Double) {
|
fun seekBy(seconds: Double) {
|
||||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||||
cachedPosition = newPosition
|
cachedPosition = newPosition
|
||||||
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
_playbackSpeed = speed
|
_playbackSpeed = speed
|
||||||
MPVLib.setPropertyDouble("speed", speed)
|
mpv?.setPropertyDouble("speed", speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
@@ -423,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "sub") continue
|
if (trackType != "sub") continue
|
||||||
|
|
||||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
|
||||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -447,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||||
if (trackId < 0) {
|
if (trackId < 0) {
|
||||||
MPVLib.setPropertyString("sid", "no")
|
mpv?.setPropertyString("sid", "no")
|
||||||
} else {
|
} else {
|
||||||
MPVLib.setPropertyInt("sid", trackId)
|
mpv?.setPropertyInt("sid", trackId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
MPVLib.setPropertyString("sid", "no")
|
mpv?.setPropertyString("sid", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return MPVLib.getPropertyInt("sid") ?: 0
|
return mpv?.getPropertyInt("sid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
MPVLib.command(arrayOf("sub-add", url, flag))
|
mpv?.command(arrayOf("sub-add", url, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
MPVLib.setPropertyInt("sub-pos", position)
|
mpv?.setPropertyInt("sub-pos", position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
mpv?.setPropertyDouble("sub-scale", scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
MPVLib.setPropertyInt("sub-margin-y", margin)
|
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
MPVLib.setPropertyString("sub-align-x", alignment)
|
mpv?.setPropertyString("sub-align-x", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
MPVLib.setPropertyString("sub-align-y", alignment)
|
mpv?.setPropertyString("sub-align-y", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
MPVLib.setPropertyInt("sub-font-size", size)
|
mpv?.setPropertyInt("sub-font-size", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBorderStyle(style: String) {
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
MPVLib.setPropertyString("sub-border-style", style)
|
mpv?.setPropertyString("sub-border-style", style)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBackgroundColor(color: String) {
|
fun setSubtitleBackgroundColor(color: String) {
|
||||||
MPVLib.setPropertyString("sub-back-color", color)
|
mpv?.setPropertyString("sub-back-color", color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAssOverride(mode: String) {
|
fun setSubtitleAssOverride(mode: String) {
|
||||||
MPVLib.setPropertyString("sub-ass-override", mode)
|
mpv?.setPropertyString("sub-ass-override", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
@@ -509,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "audio") continue
|
if (trackType != "audio") continue
|
||||||
|
|
||||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
||||||
if (channels != null && channels > 0) {
|
if (channels != null && channels > 0) {
|
||||||
track["channels"] = channels
|
track["channels"] = channels
|
||||||
}
|
}
|
||||||
|
|
||||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -538,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||||
MPVLib.setPropertyInt("aid", trackId)
|
mpv?.setPropertyInt("aid", trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return MPVLib.getPropertyInt("aid") ?: 0
|
return mpv?.getPropertyInt("aid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Scaling
|
// MARK: - Video Scaling
|
||||||
@@ -551,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Technical Info
|
// MARK: - Technical Info
|
||||||
@@ -560,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
val info = mutableMapOf<String, Any>()
|
val info = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
// Video dimensions
|
// Video dimensions
|
||||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||||
info["videoWidth"] = it
|
info["videoWidth"] = it
|
||||||
}
|
}
|
||||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||||
info["videoHeight"] = it
|
info["videoHeight"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video codec
|
// Video codec
|
||||||
MPVLib.getPropertyString("video-format")?.let {
|
mpv?.getPropertyString("video-format")?.let {
|
||||||
info["videoCodec"] = it
|
info["videoCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio codec
|
// Audio codec
|
||||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||||
info["audioCodec"] = it
|
info["audioCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// FPS (container fps)
|
// FPS (container fps)
|
||||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||||
info["fps"] = it
|
info["fps"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video bitrate (bits per second)
|
// Video bitrate (bits per second)
|
||||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["videoBitrate"] = it
|
info["videoBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio bitrate (bits per second)
|
// Audio bitrate (bits per second)
|
||||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["audioBitrate"] = it
|
info["audioBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demuxer cache duration (seconds of video buffered)
|
// Demuxer cache duration (seconds of video buffered)
|
||||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||||
info["cacheSeconds"] = it
|
info["cacheSeconds"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured cache limits — read back from mpv to confirm user
|
||||||
|
// settings actually took effect. mpv stores byte sizes as int64
|
||||||
|
// (bytes); convert to MiB for display.
|
||||||
|
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
||||||
|
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
||||||
|
}
|
||||||
|
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
||||||
|
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
||||||
|
}
|
||||||
|
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
||||||
|
info["cacheSecsLimit"] = secs
|
||||||
|
}
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||||
info["droppedFrames"] = it
|
info["droppedFrames"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||||
MPVLib.getPropertyString("vo")?.let {
|
mpv?.getPropertyString("vo")?.let {
|
||||||
info["voDriver"] = it
|
info["voDriver"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active hardware decoder
|
// Active hardware decoder.
|
||||||
MPVLib.getPropertyString("hwdec-active")?.let {
|
// hwdec-current yields e.g. "mediacodec",
|
||||||
|
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||||
|
mpv?.getPropertyString("hwdec-current")?.let {
|
||||||
info["hwdec"] = it
|
info["hwdec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estimated video output fps (renderer-side, after filtering).
|
||||||
|
// Useful for diagnosing display/pipeline drops vs container fps.
|
||||||
|
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
||||||
|
info["estimatedVfFps"] = it
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||||
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
||||||
}
|
}
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import android.view.Surface
|
|
||||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||||
* This provides a consistent interface for the rest of the app.
|
*
|
||||||
|
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
||||||
|
* a fresh, independent handle. Each player creates its own MPVLib instance
|
||||||
|
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
||||||
|
* call `LibMPV.destroy()` — its native implementation has an internal
|
||||||
|
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
||||||
|
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
||||||
|
* handle lives in process-global state until exit) is strictly safer than
|
||||||
|
* crashing.
|
||||||
|
*
|
||||||
|
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
||||||
|
* stays allocated until the next player's allocation displaces it in scudo's
|
||||||
|
* arena. On a TV app where the player is the dominant memory consumer and
|
||||||
|
* only one player is alive at a time, this is acceptable.
|
||||||
*/
|
*/
|
||||||
object MPVLib {
|
class MPVLib private constructor(private val instance: LibMPV) {
|
||||||
private const val TAG = "MPVLib"
|
|
||||||
|
|
||||||
private var initialized = false
|
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||||
|
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||||
// Event observer interface
|
|
||||||
interface EventObserver {
|
interface EventObserver {
|
||||||
fun eventProperty(property: String)
|
fun eventProperty(property: String)
|
||||||
fun eventProperty(property: String, value: Long)
|
fun eventProperty(property: String, value: Long)
|
||||||
@@ -26,195 +35,141 @@ object MPVLib {
|
|||||||
|
|
||||||
private val observers = mutableListOf<EventObserver>()
|
private val observers = mutableListOf<EventObserver>()
|
||||||
|
|
||||||
// Library event observer that forwards to our observers
|
// Library event observer that forwards LibMPV callbacks to our observers.
|
||||||
private val libObserver = object : LibMPV.EventObserver {
|
private val libObserver = object : LibMPV.EventObserver {
|
||||||
override fun eventProperty(property: String) {
|
override fun eventProperty(property: String) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Long) {
|
override fun eventProperty(property: String, value: Long) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Boolean) {
|
override fun eventProperty(property: String, value: Boolean) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: String) {
|
override fun eventProperty(property: String, value: String) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Double) {
|
override fun eventProperty(property: String, value: Double) =
|
||||||
synchronized(observers) {
|
dispatch { it.eventProperty(property, value) }
|
||||||
for (observer in observers) {
|
|
||||||
observer.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun event(eventId: Int) {
|
override fun event(eventId: Int) =
|
||||||
|
dispatch { it.event(eventId) }
|
||||||
|
|
||||||
|
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||||
synchronized(observers) {
|
synchronized(observers) {
|
||||||
for (observer in observers) {
|
observers.forEach(block)
|
||||||
observer.event(eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addObserver(observer: EventObserver) {
|
fun addObserver(observer: EventObserver) {
|
||||||
synchronized(observers) {
|
synchronized(observers) { observers.add(observer) }
|
||||||
observers.add(observer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeObserver(observer: EventObserver) {
|
fun removeObserver(observer: EventObserver) {
|
||||||
synchronized(observers) {
|
synchronized(observers) { observers.remove(observer) }
|
||||||
observers.remove(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MPV Event IDs
|
|
||||||
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() {
|
fun initialize() {
|
||||||
LibMPV.init()
|
instance.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun attachSurface(surface: android.view.Surface) {
|
||||||
if (!initialized) return
|
instance.attachSurface(surface)
|
||||||
try {
|
|
||||||
LibMPV.removeObserver(libObserver)
|
|
||||||
LibMPV.destroy()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
|
||||||
}
|
|
||||||
initialized = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isInitialized(): Boolean = initialized
|
|
||||||
|
|
||||||
fun attachSurface(surface: Surface) {
|
|
||||||
LibMPV.attachSurface(surface)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detachSurface() {
|
fun detachSurface() {
|
||||||
LibMPV.detachSurface()
|
instance.detachSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun command(cmd: Array<String?>) {
|
fun command(cmd: Array<String>) {
|
||||||
LibMPV.command(cmd)
|
instance.command(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOptionString(name: String, value: String): Int {
|
fun setOptionString(name: String, value: String): Int {
|
||||||
return LibMPV.setOptionString(name, value)
|
return instance.setOptionString(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPropertyInt(name: String): Int? {
|
fun getPropertyInt(name: String): Int? = try {
|
||||||
return try {
|
instance.getPropertyInt(name)
|
||||||
LibMPV.getPropertyInt(name)
|
} catch (e: Exception) { null }
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyDouble(name: String): Double? {
|
fun getPropertyDouble(name: String): Double? = try {
|
||||||
return try {
|
instance.getPropertyDouble(name)
|
||||||
LibMPV.getPropertyDouble(name)
|
} catch (e: Exception) { null }
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyBoolean(name: String): Boolean? {
|
fun getPropertyBoolean(name: String): Boolean? = try {
|
||||||
return try {
|
instance.getPropertyBoolean(name)
|
||||||
LibMPV.getPropertyBoolean(name)
|
} catch (e: Exception) { null }
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyString(name: String): String? {
|
fun getPropertyString(name: String): String? = try {
|
||||||
return try {
|
instance.getPropertyString(name)
|
||||||
LibMPV.getPropertyString(name)
|
} catch (e: Exception) { null }
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyInt(name: String, value: Int) {
|
fun setPropertyInt(name: String, value: Int) {
|
||||||
LibMPV.setPropertyInt(name, value)
|
instance.setPropertyInt(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPropertyDouble(name: String, value: Double) {
|
fun setPropertyDouble(name: String, value: Double) {
|
||||||
LibMPV.setPropertyDouble(name, value)
|
instance.setPropertyDouble(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||||
LibMPV.setPropertyBoolean(name, value)
|
instance.setPropertyBoolean(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPropertyString(name: String, value: String) {
|
fun setPropertyString(name: String, value: String) {
|
||||||
LibMPV.setPropertyString(name, value)
|
instance.setPropertyString(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeProperty(name: String, format: Int) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class MpvPlayerModule : Module() {
|
|||||||
|
|
||||||
val urlString = source["url"] as? String ?: return@Prop
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
// Parse cache config if provided (mirrors iOS)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val config = VideoLoadConfig(
|
val config = VideoLoadConfig(
|
||||||
url = urlString,
|
url = urlString,
|
||||||
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
|
|||||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
voDriver = source["voDriver"] as? String
|
voDriver = source["voDriver"] as? String,
|
||||||
|
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||||
|
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||||
)
|
)
|
||||||
|
|
||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop playback and release the MediaCodec decoder + demuxer.
|
||||||
|
// Does not synchronously tear down the native mpv handle (see
|
||||||
|
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
||||||
|
// away from the player screen to avoid OOM during screen
|
||||||
|
// transitions on low-RAM devices.
|
||||||
|
AsyncFunction("destroy") { view: MpvPlayerView ->
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||||
view.seekTo(position)
|
view.seekTo(position)
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.SurfaceTexture
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.TextureView
|
import android.view.SurfaceHolder
|
||||||
import android.view.View
|
import android.view.SurfaceView
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
@@ -26,15 +24,30 @@ data class VideoLoadConfig(
|
|||||||
val autoplay: Boolean = true,
|
val autoplay: Boolean = true,
|
||||||
val initialSubtitleId: Int? = null,
|
val initialSubtitleId: Int? = null,
|
||||||
val initialAudioId: Int? = null,
|
val initialAudioId: Int? = null,
|
||||||
val voDriver: String? = null
|
val voDriver: String? = null,
|
||||||
|
val cacheEnabled: String? = null,
|
||||||
|
val cacheSeconds: Int? = null,
|
||||||
|
val demuxerMaxBytes: Int? = null,
|
||||||
|
val demuxerMaxBackBytes: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
* Uses TextureView for reliable Picture-in-Picture support.
|
*
|
||||||
|
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
||||||
|
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
||||||
|
* app's window surface. This matches mpv-android's architecture and
|
||||||
|
* gives mpv a standalone surface.
|
||||||
|
*
|
||||||
|
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
||||||
|
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
||||||
|
* can be stale until the next layout pass. We push dimension updates to
|
||||||
|
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
||||||
|
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
||||||
|
* passes on the view itself) reaches mpv promptly.
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
@@ -48,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
private var textureView: TextureView
|
private var surfaceView: SurfaceView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
@@ -59,30 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var surfaceReady: Boolean = false
|
private var surfaceReady: Boolean = false
|
||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var pendingSurface: Surface? = null
|
private var activeSurface: Surface? = null
|
||||||
private var surfaceTexture: SurfaceTexture? = null
|
|
||||||
|
|
||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
private var isWaitingForPiPTransition: Boolean = false
|
|
||||||
private var isPiPSurfaceForced: Boolean = false
|
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
// SurfaceView for video rendering. Routes the surface directly to
|
||||||
textureView = TextureView(context).apply {
|
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
||||||
|
// surface. TextureView composites into the app's window surface
|
||||||
|
// which is less efficient and breaks PiP transitions.
|
||||||
|
surfaceView = SurfaceView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
surfaceTextureListener = this@MpvPlayerView
|
|
||||||
}
|
}
|
||||||
addView(textureView)
|
surfaceView.holder.addCallback(this@MpvPlayerView)
|
||||||
|
addView(surfaceView)
|
||||||
|
|
||||||
|
// Push dimension updates to mpv on every view bounds change. This
|
||||||
|
// is the primary PiP black-screen fix: entering PiP fires a layout
|
||||||
|
// pass on the SurfaceView itself, and we proactively tell mpv the
|
||||||
|
// new size so it resizes its EGL swapchain before rendering.
|
||||||
|
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
||||||
|
oldLeft, oldTop, oldRight, oldBottom ->
|
||||||
|
val w = right - left
|
||||||
|
val h = bottom - top
|
||||||
|
val oldW = oldRight - oldLeft
|
||||||
|
val oldH = oldBottom - oldTop
|
||||||
|
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
||||||
|
renderer?.updateSurfaceSize(w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(textureView)
|
pipController?.setPlayerView(surfaceView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -98,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
if (isInPiP) {
|
if (isInPiP) {
|
||||||
if (!isWaitingForPiPTransition) {
|
// Post size syncs after the PiP layout settles. Two passes
|
||||||
isWaitingForPiPTransition = true
|
// catch both the immediate surface re-attach and the
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
// post-animation layout pass. Replaces the old TextureView
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
// measure/layout polling hack (forcePiPBufferSize).
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
restoreFromPiP()
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||||
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||||
|
} else {
|
||||||
|
// Restore from PiP: surface resized back to fullscreen.
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||||
}
|
}
|
||||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
}
|
}
|
||||||
@@ -121,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the renderer with the given VO driver.
|
* Start the renderer with the given VO driver.
|
||||||
* Called lazily on first loadVideo so the voDriver setting is available.
|
* Called lazily on first loadVideo so user settings are available.
|
||||||
*/
|
*/
|
||||||
private fun ensureRendererStarted(voDriver: String?) {
|
private fun ensureRendererStarted(voDriver: String?) {
|
||||||
if (rendererStarted) return
|
if (rendererStarted) return
|
||||||
@@ -130,9 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
|
|
||||||
pendingSurface?.let { surface ->
|
// If the surface is already alive (surfaceCreated fired before
|
||||||
|
// loadVideo), attach it now. With SurfaceView the surface is
|
||||||
|
// owned by the holder, so we read it from there directly rather
|
||||||
|
// than stashing it on the side.
|
||||||
|
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
||||||
|
activeSurface = surface
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
pendingSurface = null
|
syncSurfaceSizeToView()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
@@ -140,18 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TextureView.SurfaceTextureListener
|
// MARK: - SurfaceHolder.Callback
|
||||||
|
|
||||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
this.surfaceTexture = surfaceTexture
|
val surface = holder.surface
|
||||||
val surface = Surface(surfaceTexture)
|
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
|
// The previous Surface reference is holder-owned; do NOT release
|
||||||
|
// it (SurfaceView manages its lifecycle). Just track the new one.
|
||||||
|
activeSurface = surface
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
} else {
|
// Push the actual view dimensions immediately so mpv doesn't
|
||||||
pendingSurface = surface
|
// render against stale full-screen geometry during PiP transitions.
|
||||||
|
syncSurfaceSizeToView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -162,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
if (width > 0 && height > 0) {
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
this.surfaceTexture = null
|
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
return false // mpv manages the SurfaceTexture
|
// Do NOT issue mpv "stop" here. Playback continues against the
|
||||||
|
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
||||||
|
// background/foreground), we re-attach and frames resume. This
|
||||||
|
// matches the keep-open=always setting in MPVLayerRenderer.
|
||||||
|
//
|
||||||
|
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
||||||
|
activeSurface = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
/**
|
||||||
// Called every frame — no action needed, mpv drives rendering directly
|
* Read the actual SurfaceView width/height and push them to mpv.
|
||||||
|
* The PiP transition can fire surfaceCreated before the view's layout
|
||||||
|
* has settled to PiP dimensions, so we re-sync after layout passes.
|
||||||
|
*/
|
||||||
|
private fun syncSurfaceSizeToView() {
|
||||||
|
if (!surfaceReady) return
|
||||||
|
val w = surfaceView.width
|
||||||
|
val h = surfaceView.height
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
renderer?.updateSurfaceSize(w, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
@@ -207,7 +258,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
startPosition = config.startPosition,
|
startPosition = config.startPosition,
|
||||||
externalSubtitles = config.externalSubtitles,
|
externalSubtitles = config.externalSubtitles,
|
||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId
|
initialAudioId = config.initialAudioId,
|
||||||
|
cacheEnabled = config.cacheEnabled,
|
||||||
|
cacheSeconds = config.cacheSeconds,
|
||||||
|
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||||
|
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
@@ -236,6 +291,50 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback and release decoder resources.
|
||||||
|
*
|
||||||
|
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
||||||
|
* on a background thread (flushing the demuxer and releasing the
|
||||||
|
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
||||||
|
*
|
||||||
|
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
||||||
|
* nativeDestroy has an internal use-after-free on the JNI global ref
|
||||||
|
* path, so the native mpv handle is intentionally left for the JVM GC
|
||||||
|
* / native finalizer rather than torn down synchronously. See
|
||||||
|
* [MPVLib] class doc for the full rationale.
|
||||||
|
*
|
||||||
|
* Call this BEFORE navigating away from the player screen so the
|
||||||
|
* decoder is reclaimed before the next screen (or the next episode's
|
||||||
|
* player) mounts. Otherwise Expo Router renders the new screen first
|
||||||
|
* and you briefly have two mpv instances + two 4K decoders alive —
|
||||||
|
* instant OOM on a 2 GB device.
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
renderer?.stop()
|
||||||
|
|
||||||
|
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
||||||
|
// instance re-creates the mpv handle and re-attaches the still-live
|
||||||
|
// SurfaceView surface. Without this, rendererStarted stays true and
|
||||||
|
// ensureRendererStarted() early-returns, so renderer.start() is never
|
||||||
|
// called again — but stop() already nulled the renderer's mpv handle.
|
||||||
|
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
|
||||||
|
// against mpv == null, where every mpv?.command() (including the
|
||||||
|
// "stop" and load commands) silently no-ops, leaving a black frame.
|
||||||
|
//
|
||||||
|
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||||
|
// which call destroy() immediately before router.replace() to the
|
||||||
|
// same route — Expo Router reuses the same MpvPlayerView instance,
|
||||||
|
// so the next source load happens on this view without a remount.
|
||||||
|
//
|
||||||
|
// SurfaceView note: the surface is owned by the holder and survives
|
||||||
|
// across destroy()/loadVideo() on the same view instance. The next
|
||||||
|
// ensureRendererStarted() reads it from surfaceView.holder.surface.
|
||||||
|
rendererStarted = false
|
||||||
|
currentUrl = null
|
||||||
|
activeSurface = null
|
||||||
|
}
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
@@ -267,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
isWaitingForPiPTransition = true
|
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
// Resize buffer to match PiP window after animation settles
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
|
||||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
|
||||||
*/
|
|
||||||
private fun forcePiPBufferSize() {
|
|
||||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
|
||||||
|
|
||||||
val rect = Rect()
|
|
||||||
textureView.getGlobalVisibleRect(rect)
|
|
||||||
val visW = rect.width()
|
|
||||||
val visH = rect.height()
|
|
||||||
val vw = textureView.width
|
|
||||||
val vh = textureView.height
|
|
||||||
|
|
||||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
|
||||||
|
|
||||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
|
||||||
renderer?.updateSurfaceSize(visW, visH)
|
|
||||||
|
|
||||||
// Force TextureView layout to match PiP visible area.
|
|
||||||
// layoutParams alone doesn't work during PiP because the parent
|
|
||||||
// never re-lays out its children.
|
|
||||||
textureView.measure(
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
|
||||||
)
|
|
||||||
textureView.layout(0, 0, visW, visH)
|
|
||||||
isPiPSurfaceForced = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreFromPiP() {
|
|
||||||
if (!isPiPSurfaceForced) return
|
|
||||||
isPiPSurfaceForced = false
|
|
||||||
|
|
||||||
val lp = textureView.layoutParams
|
|
||||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
textureView.layoutParams = lp
|
|
||||||
textureView.requestLayout()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
@@ -479,13 +529,24 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactively tear down the player. Called from onDetachedFromWindow so
|
||||||
|
* the app releases mpv + decoder buffers when the View detaches from the
|
||||||
|
* window. The JS-facing destroy() is intentionally thinner (just
|
||||||
|
* renderer.stop()) — see this thread for why the full teardown was kept
|
||||||
|
* off the JS path.
|
||||||
|
*/
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
surfaceTexture = null
|
renderer?.delegate = null
|
||||||
|
|
||||||
|
// SurfaceView owns the Surface via its holder — do NOT release it.
|
||||||
|
activeSurface = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
|
currentUrl = null
|
||||||
|
rendererStarted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
|
// Independently tracks whether the system should auto-enter PiP on home
|
||||||
|
// press. Decoupled from playbackRate so that disabling auto-enter
|
||||||
|
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
||||||
|
// state that buildPiPActions() derives from playbackRate.
|
||||||
|
private var autoEnterEnabled: Boolean = false
|
||||||
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
// Disable auto-enter eligibility without touching playbackRate.
|
||||||
|
// playbackRate drives the play/pause icon in buildPiPActions();
|
||||||
|
// mutating it here would cause a stale icon if PiP is re-entered
|
||||||
|
// before the next playback state callback corrects it.
|
||||||
|
autoEnterEnabled = false
|
||||||
isInPiPMode = false
|
isInPiPMode = false
|
||||||
pipEntryNotified = false
|
pipEntryNotified = false
|
||||||
unregisterLifecycleCallbacks()
|
unregisterLifecycleCallbacks()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val activity = getActivity()
|
val activity = getActivity() ?: return
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
|
||||||
activity.moveTaskToBack(false)
|
// Push minimal params with just auto-enter disabled. Do NOT call
|
||||||
|
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
||||||
|
// setActions(), which would re-register the broadcast receiver
|
||||||
|
// (just unregistered above) and attach play/pause/skip actions to
|
||||||
|
// params being torn down. That leaves a live receiver + stale
|
||||||
|
// actions after the player has unmounted.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
try {
|
||||||
|
activity.setPictureInPictureParams(
|
||||||
|
PictureInPictureParams.Builder()
|
||||||
|
.setAutoEnterEnabled(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (activity.isInPictureInPictureMode) {
|
||||||
|
activity.moveTaskToBack(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
|
autoEnterEnabled = rate > 0
|
||||||
|
|
||||||
if (rate > 0) {
|
if (rate > 0) {
|
||||||
registerLifecycleCallbacks()
|
registerLifecycleCallbacks()
|
||||||
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
builder.setActions(buildPiPActions())
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
|
|||||||
@@ -1020,12 +1020,44 @@ final class MPVLayerRenderer {
|
|||||||
info["cacheSeconds"] = cacheSeconds
|
info["cacheSeconds"] = cacheSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured cache limits — read back from mpv to confirm user
|
||||||
|
// settings actually took effect. mpv stores byte sizes as int64
|
||||||
|
// (bytes); convert to MiB for display.
|
||||||
|
var demuxerMaxBytes: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
||||||
|
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
||||||
|
}
|
||||||
|
var demuxerMaxBackBytes: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
||||||
|
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
||||||
|
}
|
||||||
|
var cacheSecsLimit: Double = 0
|
||||||
|
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
||||||
|
info["cacheSecsLimit"] = cacheSecsLimit
|
||||||
|
}
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
var droppedFrames: Int64 = 0
|
var droppedFrames: Int64 = 0
|
||||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||||
info["droppedFrames"] = Int(droppedFrames)
|
info["droppedFrames"] = Int(droppedFrames)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active video output driver
|
||||||
|
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
||||||
|
info["voDriver"] = voDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active hardware decoder
|
||||||
|
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
||||||
|
info["hwdec"] = hwdec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimated video output fps (post-filter)
|
||||||
|
var estimatedVfFps: Double = 0
|
||||||
|
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
||||||
|
info["estimatedVfFps"] = estimatedVfFps
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ public class MpvPlayerModule: Module {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronously destroy mpv instance + decoder before navigating
|
||||||
|
// away from the player screen (cross-platform; matches Android).
|
||||||
|
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
view.seekTo(position: position)
|
view.seekTo(position: position)
|
||||||
|
|||||||
@@ -289,6 +289,49 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
||||||
|
* freed before the next screen mounts. Safe to call multiple times — the
|
||||||
|
* underlying renderer.stop() guards against re-entry.
|
||||||
|
*
|
||||||
|
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
||||||
|
*/
|
||||||
|
func destroy() {
|
||||||
|
renderer?.stop()
|
||||||
|
|
||||||
|
// Reset view state and re-create the mpv handle so a subsequent
|
||||||
|
// loadVideo() on the SAME view instance can actually load.
|
||||||
|
// Without this, stop() leaves renderer.mpv == nil, and the next
|
||||||
|
// loadVideo(config:) calls renderer.load() which early-returns
|
||||||
|
// at `guard let handle = self.mpv else { return }` — but only
|
||||||
|
// after flipping isLoading = true and dispatching the loading
|
||||||
|
// delegate callback, so the JS layer is stuck in a perpetual
|
||||||
|
// "loading" state with no actual playback.
|
||||||
|
//
|
||||||
|
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||||
|
// which call destroy() immediately before router.replace() to
|
||||||
|
// the same route — Expo Router reuses the same MpvPlayerView
|
||||||
|
// instance, so the next `source` prop update arrives on this
|
||||||
|
// view without a remount. setupView() is otherwise the only
|
||||||
|
// place start() is called, so without re-starting here the
|
||||||
|
// renderer stays dead until the whole view is unmounted and
|
||||||
|
// recreated.
|
||||||
|
//
|
||||||
|
// start() is idempotent (`guard !isRunning else { return }`)
|
||||||
|
// and stop() has already nulled mpv synchronously before
|
||||||
|
// dispatching the async mpv_terminate_destroy, so creating a
|
||||||
|
// fresh handle here is safe even while the old handle's
|
||||||
|
// teardown is still in flight on a background queue (libmpv
|
||||||
|
// handles are independent).
|
||||||
|
currentURL = nil
|
||||||
|
intendedPlayState = false
|
||||||
|
do {
|
||||||
|
try renderer?.start()
|
||||||
|
} catch {
|
||||||
|
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
|
|||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
||||||
|
* Call before navigating away from the player screen so memory is
|
||||||
|
* freed before the next screen mounts. Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
||||||
|
// stop: () => Promise<void>;
|
||||||
seekTo: (position: number) => Promise<void>;
|
seekTo: (position: number) => Promise<void>;
|
||||||
seekBy: (offset: number) => Promise<void>;
|
seekBy: (offset: number) => Promise<void>;
|
||||||
setSpeed: (speed: number) => Promise<void>;
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
@@ -154,9 +162,41 @@ export type TechnicalInfo = {
|
|||||||
videoBitrate?: number;
|
videoBitrate?: number;
|
||||||
audioBitrate?: number;
|
audioBitrate?: number;
|
||||||
cacheSeconds?: number;
|
cacheSeconds?: number;
|
||||||
|
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
||||||
|
demuxerMaxBytes?: number;
|
||||||
|
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
||||||
|
demuxerMaxBackBytes?: number;
|
||||||
|
/** Configured cache-secs floor, read back from mpv */
|
||||||
|
cacheSecsLimit?: number;
|
||||||
droppedFrames?: number;
|
droppedFrames?: number;
|
||||||
/** Active video output driver (read from MPV at runtime) */
|
/** Active video output driver (read from MPV at runtime) */
|
||||||
voDriver?: string;
|
voDriver?: string;
|
||||||
/** Active hardware decoder (read from MPV at runtime) */
|
/** Active hardware decoder (read from MPV at runtime) */
|
||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
|
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||||
|
estimatedVfFps?: number;
|
||||||
|
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
|
||||||
|
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
|
||||||
|
hdrFormat?: string;
|
||||||
|
/** Color space, e.g. "BT.709" / "BT.2020" */
|
||||||
|
colorSpace?: string;
|
||||||
|
/** Color range: "Limited" / "Full" */
|
||||||
|
colorRange?: string;
|
||||||
|
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
|
||||||
|
colorTransfer?: string;
|
||||||
|
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
|
||||||
|
decoderType?: string;
|
||||||
|
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
|
||||||
|
decoderName?: string;
|
||||||
|
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
|
||||||
|
audioChannels?: number;
|
||||||
|
/** Active audio sample rate in Hz */
|
||||||
|
audioSampleRate?: number;
|
||||||
|
/**
|
||||||
|
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
|
||||||
|
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
|
||||||
|
* users can decode this manually; it's how Jellyfin's HEVC level cap
|
||||||
|
* (153 = Level 5.1) is checked against the file.
|
||||||
|
*/
|
||||||
|
videoCodecs?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
pause: async () => {
|
pause: async () => {
|
||||||
await nativeRef.current?.pause();
|
await nativeRef.current?.pause();
|
||||||
},
|
},
|
||||||
|
destroy: async () => {
|
||||||
|
await nativeRef.current?.destroy();
|
||||||
|
},
|
||||||
seekTo: async (position: number) => {
|
seekTo: async (position: number) => {
|
||||||
await nativeRef.current?.seekTo(position);
|
await nativeRef.current?.seekTo(position);
|
||||||
},
|
},
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -17,13 +17,13 @@
|
|||||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "bun scripts/typecheck.ts",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@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/query-sync-storage-persister": "^5.100.14",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
@@ -134,8 +134,9 @@
|
|||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { withPodfile } = require("expo/config-plugins");
|
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||||
|
|
||||||
const PATCH_START = "## >>> runtime-framework headers";
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
const PATCH_END = "## <<< runtime-framework headers";
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
|||||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildPatch() {
|
function buildPatch(): string {
|
||||||
return [
|
return [
|
||||||
PATCH_START,
|
PATCH_START,
|
||||||
" extra_hdrs = [",
|
" extra_hdrs = [",
|
||||||
@@ -91,7 +91,7 @@ function buildPatch() {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function withRuntimeFrameworkHeaders(config) {
|
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
let podfile = config.modResults.contents;
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -125,3 +125,5 @@ end
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withRuntimeFrameworkHeaders;
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
const {
|
import {
|
||||||
|
type ConfigPlugin,
|
||||||
withAndroidColors,
|
withAndroidColors,
|
||||||
withAndroidColorsNight,
|
withAndroidColorsNight,
|
||||||
} = require("expo/config-plugins");
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const withAndroidAlertColors = (config) => {
|
interface ColorResourceItem {
|
||||||
const setColor = (colorsList, name, value) => {
|
$: { name: string };
|
||||||
|
_: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||||
|
const setColor = (
|
||||||
|
colorsList: ColorResourceItem[],
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
const existingColor = colorsList.find(
|
const existingColor = colorsList.find(
|
||||||
(item) => item.$ && item.$.name === name,
|
(item) => item.$ && item.$.name === name,
|
||||||
);
|
);
|
||||||
@@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColors(config, (config) => {
|
config = withAndroidColors(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = colors.resources.color || [];
|
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||||
setColor(colorsList, "colorPrimary", "#000000");
|
setColor(colorsList, "colorPrimary", "#000000");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColorsNight(config, (config) => {
|
config = withAndroidColorsNight(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = colors.resources.color || [];
|
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withAndroidAlertColors;
|
export default withAndroidAlertColors;
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
const { withAndroidManifest } = require("expo/config-plugins");
|
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
||||||
|
|
||||||
const _withGoogleCastAndroidManifest = (config) =>
|
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
||||||
withAndroidManifest(config, async (mod) => {
|
withAndroidManifest(config, async (mod) => {
|
||||||
const mainApplication = mod.modResults.manifest.application[0];
|
const mainApplication = mod.modResults.manifest.application?.[0];
|
||||||
|
|
||||||
|
if (!mainApplication) {
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
// Initialize activity array if it doesn't exist
|
||||||
if (!mainApplication.activity) {
|
if (!mainApplication.activity) {
|
||||||
@@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) =>
|
|||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = _withGoogleCastAndroidManifest;
|
export default withGoogleCastAndroidManifest;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
const { readFileSync, writeFileSync } = require("node:fs");
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
const { join } = require("node:path");
|
import { join } from "node:path";
|
||||||
const { withDangerousMod } = require("expo/config-plugins");
|
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||||
|
|
||||||
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
||||||
withDangerousMod(expoConfig, [
|
withDangerousMod(expoConfig, [
|
||||||
"android",
|
"android",
|
||||||
(modConfig) => {
|
(modConfig) => {
|
||||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
module.exports = withChangeNativeAndroidTextToWhite;
|
export default withChangeNativeAndroidTextToWhite;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { withAppBuildGradle } = require("expo/config-plugins");
|
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
||||||
|
|
||||||
module.exports = function withExcludeMedia3Dash(config) {
|
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
||||||
return withAppBuildGradle(config, (config) => {
|
return withAppBuildGradle(config, (config) => {
|
||||||
const contents = config.modResults.contents;
|
const contents = config.modResults.contents;
|
||||||
|
|
||||||
@@ -32,3 +32,5 @@ configurations.all {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withExcludeMedia3Dash;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
const { withPodfile } = require("@expo/config-plugins");
|
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||||
|
|
||||||
const withGitPod = (config, { podName, podspecUrl }) => {
|
interface GitPodOptions {
|
||||||
|
podName: string;
|
||||||
|
podspecUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withGitPod: ConfigPlugin<GitPodOptions> = (
|
||||||
|
config,
|
||||||
|
{ podName, podspecUrl },
|
||||||
|
) => {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
const podfile = config.modResults.contents;
|
const podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withGitPod;
|
export default withGitPod;
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
const { withGradleProperties } = require("expo/config-plugins");
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
import {
|
||||||
|
AndroidConfig,
|
||||||
|
type ConfigPlugin,
|
||||||
|
withGradleProperties,
|
||||||
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
function setGradlePropertiesValue(config, key, value) {
|
function setGradlePropertiesValue(
|
||||||
|
config: ExpoConfig,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): ExpoConfig {
|
||||||
return withGradleProperties(config, (exportedConfig) => {
|
return withGradleProperties(config, (exportedConfig) => {
|
||||||
const props = exportedConfig.modResults;
|
const props = exportedConfig.modResults;
|
||||||
const keyIdx = props.findIndex(
|
const keyIdx = props.findIndex(
|
||||||
(item) => item.type === "property" && item.key === key,
|
(item) => item.type === "property" && item.key === key,
|
||||||
);
|
);
|
||||||
const property = {
|
const property: AndroidConfig.Properties.PropertiesItem = {
|
||||||
type: "property",
|
type: "property",
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
@@ -22,11 +31,14 @@ function setGradlePropertiesValue(config, key, value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function withCustomPlugin(config) {
|
const withCustomGradleProperties: ConfigPlugin = (config) => {
|
||||||
// Expo 52 is not setting this
|
// Expo 52 is not setting this
|
||||||
// https://github.com/expo/expo/issues/32558
|
// https://github.com/expo/expo/issues/32558
|
||||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||||
|
|
||||||
|
// NDK version required by libmpv 1.0.0
|
||||||
|
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
||||||
|
|
||||||
// Increase memory
|
// Increase memory
|
||||||
config = setGradlePropertiesValue(
|
config = setGradlePropertiesValue(
|
||||||
config,
|
config,
|
||||||
@@ -35,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
|
|||||||
);
|
);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withCustomGradleProperties;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { withXcodeProject } = require("@expo/config-plugins");
|
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
|
||||||
|
|
||||||
const withTVOSAppIcon = (config) => {
|
const withTVOSAppIcon: ConfigPlugin = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVOSAppIcon;
|
export default withTVOSAppIcon;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
const {
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
import {
|
||||||
|
type ConfigPlugin,
|
||||||
withEntitlementsPlist,
|
withEntitlementsPlist,
|
||||||
withInfoPlist,
|
withInfoPlist,
|
||||||
withXcodeProject,
|
withXcodeProject,
|
||||||
} = require("@expo/config-plugins");
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||||
@@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
|||||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||||
"StreamyfinKeychainAccessGroupIdentifier";
|
"StreamyfinKeychainAccessGroupIdentifier";
|
||||||
|
|
||||||
function getBundleIdentifier(config) {
|
interface AppExtensionConfig {
|
||||||
|
targetName: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
entitlements: {
|
||||||
|
"com.apple.security.application-groups": string[];
|
||||||
|
"keychain-access-groups": string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundleIdentifier(config: ExpoConfig): string {
|
||||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppGroupIdentifier(config) {
|
function getAppGroupIdentifier(config: ExpoConfig): string {
|
||||||
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeychainAccessGroupIdentifier(config) {
|
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
|
||||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuildConfigurations(project, configurationListId) {
|
// The xcode project object has no usable typings — keep `any` here.
|
||||||
|
function getBuildConfigurations(project: any, configurationListId: string) {
|
||||||
const configurationList =
|
const configurationList =
|
||||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||||
|
|
||||||
@@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) {
|
|||||||
|
|
||||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||||
return configurationList.buildConfigurations
|
return configurationList.buildConfigurations
|
||||||
.map((config) => buildConfigurations[config.value])
|
.map((config: { value: string }) => buildConfigurations[config.value])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppGroup(value, appGroupIdentifier) {
|
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(appGroupIdentifier)
|
return groups.includes(appGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
: [...groups, appGroupIdentifier];
|
: [...groups, appGroupIdentifier];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
function ensureKeychainAccessGroup(
|
||||||
|
value: unknown,
|
||||||
|
keychainAccessGroupIdentifier: string,
|
||||||
|
): string[] {
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(keychainAccessGroupIdentifier)
|
return groups.includes(keychainAccessGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
@@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppExtension(
|
function ensureAppExtension(
|
||||||
appExtensions,
|
appExtensions: unknown,
|
||||||
targetName,
|
targetName: string,
|
||||||
bundleIdentifier,
|
bundleIdentifier: string,
|
||||||
appGroupIdentifier,
|
appGroupIdentifier: string,
|
||||||
keychainAccessGroupIdentifier,
|
keychainAccessGroupIdentifier: string,
|
||||||
) {
|
): AppExtensionConfig[] {
|
||||||
const extensionConfig = {
|
const extensionConfig: AppExtensionConfig = {
|
||||||
targetName,
|
targetName,
|
||||||
bundleIdentifier,
|
bundleIdentifier,
|
||||||
entitlements: {
|
entitlements: {
|
||||||
@@ -63,7 +78,9 @@ function ensureAppExtension(
|
|||||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
|
||||||
|
? appExtensions
|
||||||
|
: [];
|
||||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||||
const existingIndex = extensions.findIndex(
|
const existingIndex = extensions.findIndex(
|
||||||
(appExtension) => appExtension?.targetName === targetName,
|
(appExtension) => appExtension?.targetName === targetName,
|
||||||
@@ -78,7 +95,7 @@ function ensureAppExtension(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTVOSTopShelf = (config) => {
|
const withTVOSTopShelf: ConfigPlugin = (config) => {
|
||||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||||
const keychainAccessGroupIdentifier =
|
const keychainAccessGroupIdentifier =
|
||||||
getKeychainAccessGroupIdentifier(config);
|
getKeychainAccessGroupIdentifier(config);
|
||||||
@@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVOSTopShelf;
|
export default withTVOSTopShelf;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const { withEntitlementsPlist } = require("expo/config-plugins");
|
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||||
*/
|
*/
|
||||||
const withTVUserManagement = (config) => {
|
const withTVUserManagement: ConfigPlugin = (config) => {
|
||||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||||
// be present in the provisioning profile, so injecting it into mobile builds
|
// be present in the provisioning profile, so injecting it into mobile builds
|
||||||
// breaks signing ("Entitlement ... not found and could not be included in
|
// breaks signing ("Entitlement ... not found and could not be included in
|
||||||
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withTVUserManagement;
|
export default withTVUserManagement;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const { withDangerousMod } = require("@expo/config-plugins");
|
import { execSync } from "node:child_process";
|
||||||
const { execSync } = require("node:child_process");
|
import fs from "node:fs";
|
||||||
const fs = require("node:fs");
|
import path from "node:path";
|
||||||
const path = require("node:path");
|
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
||||||
@@ -12,7 +12,7 @@ const path = require("node:path");
|
|||||||
*
|
*
|
||||||
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
||||||
*/
|
*/
|
||||||
const withTVXcodeEnv = (config) => {
|
const withTVXcodeEnv: ConfigPlugin = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => {
|
|||||||
/**
|
/**
|
||||||
* Get the actual node binary path, handling nvm installations.
|
* Get the actual node binary path, handling nvm installations.
|
||||||
*/
|
*/
|
||||||
function getNodeBinaryPath() {
|
function getNodeBinaryPath(): string | null {
|
||||||
try {
|
try {
|
||||||
// First try to get node path directly (works for non-nvm installs)
|
// First try to get node path directly (works for non-nvm installs)
|
||||||
const directPath = execSync("which node 2>/dev/null", {
|
const directPath = execSync("which node 2>/dev/null", {
|
||||||
@@ -114,4 +114,4 @@ function getNodeBinaryPath() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withTVXcodeEnv;
|
export default withTVXcodeEnv;
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
|
import fs from "node:fs";
|
||||||
const path = require("node:path");
|
import path from "node:path";
|
||||||
const fs = require("node:fs");
|
import {
|
||||||
|
AndroidConfig,
|
||||||
|
type ConfigPlugin,
|
||||||
|
type ExportedConfigWithProps,
|
||||||
|
withAndroidManifest,
|
||||||
|
} from "expo/config-plugins";
|
||||||
|
|
||||||
const fsPromises = fs.promises;
|
const fsPromises = fs.promises;
|
||||||
|
|
||||||
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
||||||
|
|
||||||
const withTrustLocalCerts = (config) => {
|
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
||||||
|
|
||||||
|
const withTrustLocalCerts: ConfigPlugin = (config) => {
|
||||||
return withAndroidManifest(config, async (mod) => {
|
return withAndroidManifest(config, async (mod) => {
|
||||||
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setCustomConfigAsync(config, androidManifest) {
|
async function setCustomConfigAsync(
|
||||||
|
config: ExportedConfigWithProps<AndroidManifest>,
|
||||||
|
androidManifest: AndroidManifest,
|
||||||
|
): Promise<AndroidManifest> {
|
||||||
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
||||||
const res_file_path = path.join(
|
const res_file_path = path.join(
|
||||||
await AndroidConfig.Paths.getResourceFolderAsync(
|
await AndroidConfig.Paths.getResourceFolderAsync(
|
||||||
@@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) {
|
|||||||
return androidManifest;
|
return androidManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = withTrustLocalCerts;
|
export default withTrustLocalCerts;
|
||||||
@@ -7,7 +7,7 @@ import type React from "react";
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import { settingsAtom } from "@/utils/atoms/settings";
|
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
@@ -78,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate device profile for MPV player
|
// Match the device profile to the actually-active player so the
|
||||||
|
// server picks codecs/containers the player can decode.
|
||||||
const native = generateDeviceProfile({
|
const native = generateDeviceProfile({
|
||||||
platform: Platform.OS as "ios" | "android",
|
platform: Platform.OS as "ios" | "android",
|
||||||
player: "mpv",
|
player: getActivePlayerType(settings),
|
||||||
audioMode: settings.audioTranscodeMode,
|
audioMode: settings.audioTranscodeMode,
|
||||||
});
|
});
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +34,20 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||||
|
|
||||||
|
interface I18nConfig {
|
||||||
|
localesDir: string;
|
||||||
|
sourceLocale: string;
|
||||||
|
srcDirs: string[];
|
||||||
|
srcExtensions: string[];
|
||||||
|
excludeDirs: string[];
|
||||||
|
ignoreUnused: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const flag = (name, def) => {
|
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
if (!a) return def;
|
if (!a) return def;
|
||||||
const [, v] = a.split("=");
|
const [, v] = a.split("=");
|
||||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
|||||||
|
|
||||||
// ---- config ----
|
// ---- config ----
|
||||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG: I18nConfig = {
|
||||||
localesDir: "translations",
|
localesDir: "translations",
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
|||||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
ignoreUnused: [],
|
ignoreUnused: [],
|
||||||
};
|
};
|
||||||
const config = existsSync(CONFIG_PATH)
|
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
? {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||||
|
}
|
||||||
: DEFAULT_CONFIG;
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
const flatten = (obj, prefix = "", out = {}) => {
|
const flatten = (
|
||||||
|
obj: LocaleTree,
|
||||||
|
prefix = "",
|
||||||
|
out: Record<string, string> = {},
|
||||||
|
): Record<string, string> => {
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
else out[key] = v;
|
else out[key] = v as string;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globMatch = (key, pattern) => {
|
const globMatch = (key: string, pattern: string): boolean => {
|
||||||
if (pattern.endsWith(".*"))
|
if (pattern.endsWith(".*"))
|
||||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
return key === pattern;
|
return key === pattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (dir, files = []) => {
|
const walk = (dir: string, files: string[] = []): string[] => {
|
||||||
let entries;
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
|||||||
}
|
}
|
||||||
for (const name of entries) {
|
for (const name of entries) {
|
||||||
const full = join(dir, name);
|
const full = join(dir, name);
|
||||||
let st;
|
let st: ReturnType<typeof statSync>;
|
||||||
try {
|
try {
|
||||||
st = statSync(full);
|
st = statSync(full);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
|||||||
// ---- load source keys ----
|
// ---- load source keys ----
|
||||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
const sourceKeys = Object.keys(
|
const sourceKeys = Object.keys(
|
||||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||||
);
|
);
|
||||||
const sourceKeySet = new Set(sourceKeys);
|
const sourceKeySet = new Set(sourceKeys);
|
||||||
|
|
||||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
|||||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||||
|
|
||||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||||
const dynamicPrefixes = new Set();
|
const dynamicPrefixes = new Set<string>();
|
||||||
const fullyDynamic = []; // { file, line }
|
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||||
|
|
||||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
// `://` inside string URLs intact.
|
// `://` inside string URLs intact.
|
||||||
const stripComments = (src) =>
|
const stripComments = (src: string): string =>
|
||||||
src
|
src
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
|||||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||||
const literalUsed = (key) =>
|
const literalUsed = (key: string): boolean =>
|
||||||
codeBlob.includes(`"${key}"`) ||
|
codeBlob.includes(`"${key}"`) ||
|
||||||
codeBlob.includes(`'${key}'`) ||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
codeBlob.includes(`\`${key}\``);
|
codeBlob.includes(`\`${key}\``);
|
||||||
const isUsed = (key) =>
|
const isUsed = (key: string): boolean =>
|
||||||
literalUsed(key) ||
|
literalUsed(key) ||
|
||||||
prefixList.some((p) => key.startsWith(p)) ||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
|||||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||||
const removeKey = (obj, parts) => {
|
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||||
const [head, ...rest] = parts;
|
const [head, ...rest] = parts;
|
||||||
if (!(head in obj)) return;
|
if (!(head in obj)) return;
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
delete obj[head];
|
delete obj[head];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeKey(obj[head], rest);
|
const child = obj[head];
|
||||||
if (
|
if (!child || typeof child !== "object") return;
|
||||||
obj[head] &&
|
removeKey(child, rest);
|
||||||
typeof obj[head] === "object" &&
|
if (Object.keys(child).length === 0) delete obj[head];
|
||||||
Object.keys(obj[head]).length === 0
|
|
||||||
)
|
|
||||||
delete obj[head];
|
|
||||||
};
|
};
|
||||||
if (FIX_UNUSED && unused.length) {
|
if (FIX_UNUSED && unused.length) {
|
||||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||||
// the keys from them automatically on the next sync once they disappear from the source.
|
// the keys from them automatically on the next sync once they disappear from the source.
|
||||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||||
for (const key of unused) removeKey(data, key.split("."));
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
|||||||
);
|
);
|
||||||
for (const k of unused) console.log(` - ${k}`);
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
@@ -21,8 +21,14 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||||
const numEnv = (name, def) => {
|
const numEnv = (name: string, def: number): number => {
|
||||||
const raw = process.env[name];
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw === "") return def;
|
if (raw === undefined || raw === "") return def;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
|||||||
).split(/\s+/),
|
).split(/\s+/),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||||
|
|
||||||
const tokens = (s) =>
|
const tokens = (s: string | null): string[] =>
|
||||||
(s || "")
|
(s || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
|||||||
.map(stem)
|
.map(stem)
|
||||||
.filter((w) => w.length > 2);
|
.filter((w) => w.length > 2);
|
||||||
|
|
||||||
const jaccard = (a, b) => {
|
const jaccard = (a: string[], b: string[]): number => {
|
||||||
const A = new Set(a);
|
const A = new Set(a);
|
||||||
const B = new Set(b);
|
const B = new Set(b);
|
||||||
if (!A.size || !B.size) return 0;
|
if (!A.size || !B.size) return 0;
|
||||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
|||||||
|
|
||||||
const newTitle = tokens(TITLE);
|
const newTitle = tokens(TITLE);
|
||||||
const newBody = tokens(BODY);
|
const newBody = tokens(BODY);
|
||||||
const score = (o) =>
|
const score = (o: Issue): number =>
|
||||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||||
0.4 * jaccard(newBody, tokens(o.body));
|
0.4 * jaccard(newBody, tokens(o.body));
|
||||||
|
|
||||||
// fetch open issues (excluding PRs and the new issue itself)
|
// fetch open issues (excluding PRs and the new issue itself)
|
||||||
let issues;
|
let issues: Issue[];
|
||||||
if (process.env.DUP_FIXTURE) {
|
if (process.env.DUP_FIXTURE) {
|
||||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||||
} else {
|
} else {
|
||||||
const raw = execFileSync(
|
const raw = execFileSync(
|
||||||
"gh",
|
"gh",
|
||||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
|||||||
issues = raw
|
issues = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((l) => JSON.parse(l));
|
.map((l) => JSON.parse(l) as Issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = issues
|
const matches = issues
|
||||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
|||||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||||
const safeTitle = (t) =>
|
const safeTitle = (t: string): string =>
|
||||||
(t || "")
|
(t || "")
|
||||||
.replace(/@/g, "@")
|
.replace(/@/g, "@")
|
||||||
.replace(/[`<>|*_~[\]]/g, " ")
|
.replace(/[`<>|*_~[\]]/g, " ")
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const _fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const process = require("node:process");
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
// const tvosPath = path.join(root, 'iostv');
|
|
||||||
// const iosPath = path.join(root, 'iosmobile');
|
|
||||||
// const androidPath = path.join(root, 'androidmobile');
|
|
||||||
// const androidTVPath = path.join(root, 'androidtv');
|
|
||||||
// const device = process.argv[2];
|
|
||||||
// const platform = process.argv[2];
|
|
||||||
const isTV = process.env.EXPO_TV || false;
|
|
||||||
|
|
||||||
const paths = new Map([
|
|
||||||
["tvos", path.join(root, "iostv")],
|
|
||||||
["ios", path.join(root, "iosmobile")],
|
|
||||||
["android", path.join(root, "androidmobile")],
|
|
||||||
["androidtv", path.join(root, "androidtv")],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// const platformPath = paths.get(platform);
|
|
||||||
|
|
||||||
if (isTV) {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
|
||||||
"androidtv",
|
|
||||||
)} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
} else {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// target = "";
|
|
||||||
// switch (platform) {
|
|
||||||
// case "tvos":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "ios":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "android":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// case "androidtv":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
import { execFileSync } from "node:child_process";
|
||||||
const process = require("node:process");
|
import { createRequire } from "node:module";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// Enhanced ANSI color codes and styles
|
// Enhanced ANSI color codes and styles
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
|||||||
|
|
||||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||||
|
|
||||||
function log(message, color = "") {
|
function log(message: string, color = "") {
|
||||||
if (useColor && color) {
|
if (useColor && color) {
|
||||||
console.log(`${color}${message}${colors.reset}`);
|
console.log(`${color}${message}${colors.reset}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(errorLine) {
|
function formatError(errorLine: string): string {
|
||||||
if (!useColor) return errorLine;
|
if (!useColor) return errorLine;
|
||||||
|
|
||||||
// Color file paths in cyan
|
// Color file paths in cyan
|
||||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorsAndCreateSummary(errorOutput) {
|
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||||
|
formattedErrors: string[];
|
||||||
|
errorsByFile: Map<string, number>;
|
||||||
|
} {
|
||||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||||
const errorsByFile = new Map();
|
const errorsByFile = new Map<string, number>();
|
||||||
const formattedErrors = [];
|
const formattedErrors: string[] = [];
|
||||||
|
|
||||||
let currentError = [];
|
let currentError: string[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
if (!errorsByFile.has(filePath)) {
|
if (!errorsByFile.has(filePath)) {
|
||||||
errorsByFile.set(filePath, 0);
|
errorsByFile.set(filePath, 0);
|
||||||
}
|
}
|
||||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||||
|
|
||||||
// Start new error
|
// Start new error
|
||||||
currentError.push(formatError(line));
|
currentError.push(formatError(line));
|
||||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
return { formattedErrors, errorsByFile };
|
return { formattedErrors, errorsByFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createErrorSummaryTable(errorsByFile) {
|
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||||
if (errorsByFile.size === 0) return "";
|
if (errorsByFile.size === 0) return "";
|
||||||
|
|
||||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTypeCheck() {
|
function runTypeCheck(): { ok: boolean } {
|
||||||
const extraArgs = process.argv.slice(2);
|
const extraArgs = process.argv.slice(2);
|
||||||
|
|
||||||
// Prefer local TypeScript binary when available
|
// Prefer local TypeScript binary when available
|
||||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
|||||||
"false",
|
"false",
|
||||||
...extraArgs,
|
...extraArgs,
|
||||||
];
|
];
|
||||||
let execArgs = null;
|
let execArgs: { cmd: string; args: string[] };
|
||||||
try {
|
try {
|
||||||
const tscBin = require.resolve("typescript/bin/tsc");
|
const tscBin = require.resolve("typescript/bin/tsc");
|
||||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||||
} catch {
|
} catch {
|
||||||
// fallback to PATH tsc
|
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||||
execArgs = {
|
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||||
cmd: "tsc",
|
|
||||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
|||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
const execError = error as { stderr?: string; stdout?: string };
|
||||||
|
const errorOutput = [execError.stdout, execError.stderr]
|
||||||
|
.filter((chunk): chunk is string => Boolean(chunk))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||||
|
// launch failure fall through to the "passed" branch and green-light CI.
|
||||||
|
if (!errorOutput) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log(
|
||||||
|
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||||
|
colors.red,
|
||||||
|
);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||||
// that generates a large volume of known type errors
|
// that generates a large volume of known type errors
|
||||||
@@ -65,7 +65,7 @@ final class TopShelfProvider: TVTopShelfContentProvider {
|
|||||||
|
|
||||||
let item = TVTopShelfSectionedItem(identifier: cacheItem.id)
|
let item = TVTopShelfSectionedItem(identifier: cacheItem.id)
|
||||||
item.title = cacheItem.title
|
item.title = cacheItem.title
|
||||||
item.imageShape = .poster
|
item.imageShape = .hdtv
|
||||||
item.displayAction = TVTopShelfAction(url: route)
|
item.displayAction = TVTopShelfAction(url: route)
|
||||||
|
|
||||||
if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) {
|
if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) {
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "لا شيء",
|
"None": "لا شيء",
|
||||||
"OnlyForced": "فقط الإجبارية"
|
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "أخرى",
|
"other_title": "أخرى",
|
||||||
"video_orientation": "اتجاه الفيديو",
|
"video_orientation": "اتجاه الفيديو",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "غير معروف"
|
"UNKNOWN": "غير معروف"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||||
"video_player": "مشغل الفيديو",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||||
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||||
"hide_libraries": "إخفاء المكتبات",
|
"hide_libraries": "إخفاء المكتبات",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||||
"disabled": "معطل"
|
"disabled": "معطل"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "التنزيلات"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "الموسيقى",
|
||||||
"playback_title": "التشغيل",
|
"playback_title": "التشغيل",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "الإضافات",
|
"plugins_title": "الإضافات",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "هذا الربط في مراحله الأولى. توقع حدوث تغييرات.",
|
|
||||||
"server_url": "رابط الخادم",
|
"server_url": "رابط الخادم",
|
||||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
||||||
"server_url_placeholder": "رابط Seerr...",
|
"server_url_placeholder": "رابط Seerr...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||||
"save_button": "حفظ",
|
"save_button": "حفظ",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ"
|
||||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "تفعيل Streamystats",
|
|
||||||
"disable_streamystats": "تعطيل Streamystats",
|
"disable_streamystats": "تعطيل Streamystats",
|
||||||
"enable_search": "استخدم للبحث",
|
"enable_search": "استخدم للبحث",
|
||||||
"url": "الرابط",
|
"url": "الرابط",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
||||||
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
||||||
"save_button": "حفظ",
|
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"features_title": "المميزات",
|
"features_title": "المميزات",
|
||||||
"home_sections_title": "أقسام الرئيسية",
|
|
||||||
"enable_movie_recommendations": "توصيات الأفلام",
|
"enable_movie_recommendations": "توصيات الأفلام",
|
||||||
"enable_series_recommendations": "توصيات المسلسلات",
|
"enable_series_recommendations": "توصيات المسلسلات",
|
||||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا"
|
||||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
|
||||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "النظام"
|
"system": "النظام"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "خطأ في حذف الملفات",
|
"error_deleting_files": "خطأ في حذف الملفات"
|
||||||
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
|
||||||
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "التنزيلات",
|
"downloads_title": "التنزيلات",
|
||||||
"series": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"queue": "قائمة الانتظار",
|
|
||||||
"other_media": "وسائط أخرى",
|
"other_media": "وسائط أخرى",
|
||||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
|
||||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
|
||||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||||
"delete_all_series_button": "حذف جميع المسلسلات",
|
"delete_all_series_button": "حذف جميع المسلسلات",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
||||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||||
"download_deleted": "تم حذف التنزيل",
|
|
||||||
"download_cancelled": "تم إلغاء التنزيل",
|
"download_cancelled": "تم إلغاء التنزيل",
|
||||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
|
||||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
|
||||||
"download_resumed": "تم استئناف التنزيل",
|
|
||||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
|
||||||
"download_completed": "اكتمل التنزيل",
|
"download_completed": "اكتمل التنزيل",
|
||||||
"download_failed": "فشل التنزيل",
|
"download_failed": "فشل التنزيل",
|
||||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
||||||
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
||||||
"files_deleted_by_type": "تم حذف {{count}} {{type}}",
|
"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}}",
|
"could_not_get_download_url_for_item": "تعذر الحصول على عنوان URL للتنزيل لـ{{itemName}}",
|
||||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
|
||||||
"file_deleted": "تم حذف {{item}}"
|
"file_deleted": "تم حذف {{item}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"track": "أغنية",
|
"track": "أغنية",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"ok": "حسناً",
|
"ok": "حسناً",
|
||||||
"remove": "إزالة",
|
"remove": "إزالة",
|
||||||
"next": "التالي",
|
|
||||||
"back": "رجوع",
|
"back": "رجوع",
|
||||||
"continue": "متابعة",
|
"continue": "متابعة",
|
||||||
"verifying": "جارٍ التحقق...",
|
"verifying": "جارٍ التحقق...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "بحث...",
|
"search": "بحث...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||||
"next_episode": "الحلقة التالية",
|
"next_episode": "الحلقة التالية",
|
||||||
"refresh_tracks": "تحديث المسارات",
|
|
||||||
"audio_tracks": "مسارات الصوت:",
|
|
||||||
"playback_state": "حالة التشغيل:",
|
|
||||||
"index": "الفِهْرِس:",
|
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"go_back": "رجوع",
|
"go_back": "رجوع",
|
||||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "عرض المزيد",
|
"show_more": "عرض المزيد",
|
||||||
"show_less": "عرض أقل",
|
"show_less": "عرض أقل",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "قوائم التشغيل",
|
"playlists": "قوائم التشغيل",
|
||||||
"tracks": "الأغاني"
|
"tracks": "الأغاني"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "الكل"
|
|
||||||
},
|
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
"recently_played": "تم تشغيله مؤخرًا",
|
"recently_played": "تم تشغيله مؤخرًا",
|
||||||
"frequently_played": "الأكثر تشغيلاً",
|
"frequently_played": "الأكثر تشغيلاً",
|
||||||
"explore": "اكتشف",
|
|
||||||
"top_tracks": "أفضل الأغاني",
|
"top_tracks": "أفضل الأغاني",
|
||||||
"play": "تشغيل",
|
"play": "تشغيل",
|
||||||
"shuffle": "ترتيب عشوائي",
|
"shuffle": "ترتيب عشوائي",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Cap",
|
"None": "Cap",
|
||||||
"OnlyForced": "Només els forçats"
|
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Altres",
|
"other_title": "Altres",
|
||||||
"video_orientation": "Orientació del vídeo",
|
"video_orientation": "Orientació del vídeo",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Desconeguda"
|
"UNKNOWN": "Desconeguda"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Àrea segura als controls",
|
"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_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Oculta biblioteques",
|
"hide_libraries": "Oculta biblioteques",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||||
"disabled": "Desactivat"
|
"disabled": "Desactivat"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Descàrregues"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Connectors",
|
"plugins_title": "Connectors",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Aquesta integració es troba en una versió primerenca. Espereu que les coses canviïn.",
|
|
||||||
"server_url": "URL del servidor",
|
"server_url": "URL del servidor",
|
||||||
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
|
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
|
||||||
"server_url_placeholder": "URL de Jellyseerr...",
|
"server_url_placeholder": "URL de Jellyseerr...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||||
"save_button": "Desa",
|
"save_button": "Desa",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Desat",
|
"saved": "Desat"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error en suprimir fitxers",
|
"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"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Descàrregues",
|
"downloads_title": "Descàrregues",
|
||||||
"series": "Sèries",
|
"series": "Sèries",
|
||||||
"movies": "Pel·lícules",
|
"movies": "Pel·lícules",
|
||||||
"queue": "Cua",
|
|
||||||
"other_media": "Other media",
|
"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",
|
"no_downloaded_items": "No hi ha elements descarregats",
|
||||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||||
"delete_all_series_button": "Suprimeix totes les sèries",
|
"delete_all_series_button": "Suprimeix totes les sèries",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Download Deleted",
|
|
||||||
"download_cancelled": "Descàrrega cancel·lada",
|
"download_cancelled": "Descàrrega cancel·lada",
|
||||||
"could_not_delete_download": "Could Not Delete Download",
|
"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_completed": "Descàrrega completada",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"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}}",
|
"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"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||||
"message_from_server": "Missatge del servidor: {{message}}",
|
"message_from_server": "Missatge del servidor: {{message}}",
|
||||||
"next_episode": "Episodi següent",
|
"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",
|
"continue_watching": "Continuar veient",
|
||||||
"go_back": "Enrere",
|
"go_back": "Enrere",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Mostra més",
|
"show_more": "Mostra més",
|
||||||
"show_less": "Mostra menys",
|
"show_less": "Mostra menys",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Següent",
|
"next": "Següent",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Nic",
|
"None": "Nic",
|
||||||
"OnlyForced": "Pouze vynucené"
|
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Ostatní",
|
"other_title": "Ostatní",
|
||||||
"video_orientation": "Orientace videa",
|
"video_orientation": "Orientace videa",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Neznámý"
|
"UNKNOWN": "Neznámý"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
"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_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||||
"hide_libraries": "Skrýt knihovny",
|
"hide_libraries": "Skrýt knihovny",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
||||||
"disabled": "Zakázáno"
|
"disabled": "Zakázáno"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Stahování"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Pluginy",
|
"plugins_title": "Pluginy",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Tato integrace je v raných fázích. Očekávejte změnu situace.",
|
|
||||||
"server_url": "URL serveru",
|
"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_hint": "Příklad: http(s)://your-host.url\n(přidat port, pokud je vyžadováno)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
||||||
"save_button": "Uložit",
|
"save_button": "Uložit",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Uloženo",
|
"saved": "Uloženo"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Systém"
|
"system": "Systém"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Chyba při mazání souborů",
|
"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"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Stahování",
|
"downloads_title": "Stahování",
|
||||||
"series": "Televizní série",
|
"series": "Televizní série",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"queue": "Fronta",
|
|
||||||
"other_media": "Ostatní média",
|
"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",
|
"no_downloaded_items": "Žádné stažené položky",
|
||||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||||
"delete_all_series_button": "Odstranit všechny TV-série",
|
"delete_all_series_button": "Odstranit všechny TV-série",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
||||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||||
"download_deleted": "Stahování smazáno",
|
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Stahování nelze odstranit",
|
"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_completed": "Stahování dokončeno",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"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}}",
|
"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"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Hledat...",
|
"search": "Hledat...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||||
"next_episode": "Další epizoda",
|
"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í",
|
"continue_watching": "Pokračovat ve sledování",
|
||||||
"go_back": "Zpět",
|
"go_back": "Zpět",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Zobrazit více",
|
"show_more": "Zobrazit více",
|
||||||
"show_less": "Zobrazit méně",
|
"show_less": "Zobrazit méně",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Další",
|
"next": "Další",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Ingen",
|
"None": "Ingen",
|
||||||
"OnlyForced": "Kun tvungne undertekster"
|
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Andet",
|
"other_title": "Andet",
|
||||||
"video_orientation": "Videoorientering",
|
"video_orientation": "Videoorientering",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Ukendt"
|
"UNKNOWN": "Ukendt"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
"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_custom_menu_links": "Vis tilpassede menulinks",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
||||||
"disabled": "Deaktiveret"
|
"disabled": "Deaktiveret"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Denne integration er i en tidlig fase. Forvent, at tingene ændres.",
|
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Eksempel: http(s)://din-host.url\n(tilføj port hvis nødvendigt)",
|
"server_url_hint": "Eksempel: http(s)://din-host.url\n(tilføj port hvis nødvendigt)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Læs mere om Marlin.",
|
"read_more_about_marlin": "Læs mere om Marlin.",
|
||||||
"save_button": "Gem",
|
"save_button": "Gem",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Gemt",
|
"saved": "Gemt"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Fejl ved sletning af filer",
|
"error_deleting_files": "Fejl ved sletning af filer"
|
||||||
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
|
||||||
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "TV-serier",
|
"series": "TV-serier",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Kø",
|
|
||||||
"other_media": "Andre medier",
|
"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",
|
"no_downloaded_items": "Ingen downloadede elementer",
|
||||||
"delete_all_movies_button": "Slet alle film",
|
"delete_all_movies_button": "Slet alle film",
|
||||||
"delete_all_series_button": "Slet alle TV-serier",
|
"delete_all_series_button": "Slet alle TV-serier",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
"download_deleted": "Download Slettet",
|
|
||||||
"download_cancelled": "Download afbrudt",
|
"download_cancelled": "Download afbrudt",
|
||||||
"could_not_delete_download": "Kunne Ikke Slette Download",
|
"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_completed": "Download fuldført",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"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}}",
|
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
||||||
"go_to_downloads": "Gå til downloads",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Søg...",
|
"search": "Søg...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||||
"message_from_server": "Besked fra server: {{message}}",
|
"message_from_server": "Besked fra server: {{message}}",
|
||||||
"next_episode": "Næste episode",
|
"next_episode": "Næste episode",
|
||||||
"refresh_tracks": "Opdater spor",
|
|
||||||
"audio_tracks": "Lydspor:",
|
|
||||||
"playback_state": "Afspilningstilstand:",
|
|
||||||
"index": "Indeks:",
|
|
||||||
"continue_watching": "Fortsæt med at se",
|
"continue_watching": "Fortsæt med at se",
|
||||||
"go_back": "Gå Tilbage",
|
"go_back": "Gå Tilbage",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Vis mere",
|
"show_more": "Vis mere",
|
||||||
"show_less": "Vis mindre",
|
"show_less": "Vis mindre",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Næste",
|
"next": "Næste",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"error_title": "Fehler",
|
"error_title": "Fehler",
|
||||||
"login_title": "Anmelden",
|
"login_title": "Anmelden",
|
||||||
"login_to_title": "Anmelden bei",
|
"login_to_title": "Anmelden bei",
|
||||||
"select_user": "Select a user to log in",
|
"select_user": "Benutzer zum Anmelden auswählen",
|
||||||
"add_user_to_login": "Add a user to log in",
|
"add_user_to_login": "Zum Anmelden einen Benutzer hinzufügen",
|
||||||
"add_user": "Add User",
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Benutzername",
|
"username_placeholder": "Benutzername",
|
||||||
"password_placeholder": "Passwort",
|
"password_placeholder": "Passwort",
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
"add_account": "Konto hinzufügen",
|
"add_account": "Konto hinzufügen",
|
||||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
||||||
"remove_server": "Remove Server",
|
"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",
|
"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",
|
"add_server": "Add Server",
|
||||||
"change_server": "Change Server"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"oops": "Ups!",
|
"oops": "Ups!",
|
||||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||||
"continue_watching": "Weiterschauen",
|
"continue_watching": "Weiterschauen",
|
||||||
"continue": "Continue",
|
"continue": "Weiter",
|
||||||
"next_up": "Als nächstes",
|
"next_up": "Als nächstes",
|
||||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||||
@@ -121,9 +121,9 @@
|
|||||||
"log_out_button": "Abmelden",
|
"log_out_button": "Abmelden",
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Benutzerkonto",
|
||||||
"switch_user": "Switch User on This Server",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "current"
|
"current": "aktuell"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategorien"
|
"title": "Kategorien"
|
||||||
@@ -143,9 +143,9 @@
|
|||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
"theme_music": "Theme Music",
|
"theme_music": "Theme Music",
|
||||||
"display_size": "Display Size",
|
"display_size": "Display Size",
|
||||||
"display_size_small": "Small",
|
"display_size_small": "Klein",
|
||||||
"display_size_default": "Default",
|
"display_size_default": "Standard",
|
||||||
"display_size_large": "Large",
|
"display_size_large": "Groß",
|
||||||
"display_size_extra_large": "Extra Large"
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
"cache_auto": "Auto",
|
"cache_auto": "Auto",
|
||||||
"cache_yes": "Enabled",
|
"cache_yes": "Aktiviert",
|
||||||
"cache_no": "Disabled",
|
"cache_no": "Deaktiviert",
|
||||||
"buffer_duration": "Buffer Duration",
|
"buffer_duration": "Buffer Duration",
|
||||||
"max_cache_size": "Max Cache Size",
|
"max_cache_size": "Max Cache Size",
|
||||||
"max_backward_cache": "Max Backward Cache"
|
"max_backward_cache": "Max Backward Cache"
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"vo_driver": {
|
"vo_driver": {
|
||||||
"title": "Video Output",
|
"title": "Video Output",
|
||||||
"vo_mode": "VO Driver",
|
"vo_mode": "VO Driver",
|
||||||
"gpu_next": "gpu-next (Recommended)",
|
"gpu_next": "gpu-next (empfohlen)",
|
||||||
"gpu": "gpu"
|
"gpu": "gpu"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
@@ -261,79 +261,23 @@
|
|||||||
"None": "Keine",
|
"None": "Keine",
|
||||||
"OnlyForced": "Nur erzwungene"
|
"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_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": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
"opensubtitles_api_key_placeholder": "API-Schüssel eingeben ...",
|
||||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
"opensubtitles_get_key": "Holen Sie sich Ihren kostenlosen API-Schlüssel unter opensubtitles.com/de/consumers",
|
||||||
"mpv_subtitle_scale": "Subtitle Scale",
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal Align",
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
"mpv_subtitle_align_y": "Vertical Align",
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Left",
|
"left": "Links",
|
||||||
"center": "Center",
|
"center": "Mittig",
|
||||||
"right": "Right",
|
"right": "Rechts",
|
||||||
"top": "Top",
|
"top": "Oben",
|
||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Sonstiges",
|
"other_title": "Sonstiges",
|
||||||
"video_orientation": "Videoausrichtung",
|
"video_orientation": "Videoausrichtung",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Unbekannt"
|
"UNKNOWN": "Unbekannt"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
"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_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||||
"hide_libraries": "Bibliotheken ausblenden",
|
"hide_libraries": "Bibliotheken ausblenden",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||||
"disabled": "Deaktiviert"
|
"disabled": "Deaktiviert"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
"playback_title": "Wiedergabe",
|
"playback_title": "Wiedergabe",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.",
|
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
|
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||||
"save_button": "Speichern",
|
"save_button": "Speichern",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Gespeichert",
|
"saved": "Gespeichert"
|
||||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Streamystats aktivieren",
|
|
||||||
"disable_streamystats": "Streamystats deaktivieren",
|
"disable_streamystats": "Streamystats deaktivieren",
|
||||||
"enable_search": "Zum Suchen verwenden",
|
"enable_search": "Zum Suchen verwenden",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||||
"save_button": "Speichern",
|
|
||||||
"save": "Gespeichert",
|
"save": "Gespeichert",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Startseitenbereiche",
|
|
||||||
"enable_movie_recommendations": "Filmempfehlungen",
|
"enable_movie_recommendations": "Filmempfehlungen",
|
||||||
"enable_series_recommendations": "Serienempfehlungen",
|
"enable_series_recommendations": "Serienempfehlungen",
|
||||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
"watchlist_enabler": "Merklisten-Integration aktivieren"
|
||||||
"watchlist_button": "Merklisten-Integration umschalten"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||||
"music_cache_title": "Musik-Cache",
|
"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",
|
"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",
|
"clear_music_cache": "Musik-Cache leeren",
|
||||||
"music_cache_size": "{{size}} gechached",
|
"music_cache_size": "{{size}} gechached",
|
||||||
"music_cache_cleared": "Musik-Cache geleert",
|
"music_cache_cleared": "Musik-Cache geleert",
|
||||||
@@ -466,10 +394,8 @@
|
|||||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_success": "Cache Cleared",
|
"clear_all_cache_error_desc": "Beim Löschen des Caches ist ein Fehler aufgetreten."
|
||||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Einführung",
|
"title": "Einführung",
|
||||||
@@ -490,23 +416,20 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
"error_deleting_files": "Fehler beim Löschen von Dateien"
|
||||||
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
|
||||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Sicherheit",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
"disabled": "Deaktiviert",
|
||||||
"disabled": "Disabled",
|
"1_minute": "1 Minute",
|
||||||
"1_minute": "1 minute",
|
"5_minutes": "5 Minuten",
|
||||||
"5_minutes": "5 minutes",
|
"15_minutes": "15 Minuten",
|
||||||
"15_minutes": "15 minutes",
|
"30_minutes": "30 Minuten",
|
||||||
"30_minutes": "30 minutes",
|
"1_hour": "1 Stunde",
|
||||||
"1_hour": "1 hour",
|
"4_hours": "4 Stunden",
|
||||||
"4_hours": "4 hours",
|
"24_hours": "24 Stunden"
|
||||||
"24_hours": "24 hours"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"queue": "Warteschlange",
|
|
||||||
"other_media": "Andere Medien",
|
"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",
|
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||||
"delete_all_movies_button": "Alle Filme löschen",
|
"delete_all_movies_button": "Alle Filme löschen",
|
||||||
"delete_all_series_button": "Alle Serien löschen",
|
"delete_all_series_button": "Alle Serien löschen",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
||||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||||
"download_deleted": "Download gelöscht",
|
|
||||||
"download_cancelled": "Download abgebrochen",
|
"download_cancelled": "Download abgebrochen",
|
||||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
"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_completed": "Download abgeschlossen",
|
||||||
"download_failed": "Download fehlgeschlagen",
|
"download_failed": "Download fehlgeschlagen",
|
||||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} Lädt",
|
"item_already_downloading": "{{item}} Lädt",
|
||||||
"all_files_deleted": "Alle Downloads gelöscht",
|
"all_files_deleted": "Alle Downloads gelöscht",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} 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",
|
"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"
|
"file_deleted": "{{item}} gelöscht"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"track": "Spur",
|
"track": "Spur",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"next": "Weiter",
|
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"continue": "Fortsetzen",
|
"continue": "Fortsetzen",
|
||||||
"verifying": "Verifiziere...",
|
"verifying": "Verifiziere...",
|
||||||
"login": "Login",
|
"login": "Anmelden",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Suchen...",
|
"search": "Suchen...",
|
||||||
@@ -641,7 +554,7 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"boxsets": "Boxsets",
|
"boxsets": "Boxsets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Wiedergabelisten",
|
||||||
"items": "Elemente"
|
"items": "Elemente"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -653,7 +566,7 @@
|
|||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Titel anzeigen",
|
"show_titles": "Titel anzeigen",
|
||||||
"show_stats": "Statistiken anzeigen",
|
"show_stats": "Statistiken anzeigen",
|
||||||
"options_title": "Options"
|
"options_title": "Optionen"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -662,10 +575,10 @@
|
|||||||
"filter_by": "Filtern nach",
|
"filter_by": "Filtern nach",
|
||||||
"sort_order": "Sortierreihenfolge",
|
"sort_order": "Sortierreihenfolge",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"all": "All",
|
"all": "Alle",
|
||||||
"reset": "Reset",
|
"reset": "Zurücksetzen",
|
||||||
"asc": "Ascending",
|
"asc": "Aufsteigend",
|
||||||
"desc": "Descending"
|
"desc": "Absteigend"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||||
"next_episode": "Nächste Episode",
|
"next_episode": "Nächste Episode",
|
||||||
"refresh_tracks": "Spuren aktualisieren",
|
|
||||||
"audio_tracks": "Audiospuren:",
|
|
||||||
"playback_state": "Wiedergabestatus:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Fortsetzen",
|
"continue_watching": "Fortsetzen",
|
||||||
"go_back": "Zurück",
|
"go_back": "Zurück",
|
||||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||||
@@ -702,34 +611,35 @@
|
|||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen",
|
"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}}",
|
"ends_at": "Endet um {{time}}",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracks",
|
"subtitle_tracks": "Titel",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Download",
|
"download": "Herunterladen",
|
||||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
"subtitle_download_hint": "Heruntergeladene Untertitel werden in Ihrer Bibliothek gespeichert",
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Sprache",
|
||||||
"results": "Results",
|
"results": "Ergebnisse",
|
||||||
"searching": "Searching...",
|
"searching": "Suche ...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Suche fehlgeschlagen",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
"add_opensubtitles_key_hint": "OpenSubtitles API-Schlüssel in den Einstellungen für Client-seitigen Fallback hinzufügen",
|
||||||
"settings": "Settings",
|
"settings": "Einstellungen",
|
||||||
"skip_intro": "Skip Intro",
|
"skip_intro": "Skip Intro",
|
||||||
"skip_credits": "Skip Credits",
|
"skip_credits": "Skip Credits",
|
||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Wiedergabe von \"{{title}}\" beenden?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Bist du sicher, dass du die Wiedergabe beenden möchtest?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Heruntergeladen",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Kapitel",
|
||||||
"chapter_number": "Chapter {{number}}",
|
"chapter_number": "Kapitel {{number}}",
|
||||||
"open": "Open chapters",
|
"open": "Kapitel öffnen",
|
||||||
"close": "Close chapters"
|
"close": "Kapitel schließen"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
@@ -754,20 +664,19 @@
|
|||||||
"quality": "Qualität",
|
"quality": "Qualität",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Subtitle",
|
"label": "Untertitel",
|
||||||
"none": "None",
|
"none": "Keine",
|
||||||
"tracks": "Tracks"
|
"tracks": "Titel"
|
||||||
},
|
},
|
||||||
"show_more": "Mehr anzeigen",
|
"show_more": "Mehr anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
"left": "left",
|
"left": "übrig",
|
||||||
"more_info": "More Info",
|
"director": "Regisseur*in",
|
||||||
"director": "Director",
|
"cast": "Besetzung",
|
||||||
"cast": "Cast",
|
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Erschien in",
|
"appeared_in": "Erschien in",
|
||||||
"movies": "Movies",
|
"movies": "Filme",
|
||||||
"shows": "Shows",
|
"shows": "Serien",
|
||||||
"could_not_load_item": "Konnte Element nicht laden",
|
"could_not_load_item": "Konnte Element nicht laden",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -782,9 +691,10 @@
|
|||||||
"mark_played": "Mark as Watched",
|
"mark_played": "Mark as Watched",
|
||||||
"mark_unplayed": "Mark as Unwatched",
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
"resume_playback": "Resume Playback",
|
"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",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Weiter ab {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Nächste",
|
"next": "Nächste",
|
||||||
@@ -796,16 +706,16 @@
|
|||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Für Kinder",
|
"for_kids": "Für Kinder",
|
||||||
"news": "Nachrichten",
|
"news": "Nachrichten",
|
||||||
"page_of": "Page {{current}} of {{total}}",
|
"page_of": "Seite {{current}} von {{total}}",
|
||||||
"no_programs": "No programs available",
|
"no_programs": "Keine Programme verfügbar",
|
||||||
"no_channels": "No channels available",
|
"no_channels": "Keine Kanäle verfügbar",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programs",
|
"programs": "Programme",
|
||||||
"guide": "Guide",
|
"guide": "Führer",
|
||||||
"channels": "Channels",
|
"channels": "Kanäle",
|
||||||
"recordings": "Recordings",
|
"recordings": "Aufzeichnungen",
|
||||||
"schedule": "Schedule",
|
"schedule": "Planung",
|
||||||
"series": "Series"
|
"series": "Serien"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -851,12 +761,12 @@
|
|||||||
"decline": "Ablehnen",
|
"decline": "Ablehnen",
|
||||||
"requested_by": "Angefragt von {{user}}",
|
"requested_by": "Angefragt von {{user}}",
|
||||||
"unknown_user": "Unbekannter Nutzer",
|
"unknown_user": "Unbekannter Nutzer",
|
||||||
"select": "Select",
|
"select": "Auswählen",
|
||||||
"request_all": "Request All",
|
"request_all": "Request All",
|
||||||
"request_seasons": "Request Seasons",
|
"request_seasons": "Request Seasons",
|
||||||
"select_seasons": "Select Seasons",
|
"select_seasons": "Select Seasons",
|
||||||
"request_selected": "Request Selected",
|
"request_selected": "Request Selected",
|
||||||
"n_selected": "{{count}} selected",
|
"n_selected": "{{count}} ausgewählt",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
"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.",
|
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
@@ -877,7 +787,7 @@
|
|||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
"custom_links": "Links",
|
"custom_links": "Links",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"settings": "Settings"
|
"settings": "Einstellungen"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "Titel"
|
"tracks": "Titel"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Alle"
|
|
||||||
},
|
|
||||||
"recently_added": "Kürzlich hinzugefügt",
|
"recently_added": "Kürzlich hinzugefügt",
|
||||||
"recently_played": "Vor kurzem gehört",
|
"recently_played": "Vor kurzem gehört",
|
||||||
"frequently_played": "Oft gehört",
|
"frequently_played": "Oft gehört",
|
||||||
"explore": "Entdecken",
|
|
||||||
"top_tracks": "Top-Titel",
|
"top_tracks": "Top-Titel",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1004,34 +910,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Pair with TV",
|
"title": "Mit TV koppeln",
|
||||||
"align_qr": "Align the QR code within the frame",
|
"align_qr": "Den QR-Code innerhalb des Rahmens ausrichten",
|
||||||
"enter_code_manually": "Enter code manually",
|
"enter_code_manually": "Code manuell eingeben",
|
||||||
"pairing_enter_credentials": "Enter credentials for TV",
|
"pairing_enter_credentials": "Anmeldedaten für TV eingeben",
|
||||||
"pairing_code_label": "Pairing code",
|
"pairing_code_label": "Kopplungscode",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"authorize_button": "Authorize",
|
"authorize_button": "Autorisieren",
|
||||||
"authorizing": "Authorizing...",
|
"authorizing": "Autorisieren...",
|
||||||
"scan_again": "Scan Again",
|
"scan_again": "Scan Again",
|
||||||
"done": "Done",
|
"done": "Fertig",
|
||||||
"success_title": "Authorization Sent",
|
"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_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
"error_invalid_qr": "Ungültiger QR-Code. Bitte scannen Sie den TV-Kopplungscode.",
|
||||||
"error_generic": "Something went wrong. Please try again.",
|
"error_generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
"error_permission_denied": "Kameraberechtigung erforderlich zum Scannen von QR-Codes.",
|
||||||
"login_as": "Log in as {{username}}?",
|
"login_as": "Als {{username}} anmelden?",
|
||||||
"on_server": "on {{server}}",
|
"on_server": "auf {{server}}",
|
||||||
"use_different_user": "Use a different user",
|
"use_different_user": "Verwende einen anderen Benutzer",
|
||||||
"open_settings": "Open Settings"
|
"open_settings": "Einstellungen öffnen"
|
||||||
},
|
},
|
||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
"waiting_for_phone": "Warte auf Telefon...",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"scan_with_phone": "Scanne mit der Streamyfin-App auf deinem Handy",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"logging_in": "Anmeldung...",
|
||||||
"logging_in": "Logging in...",
|
"logging_in_description": "Verbinde mit deinem Server"
|
||||||
"logging_in_description": "Connecting to your server"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Κανένα",
|
"None": "Κανένα",
|
||||||
"OnlyForced": "Μόνο"
|
"OnlyForced": "Μόνο"
|
||||||
},
|
},
|
||||||
"text_color": "Χρώμα Κειμένου",
|
|
||||||
"background_color": "Χρώμα Φόντου",
|
|
||||||
"outline_color": "Χρώμα Περιγράμματος",
|
|
||||||
"outline_thickness": "Πάχος Περιγράμματος",
|
|
||||||
"background_opacity": "Αδιαφάνεια Φόντου",
|
|
||||||
"outline_opacity": "Αδιαφάνεια Περιγράμματος",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Μαύρο",
|
|
||||||
"Gray": "Γκρι",
|
|
||||||
"Silver": "Ασημένιο",
|
|
||||||
"White": "Λευκό",
|
|
||||||
"Maroon": "Μαρώ",
|
|
||||||
"Red": "Κόκκινο",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Κίτρινο",
|
|
||||||
"Olive": "Ελιές",
|
|
||||||
"Green": "Πράσινο",
|
|
||||||
"Teal": "Τιρκουάζ",
|
|
||||||
"Lime": "Άσβεστος",
|
|
||||||
"Purple": "Μωβ",
|
|
||||||
"Navy": "Ναυτικό",
|
|
||||||
"Blue": "Μπλε",
|
|
||||||
"Aqua": "Νερό"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Κανένα",
|
|
||||||
"Thin": "Λεπτό",
|
|
||||||
"Normal": "Κανονικό",
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Άλλο",
|
"other_title": "Άλλο",
|
||||||
"video_orientation": "Προσανατολισμός Βίντεο",
|
"video_orientation": "Προσανατολισμός Βίντεο",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Άγνωστο"
|
"UNKNOWN": "Άγνωστο"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
||||||
"video_player": "Αναπαραγωγέας Βίντεο",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Πειραματική + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
||||||
"disabled": "Απενεργοποιημένο"
|
"disabled": "Απενεργοποιημένο"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Λήψεις"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Πρόσθετα",
|
"plugins_title": "Πρόσθετα",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Αυτή η ενσωμάτωση βρίσκεται στα αρχικά της στάδια.",
|
|
||||||
"server_url": "Url Διακομιστή",
|
"server_url": "Url Διακομιστή",
|
||||||
"server_url_hint": "Παράδειγμα: http(s)://your-host.url\n(προσθέστε θύρα εφόσον απαιτείται)",
|
"server_url_hint": "Παράδειγμα: http(s)://your-host.url\n(προσθέστε θύρα εφόσον απαιτείται)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
||||||
"save_button": "Αποθήκευση",
|
"save_button": "Αποθήκευση",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Αποθηκεύτηκε",
|
"saved": "Αποθηκεύτηκε"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Σύστημα"
|
"system": "Σύστημα"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
|
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων"
|
||||||
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
|
|
||||||
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Λήψεις",
|
"downloads_title": "Λήψεις",
|
||||||
"series": "Τηλεόραση-Σειρά",
|
"series": "Τηλεόραση-Σειρά",
|
||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
"queue": "Ουρά",
|
|
||||||
"other_media": "Άλλα μέσα",
|
"other_media": "Άλλα μέσα",
|
||||||
"queue_hint": "Ουρά και λήψεις θα χαθούν κατά την επανεκκίνηση της εφαρμογής",
|
|
||||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
|
||||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||||
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||||
"download_deleted": "Η Λήψη Διαγράφηκε",
|
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
||||||
"download_paused": "Λήψη Σε Παύση",
|
|
||||||
"could_not_pause_download": "Αδυναμία Παύσης Λήψης",
|
|
||||||
"download_resumed": "Συνέχιση Λήψης",
|
|
||||||
"could_not_resume_download": "Αδυναμία Συνέχισης Λήψης",
|
|
||||||
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Όλα τα αρχεία, οι φάκελοι και οι εργασίες διαγράφηκαν με επιτυχία",
|
|
||||||
"failed_to_clean_cache_directory": "Αποτυχία καθαρισμού φακέλου προσωρινής μνήμης",
|
|
||||||
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
||||||
"go_to_downloads": "Μετάβαση στις λήψεις",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Αναζήτηση...",
|
"search": "Αναζήτηση...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
||||||
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
||||||
"next_episode": "Επόμενο Επεισόδιο",
|
"next_episode": "Επόμενο Επεισόδιο",
|
||||||
"refresh_tracks": "Ανανέωση Κομματιών",
|
|
||||||
"audio_tracks": "Κομμάτια Ήχου:",
|
|
||||||
"playback_state": "Κατάσταση Αναπαραγωγής:",
|
|
||||||
"index": "Δείκτης:",
|
|
||||||
"continue_watching": "Συνέχεια Παρακολούθησης",
|
"continue_watching": "Συνέχεια Παρακολούθησης",
|
||||||
"go_back": "Μετάβαση Πίσω",
|
"go_back": "Μετάβαση Πίσω",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Εμφάνιση Περισσότερων",
|
"show_more": "Εμφάνιση Περισσότερων",
|
||||||
"show_less": "Εμφάνιση Λιγότερων",
|
"show_less": "Εμφάνιση Λιγότερων",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Επόμενο",
|
"next": "Επόμενο",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -199,6 +199,13 @@
|
|||||||
"rewind_length": "Rewind length",
|
"rewind_length": "Rewind length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"exoplayer": "ExoPlayer",
|
||||||
|
"mpv": "MPV",
|
||||||
|
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
|
||||||
|
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
|
||||||
|
},
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Buffer settings",
|
"title": "Buffer settings",
|
||||||
"cache_mode": "Cache mode",
|
"cache_mode": "Cache mode",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Nada",
|
"None": "Nada",
|
||||||
"OnlyForced": "Solo forzados"
|
"OnlyForced": "Solo forzados"
|
||||||
},
|
},
|
||||||
"text_color": "Color del texto",
|
|
||||||
"background_color": "Color de fondo",
|
|
||||||
"outline_color": "Color de salida",
|
|
||||||
"outline_thickness": "Grosor exterior",
|
|
||||||
"background_opacity": "Opacidad de fondo",
|
|
||||||
"outline_opacity": "Opacidad exterior",
|
|
||||||
"bold_text": "Texto en negrita",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Negro",
|
|
||||||
"Gray": "Gris",
|
|
||||||
"Silver": "Plata",
|
|
||||||
"White": "Blanco",
|
|
||||||
"Maroon": "Granate",
|
|
||||||
"Red": "Rojo",
|
|
||||||
"Fuchsia": "Fucsia",
|
|
||||||
"Yellow": "Amarillo",
|
|
||||||
"Olive": "Oliva",
|
|
||||||
"Green": "Verde",
|
|
||||||
"Teal": "Cereal",
|
|
||||||
"Lime": "Lima",
|
|
||||||
"Purple": "Morado",
|
|
||||||
"Navy": "Naval",
|
|
||||||
"Blue": "Azul",
|
|
||||||
"Aqua": "Agua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Ninguno",
|
|
||||||
"Thin": "Ligero",
|
|
||||||
"Normal": "Normal",
|
|
||||||
"Thick": "Grosor"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Color de los Subtítulos",
|
|
||||||
"subtitle_background_color": "Color del fondo",
|
|
||||||
"subtitle_font": "Fuente de los subtítulos",
|
|
||||||
"ksplayer_title": "Ajustes de KSPlayer",
|
|
||||||
"hardware_decode": "Decodificación de hardware",
|
|
||||||
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "Configuración de subtítulos VLC",
|
|
||||||
"hint": "Personalizar la apariencia de los subtítulos para el reproductor VLC. Los cambios tendrán efecto en la próxima reproducción.",
|
|
||||||
"text_color": "Color del texto",
|
|
||||||
"background_color": "Color del fondo",
|
|
||||||
"background_opacity": "Opacidad del fondo",
|
|
||||||
"outline_color": "Color del contorno",
|
|
||||||
"outline_opacity": "Opacidad del contorno",
|
|
||||||
"outline_thickness": "Grosor del contorno",
|
|
||||||
"bold": "Texto en negrita",
|
|
||||||
"margin": "Margen inferior"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Reproductor de vídeo",
|
|
||||||
"video_player": "Reproductor de vídeo",
|
|
||||||
"video_player_description": "Elige qué reproductor de vídeo en iOS",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Otros",
|
"other_title": "Otros",
|
||||||
"video_orientation": "Orientación de vídeo",
|
"video_orientation": "Orientación de vídeo",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Desconocida"
|
"UNKNOWN": "Desconocida"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Área segura en controles",
|
"safe_area_in_controls": "Área segura en controles",
|
||||||
"video_player": "Reproductor de vídeo",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||||
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||||
"disabled": "Deshabilitado"
|
"disabled": "Deshabilitado"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Descargas"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Música",
|
"title": "Música",
|
||||||
"playback_title": "Reproducir",
|
"playback_title": "Reproducir",
|
||||||
@@ -378,13 +314,12 @@
|
|||||||
"caching_title": "Almacenando en caché",
|
"caching_title": "Almacenando en caché",
|
||||||
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
|
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
|
||||||
"lookahead_enabled": "Activar el look-Ahead Cache",
|
"lookahead_enabled": "Activar el look-Ahead Cache",
|
||||||
"lookahead_count": "",
|
"lookahead_count": "Songs to pre-cache",
|
||||||
"max_cache_size": "Tamaño máximo del caché"
|
"max_cache_size": "Tamaño máximo del caché"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Esta integración está en sus primeras etapas. Cuenta con posibles cambios.",
|
|
||||||
"server_url": "URL del servidor",
|
"server_url": "URL del servidor",
|
||||||
"server_url_hint": "Ejemplo: http(s)://tu-dominio.url\n(añade el puerto si es necesario)",
|
"server_url_hint": "Ejemplo: http(s)://tu-dominio.url\n(añade el puerto si es necesario)",
|
||||||
"server_url_placeholder": "URL de Jellyseerr...",
|
"server_url_placeholder": "URL de Jellyseerr...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Leer más sobre Marlin.",
|
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||||
"save_button": "Guardar",
|
"save_button": "Guardar",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Guardado",
|
"saved": "Guardado"
|
||||||
"refreshed": "Ajustes del servidor actualizados"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Actualizar ajustes del servidor"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Habilitar Streamystats",
|
|
||||||
"disable_streamystats": "Deshabilitar Streamystats",
|
"disable_streamystats": "Deshabilitar Streamystats",
|
||||||
"enable_search": "Usar para la búsqueda",
|
"enable_search": "Usar para la búsqueda",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
||||||
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||||
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
||||||
"save_button": "Guardar",
|
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"features_title": "Características",
|
"features_title": "Características",
|
||||||
"home_sections_title": "Secciones de inicio",
|
|
||||||
"enable_movie_recommendations": "Recomendaciones de películas",
|
"enable_movie_recommendations": "Recomendaciones de películas",
|
||||||
"enable_series_recommendations": "Recomendaciones de series",
|
"enable_series_recommendations": "Recomendaciones de series",
|
||||||
"enable_promoted_watchlists": "Listas promocionadas",
|
"enable_promoted_watchlists": "Listas promocionadas",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento",
|
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento"
|
||||||
"watchlist_button": "Activar o desactivar la integración de la lista de seguimiento"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
||||||
"music_cache_title": "Caché de música",
|
"music_cache_title": "Caché de música",
|
||||||
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
|
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
|
||||||
"enable_music_cache": "Activar Caché de Música",
|
|
||||||
"clear_music_cache": "Borrar Caché de Música",
|
"clear_music_cache": "Borrar Caché de Música",
|
||||||
"music_cache_size": "Caché {{Tamaño}}",
|
"music_cache_size": "Caché {{Tamaño}}",
|
||||||
"music_cache_cleared": "Caché de música eliminado",
|
"music_cache_cleared": "Caché de música eliminado",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error al eliminar archivos",
|
"error_deleting_files": "Error al eliminar archivos"
|
||||||
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
|
||||||
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Descargas",
|
"downloads_title": "Descargas",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
"queue": "Cola",
|
|
||||||
"other_media": "Otros medios",
|
"other_media": "Otros medios",
|
||||||
"queue_hint": "La cola de series y películas se perderá al reiniciar la app",
|
|
||||||
"no_items_in_queue": "No hay ítems en la cola",
|
|
||||||
"no_downloaded_items": "No hay ítems descargados",
|
"no_downloaded_items": "No hay ítems descargados",
|
||||||
"delete_all_movies_button": "Eliminar todas las películas",
|
"delete_all_movies_button": "Eliminar todas las películas",
|
||||||
"delete_all_series_button": "Eliminar todas las series",
|
"delete_all_series_button": "Eliminar todas las series",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Error al eliminar todas las series",
|
"failed_to_delete_all_series": "Error al eliminar todas las series",
|
||||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||||
"download_deleted": "Descarga eliminada",
|
|
||||||
"download_cancelled": "Descarga cancelada",
|
"download_cancelled": "Descarga cancelada",
|
||||||
"could_not_delete_download": "No se pudo eliminar la descarga",
|
"could_not_delete_download": "No se pudo eliminar la descarga",
|
||||||
"download_paused": "Descarga pausada",
|
|
||||||
"could_not_pause_download": "No se pudo pausar la descarga",
|
|
||||||
"download_resumed": "Descarga rebatida",
|
|
||||||
"could_not_resume_download": "No se pudo reiniciar la descarga",
|
|
||||||
"download_completed": "Descarga completada",
|
"download_completed": "Descarga completada",
|
||||||
"download_failed": "Descarga fallida",
|
"download_failed": "Descarga fallida",
|
||||||
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} ya está descargando",
|
"item_already_downloading": "{{item}} ya está descargando",
|
||||||
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Todos los archivos, carpetas y trabajos eliminados con éxito",
|
|
||||||
"failed_to_clean_cache_directory": "Error al limpiar el directorio de caché",
|
|
||||||
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
||||||
"go_to_downloads": "Ir a descargas",
|
|
||||||
"file_deleted": "{{item}} eliminado"
|
"file_deleted": "{{item}} eliminado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Nada",
|
"none": "Nada",
|
||||||
"track": "Pista",
|
"track": "Pista",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
"ok": "Aceptar",
|
"ok": "Aceptar",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
"next": "Siguiente",
|
|
||||||
"back": "Atrás",
|
"back": "Atrás",
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"verifying": "Verificando...",
|
"verifying": "Verificando...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Buscar...",
|
"search": "Buscar...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||||
"next_episode": "Siguiente episodio",
|
"next_episode": "Siguiente episodio",
|
||||||
"refresh_tracks": "Refrescar pistas",
|
|
||||||
"audio_tracks": "Pistas de audio:",
|
|
||||||
"playback_state": "Estado de la reproducción:",
|
|
||||||
"index": "Índice:",
|
|
||||||
"continue_watching": "Continuar viendo",
|
"continue_watching": "Continuar viendo",
|
||||||
"go_back": "Volver",
|
"go_back": "Volver",
|
||||||
"downloaded_file_title": "Ya tienes este archivo descargado",
|
"downloaded_file_title": "Ya tienes este archivo descargado",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Mostrar más",
|
"show_more": "Mostrar más",
|
||||||
"show_less": "Mostrar menos",
|
"show_less": "Mostrar menos",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Listas de reproducción",
|
"playlists": "Listas de reproducción",
|
||||||
"tracks": "Canciones"
|
"tracks": "Canciones"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Todas"
|
|
||||||
},
|
|
||||||
"recently_added": "Recientemente añadido",
|
"recently_added": "Recientemente añadido",
|
||||||
"recently_played": "Reproducidos Recientemente",
|
"recently_played": "Reproducidos Recientemente",
|
||||||
"frequently_played": "Reproducido con frecuencia",
|
"frequently_played": "Reproducido con frecuencia",
|
||||||
"explore": "Explorar",
|
|
||||||
"top_tracks": "Canciones Populares",
|
"top_tracks": "Canciones Populares",
|
||||||
"play": "Reproducir",
|
"play": "Reproducir",
|
||||||
"shuffle": "Aleatorio",
|
"shuffle": "Aleatorio",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Ei mitään",
|
"None": "Ei mitään",
|
||||||
"OnlyForced": "Vain pakotettu"
|
"OnlyForced": "Vain pakotettu"
|
||||||
},
|
},
|
||||||
"text_color": "Tekstin väri",
|
|
||||||
"background_color": "Taustaväri",
|
|
||||||
"outline_color": "Ääriviivan väri",
|
|
||||||
"outline_thickness": "Ääriviivan paksuus",
|
|
||||||
"background_opacity": "Taustan läpinäkyvyys",
|
|
||||||
"outline_opacity": "Ääriviivan Läpinäkyvyys",
|
|
||||||
"bold_text": "Lihavoi teksti",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Musta",
|
|
||||||
"Gray": "Harmaa",
|
|
||||||
"Silver": "Hopea",
|
|
||||||
"White": "Valkoinen",
|
|
||||||
"Maroon": "Maroon",
|
|
||||||
"Red": "Punainen",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Keltainen",
|
|
||||||
"Olive": "Oliivit",
|
|
||||||
"Green": "Vihreä",
|
|
||||||
"Teal": "Sinappi",
|
|
||||||
"Lime": "Limea",
|
|
||||||
"Purple": "Violetti",
|
|
||||||
"Navy": "Laiva",
|
|
||||||
"Blue": "Sininen",
|
|
||||||
"Aqua": "Vesi"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Ei mitään",
|
|
||||||
"Thin": "Ohut",
|
|
||||||
"Normal": "Normaali",
|
|
||||||
"Thick": "Paksu"
|
|
||||||
},
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Muut",
|
"other_title": "Muut",
|
||||||
"video_orientation": "Videon suunta",
|
"video_orientation": "Videon suunta",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Tuntematon"
|
"UNKNOWN": "Tuntematon"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Turvallinen alue ohjaimissa",
|
"safe_area_in_controls": "Turvallinen alue ohjaimissa",
|
||||||
"video_player": "Videosoitin",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Kokeellinen + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
||||||
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
||||||
"hide_libraries": "Piilota kirjastot",
|
"hide_libraries": "Piilota kirjastot",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
||||||
"disabled": "Pois Käytöstä"
|
"disabled": "Pois Käytöstä"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Lataukset"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Liitännäiset",
|
"plugins_title": "Liitännäiset",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Tämä integraatio on alkuvaiheessa. Odota muutoksia.",
|
|
||||||
"server_url": "Palvelimen URL",
|
"server_url": "Palvelimen URL",
|
||||||
"server_url_hint": "Esimerkki: http(s)://verkkotunnus.url\n(lisää portti tarvittaessa)",
|
"server_url_hint": "Esimerkki: http(s)://verkkotunnus.url\n(lisää portti tarvittaessa)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Lue lisää Marlinista.",
|
"read_more_about_marlin": "Lue lisää Marlinista.",
|
||||||
"save_button": "Tallenna",
|
"save_button": "Tallenna",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Tallennettu",
|
"saved": "Tallennettu"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Järjestelmä"
|
"system": "Järjestelmä"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Virhe tiedostojen poistamisessa",
|
"error_deleting_files": "Virhe tiedostojen poistamisessa"
|
||||||
"background_downloads_enabled": "Taustalataukset käytössä",
|
|
||||||
"background_downloads_disabled": "Taustalataukset pois käytöstä"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Lataukset",
|
"downloads_title": "Lataukset",
|
||||||
"series": "TV-sarjat",
|
"series": "TV-sarjat",
|
||||||
"movies": "Elokuvat",
|
"movies": "Elokuvat",
|
||||||
"queue": "Jonot",
|
|
||||||
"other_media": "Muu media",
|
"other_media": "Muu media",
|
||||||
"queue_hint": "Jonot ja lataukset menetetään sovelluksen uudelleenkäynnistyksen yhteydessä",
|
|
||||||
"no_items_in_queue": "Ei kohteita jonossa",
|
|
||||||
"no_downloaded_items": "Ei ladattuja kohteita",
|
"no_downloaded_items": "Ei ladattuja kohteita",
|
||||||
"delete_all_movies_button": "Poista kaikki elokuvat",
|
"delete_all_movies_button": "Poista kaikki elokuvat",
|
||||||
"delete_all_series_button": "Poista kaikki TV-sarjat",
|
"delete_all_series_button": "Poista kaikki TV-sarjat",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||||
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
||||||
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
||||||
"download_deleted": "Lataus Poistettu",
|
|
||||||
"download_cancelled": "Lataus peruutettu",
|
"download_cancelled": "Lataus peruutettu",
|
||||||
"could_not_delete_download": "Latausta Ei Voitu Poistaa",
|
"could_not_delete_download": "Latausta Ei Voitu Poistaa",
|
||||||
"download_paused": "Lataus Keskeytetty",
|
|
||||||
"could_not_pause_download": "Latausta Ei Voitu Keskeyttää",
|
|
||||||
"download_resumed": "Lataus Jatketaan",
|
|
||||||
"could_not_resume_download": "Latausta Ei Voitu Jatkaa.",
|
|
||||||
"download_completed": "Lataus valmis",
|
"download_completed": "Lataus valmis",
|
||||||
"download_failed": "Lataus epäonnistui",
|
"download_failed": "Lataus epäonnistui",
|
||||||
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} poistettu",
|
"files_deleted_by_type": "{{count}} {{type}} poistettu",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Kaikki tiedostot, kansiot ja tehtävät poistettu onnistuneesti",
|
|
||||||
"failed_to_clean_cache_directory": "Välimuistin hakemiston puhdistus epäonnistui",
|
|
||||||
"could_not_get_download_url_for_item": "Latauksen URL-osoitetta ei voitu ladata {{itemName}}",
|
"could_not_get_download_url_for_item": "Latauksen URL-osoitetta ei voitu ladata {{itemName}}",
|
||||||
"go_to_downloads": "Siirry latauksiin",
|
|
||||||
"file_deleted": "{{item}} poistettu"
|
"file_deleted": "{{item}} poistettu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Ei mitään",
|
"none": "Ei mitään",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Haku...",
|
"search": "Haku...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
||||||
"message_from_server": "Viesti palvelimelta: {{message}}",
|
"message_from_server": "Viesti palvelimelta: {{message}}",
|
||||||
"next_episode": "Seuraava Jakso",
|
"next_episode": "Seuraava Jakso",
|
||||||
"refresh_tracks": "Päivitä Kappaleet",
|
|
||||||
"audio_tracks": "Ääni Kappaleet:",
|
|
||||||
"playback_state": "Toiston Tila:",
|
|
||||||
"index": "Indeksi:",
|
|
||||||
"continue_watching": "Jatka katsomista",
|
"continue_watching": "Jatka katsomista",
|
||||||
"go_back": "Siirry Takaisin",
|
"go_back": "Siirry Takaisin",
|
||||||
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Näytä Lisää",
|
"show_more": "Näytä Lisää",
|
||||||
"show_less": "Näytä Vähemmän",
|
"show_less": "Näytä Vähemmän",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Seuraava",
|
"next": "Seuraava",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Aucun",
|
"None": "Aucun",
|
||||||
"OnlyForced": "Forcés seulement"
|
"OnlyForced": "Forcés seulement"
|
||||||
},
|
},
|
||||||
"text_color": "Couleur du texte",
|
|
||||||
"background_color": "Couleur d'arrière-plan",
|
|
||||||
"outline_color": "Couleur du contour",
|
|
||||||
"outline_thickness": "Épaisseur du contour",
|
|
||||||
"background_opacity": "Opacité de l'arrière-plan",
|
|
||||||
"outline_opacity": "Opacité du contour",
|
|
||||||
"bold_text": "Texte en gras",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Noir",
|
|
||||||
"Gray": "Gris",
|
|
||||||
"Silver": "Argent",
|
|
||||||
"White": "Blanc",
|
|
||||||
"Maroon": "Marron",
|
|
||||||
"Red": "Rouge",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Jaune",
|
|
||||||
"Olive": "Olive",
|
|
||||||
"Green": "Vert",
|
|
||||||
"Teal": "Bleu canard",
|
|
||||||
"Lime": "Citron vert",
|
|
||||||
"Purple": "Violet",
|
|
||||||
"Navy": "Bleu marine",
|
|
||||||
"Blue": "Bleu",
|
|
||||||
"Aqua": "Bleu turquoise"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Aucun",
|
|
||||||
"Thin": "Maigre",
|
|
||||||
"Normal": "Normale",
|
|
||||||
"Thick": "Épais"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Couleur des sous-titres",
|
|
||||||
"subtitle_background_color": "Couleur d'arrière-plan",
|
|
||||||
"subtitle_font": "Police des sous-titres",
|
|
||||||
"ksplayer_title": "Paramètres de KSPlayer",
|
|
||||||
"hardware_decode": "Décodage matériel",
|
|
||||||
"hardware_decode_description": "Utilisez l’accélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "Paramètres des sous-titres VLC",
|
|
||||||
"hint": "Personnaliser l'apparence des sous-titres pour le lecteur VLC. Les changements prennent effet lors de la lecture suivante.",
|
|
||||||
"text_color": "Couleur du texte",
|
|
||||||
"background_color": "Couleur d'arrière-plan",
|
|
||||||
"background_opacity": "Opacité de l'arrière-plan",
|
|
||||||
"outline_color": "Couleur du contour",
|
|
||||||
"outline_opacity": "Opacité du contour",
|
|
||||||
"outline_thickness": "Épaisseur du contour",
|
|
||||||
"bold": "Texte en gras",
|
|
||||||
"margin": "Marge inférieure"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Lecteur vidéo",
|
|
||||||
"video_player": "Lecteur vidéo",
|
|
||||||
"video_player_description": "Choisissez le lecteur vidéo à utiliser sur iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Autres",
|
"other_title": "Autres",
|
||||||
"video_orientation": "Orientation vidéo",
|
"video_orientation": "Orientation vidéo",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Inconnu"
|
"UNKNOWN": "Inconnu"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||||
"video_player": "Lecteur vidéo",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Expérimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||||
"hide_libraries": "Cacher des bibliothèques",
|
"hide_libraries": "Cacher des bibliothèques",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||||
"disabled": "Désactivé"
|
"disabled": "Désactivé"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Téléchargements"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musique",
|
"title": "Musique",
|
||||||
"playback_title": "Lecture",
|
"playback_title": "Lecture",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.",
|
|
||||||
"server_url": "URL du serveur",
|
"server_url": "URL du serveur",
|
||||||
"server_url_hint": "Exemple : http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
|
"server_url_hint": "Exemple : http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
|
||||||
"server_url_placeholder": "URL de Seerr...",
|
"server_url_placeholder": "URL de Seerr...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "En savoir plus sur Marlin.",
|
"read_more_about_marlin": "En savoir plus sur Marlin.",
|
||||||
"save_button": "Enregistrer",
|
"save_button": "Enregistrer",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Enregistré",
|
"saved": "Enregistré"
|
||||||
"refreshed": "Paramètres actualisés depuis le serveur"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Activer Streamystats",
|
|
||||||
"disable_streamystats": "Désactiver Streamystats",
|
"disable_streamystats": "Désactiver Streamystats",
|
||||||
"enable_search": "Utiliser pour la recherche",
|
"enable_search": "Utiliser pour la recherche",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Entrez l'URL de votre serveur Streamystats. L'URL doit inclure http ou https et éventuellement le port.",
|
"streamystats_search_hint": "Entrez l'URL de votre serveur Streamystats. L'URL doit inclure http ou https et éventuellement le port.",
|
||||||
"read_more_about_streamystats": "En savoir plus sur Streamystats.",
|
"read_more_about_streamystats": "En savoir plus sur Streamystats.",
|
||||||
"save_button": "Enregistrer",
|
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"features_title": "Fonctionnalités",
|
"features_title": "Fonctionnalités",
|
||||||
"home_sections_title": "Sections de la page d´accueil",
|
|
||||||
"enable_movie_recommendations": "Recommandations de films",
|
"enable_movie_recommendations": "Recommandations de films",
|
||||||
"enable_series_recommendations": "Recommandations de séries",
|
"enable_series_recommendations": "Recommandations de séries",
|
||||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture",
|
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||||
"watchlist_button": "Activer l'intégration de notre liste de lecture"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
|
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
|
||||||
"music_cache_title": "Mise en cache de la musique",
|
"music_cache_title": "Mise en cache de la musique",
|
||||||
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
|
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
|
||||||
"enable_music_cache": "Activer le cache sur la musique",
|
|
||||||
"clear_music_cache": "Vider le cache de la musique",
|
"clear_music_cache": "Vider le cache de la musique",
|
||||||
"music_cache_size": "{{size}} mis en cache",
|
"music_cache_size": "{{size}} mis en cache",
|
||||||
"music_cache_cleared": "Cache de musique effacé",
|
"music_cache_cleared": "Cache de musique effacé",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Système"
|
"system": "Système"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Erreur lors de la suppression des fichiers",
|
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||||
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
|
|
||||||
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Téléchargements",
|
"downloads_title": "Téléchargements",
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "File d'attente",
|
|
||||||
"other_media": "Autres médias",
|
"other_media": "Autres médias",
|
||||||
"queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application",
|
|
||||||
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
|
||||||
"no_downloaded_items": "Aucun média téléchargé",
|
"no_downloaded_items": "Aucun média téléchargé",
|
||||||
"delete_all_movies_button": "Supprimer tous les films",
|
"delete_all_movies_button": "Supprimer tous les films",
|
||||||
"delete_all_series_button": "Supprimer toutes les séries",
|
"delete_all_series_button": "Supprimer toutes les séries",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||||
"download_deleted": "Téléchargement supprimé",
|
|
||||||
"download_cancelled": "Téléchargement annulé",
|
"download_cancelled": "Téléchargement annulé",
|
||||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||||
"download_paused": "Téléchargement en pause",
|
|
||||||
"could_not_pause_download": "Impossible de mettre en pause le téléchargement",
|
|
||||||
"download_resumed": "Reprise du téléchargement",
|
|
||||||
"could_not_resume_download": "Impossible de reprendre le téléchargement",
|
|
||||||
"download_completed": "Téléchargement terminé",
|
"download_completed": "Téléchargement terminé",
|
||||||
"download_failed": "Échec du téléchargement",
|
"download_failed": "Échec du téléchargement",
|
||||||
"download_failed_for_item": "Échec du téléchargement pour {{item}} - {{error}}",
|
"download_failed_for_item": "Échec du téléchargement pour {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} est déjà en cours de téléchargement",
|
"item_already_downloading": "{{item}} est déjà en cours de téléchargement",
|
||||||
"all_files_deleted": "Tous les téléchargements supprimés avec succès",
|
"all_files_deleted": "Tous les téléchargements supprimés avec succès",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} supprimé",
|
"files_deleted_by_type": "{{count}} {{type}} supprimé",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et tâches ont été supprimés avec succès",
|
|
||||||
"failed_to_clean_cache_directory": "Échec du nettoyage du répertoire de cache",
|
|
||||||
"could_not_get_download_url_for_item": "Échec d'obtention de l'URL de téléchargement pour {{itemName}}",
|
"could_not_get_download_url_for_item": "Échec d'obtention de l'URL de téléchargement pour {{itemName}}",
|
||||||
"go_to_downloads": "Aller aux téléchargements",
|
|
||||||
"file_deleted": "{{item}} supprimé"
|
"file_deleted": "{{item}} supprimé"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"track": "Suivre",
|
"track": "Suivre",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
"remove": "Retirer",
|
"remove": "Retirer",
|
||||||
"next": "Suivant",
|
|
||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"verifying": "Vérification...",
|
"verifying": "Vérification...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
||||||
"message_from_server": "Message du serveur : {{message}}",
|
"message_from_server": "Message du serveur : {{message}}",
|
||||||
"next_episode": "Épisode suivant",
|
"next_episode": "Épisode suivant",
|
||||||
"refresh_tracks": "Rafraîchir les pistes",
|
|
||||||
"audio_tracks": "Pistes audio :",
|
|
||||||
"playback_state": "État de lecture :",
|
|
||||||
"index": "Index :",
|
|
||||||
"continue_watching": "Continuer à regarder",
|
"continue_watching": "Continuer à regarder",
|
||||||
"go_back": "Retour",
|
"go_back": "Retour",
|
||||||
"downloaded_file_title": "Ce fichier est téléchargé",
|
"downloaded_file_title": "Ce fichier est téléchargé",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "morceaux"
|
"tracks": "morceaux"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Toutes"
|
|
||||||
},
|
|
||||||
"recently_added": "Ajoutés récemment",
|
"recently_added": "Ajoutés récemment",
|
||||||
"recently_played": "Récemment joué",
|
"recently_played": "Récemment joué",
|
||||||
"frequently_played": "Fréquemment joué",
|
"frequently_played": "Fréquemment joué",
|
||||||
"explore": "Explorez",
|
|
||||||
"top_tracks": "Top chansons",
|
"top_tracks": "Top chansons",
|
||||||
"play": "Lecture",
|
"play": "Lecture",
|
||||||
"shuffle": "Aléatoire",
|
"shuffle": "Aléatoire",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "ללא",
|
"None": "ללא",
|
||||||
"OnlyForced": "רק כפוי"
|
"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 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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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_description": "Choose which video player to use on iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "אחר",
|
"other_title": "אחר",
|
||||||
"video_orientation": "כיוון וידיאו",
|
"video_orientation": "כיוון וידיאו",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "לא ידוע"
|
"UNKNOWN": "לא ידוע"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "איזור בטוח בפקדים",
|
"safe_area_in_controls": "איזור בטוח בפקדים",
|
||||||
"video_player": "נגן וידאו",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (ניסיוני + נגן בתוך נגן)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
|
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
|
||||||
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
|
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
|
||||||
"hide_libraries": "הסתר ספריות",
|
"hide_libraries": "הסתר ספריות",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "כמות פרקים מקסימלית לניגון אוטומטי",
|
"max_auto_play_episode_count": "כמות פרקים מקסימלית לניגון אוטומטי",
|
||||||
"disabled": "כבוי"
|
"disabled": "כבוי"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "הורדות"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "מוזיקה",
|
"title": "מוזיקה",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "תוספים",
|
"plugins_title": "תוספים",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "חלק זה נמצא עדיין בשלבים מוקדמים. צפו שדברים ישתנו.",
|
|
||||||
"server_url": "כתובת ה-URL של השרת",
|
"server_url": "כתובת ה-URL של השרת",
|
||||||
"server_url_hint": "לדוגמא: http(s)://your-host.url\n(הוסף פורט במידת הצורך)",
|
"server_url_hint": "לדוגמא: http(s)://your-host.url\n(הוסף פורט במידת הצורך)",
|
||||||
"server_url_placeholder": "כתובת ה-URL של Seerr",
|
"server_url_placeholder": "כתובת ה-URL של Seerr",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "קרא עוד על Marlin.",
|
"read_more_about_marlin": "קרא עוד על Marlin.",
|
||||||
"save_button": "שמור",
|
"save_button": "שמור",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "נשמר",
|
"saved": "נשמר"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
|
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "מערכת"
|
"system": "מערכת"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "שגיאה במחיקת קבצים",
|
"error_deleting_files": "שגיאה במחיקת קבצים"
|
||||||
"background_downloads_enabled": "הורדה ברקע מופעלת",
|
|
||||||
"background_downloads_disabled": "הורדה ברקע כבויה"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "הורדות",
|
"downloads_title": "הורדות",
|
||||||
"series": "סדרות",
|
"series": "סדרות",
|
||||||
"movies": "סרטים",
|
"movies": "סרטים",
|
||||||
"queue": "תוֹר",
|
|
||||||
"other_media": "תוכן אחר",
|
"other_media": "תוכן אחר",
|
||||||
"queue_hint": "התור וההורדות יאבדו בפתיחה מחדש של האפליקציה",
|
|
||||||
"no_items_in_queue": "אין פרטים בתור",
|
|
||||||
"no_downloaded_items": "אין פריטים שהורדו",
|
"no_downloaded_items": "אין פריטים שהורדו",
|
||||||
"delete_all_movies_button": "מחק את כל הסרטים",
|
"delete_all_movies_button": "מחק את כל הסרטים",
|
||||||
"delete_all_series_button": "מחק את כל הסדרות",
|
"delete_all_series_button": "מחק את כל הסדרות",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
|
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
|
||||||
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
||||||
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
||||||
"download_deleted": "ההורדה נמחקה",
|
|
||||||
"download_cancelled": "ההורדה בוטלה",
|
"download_cancelled": "ההורדה בוטלה",
|
||||||
"could_not_delete_download": "לא היה ניתן למחוק את ההורדה",
|
"could_not_delete_download": "לא היה ניתן למחוק את ההורדה",
|
||||||
"download_paused": "ההורדה נעצרה",
|
|
||||||
"could_not_pause_download": "לא היה ניתן לעצור את ההורדה",
|
|
||||||
"download_resumed": "ההורדה חודשה",
|
|
||||||
"could_not_resume_download": "לא היה ניתן לחדש את ההורדה",
|
|
||||||
"download_completed": "ההורדה הושלמה",
|
"download_completed": "ההורדה הושלמה",
|
||||||
"download_failed": "ההורדה נכשלה",
|
"download_failed": "ההורדה נכשלה",
|
||||||
"download_failed_for_item": "ההורדה נכשלה עבור {{item}} - {{error}}",
|
"download_failed_for_item": "ההורדה נכשלה עבור {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} כבר נמצא בהורדה",
|
"item_already_downloading": "{{item}} כבר נמצא בהורדה",
|
||||||
"all_files_deleted": "כל ההורדות נמחקו בהצלחה",
|
"all_files_deleted": "כל ההורדות נמחקו בהצלחה",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} נמחקו",
|
"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": "לא היה ניתן להשיג את קישור ההורדה של {{itemName}}",
|
"could_not_get_download_url_for_item": "לא היה ניתן להשיג את קישור ההורדה של {{itemName}}",
|
||||||
"go_to_downloads": "עבור להורדות",
|
|
||||||
"file_deleted": "{{item}} נמחק"
|
"file_deleted": "{{item}} נמחק"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "ללא",
|
"none": "ללא",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "חפש...",
|
"search": "חפש...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
||||||
"message_from_server": "הודעה מהשרת: {{message}}",
|
"message_from_server": "הודעה מהשרת: {{message}}",
|
||||||
"next_episode": "הפרק הבא",
|
"next_episode": "הפרק הבא",
|
||||||
"refresh_tracks": "רענן רצועות",
|
|
||||||
"audio_tracks": "רצועות שמע:",
|
|
||||||
"playback_state": "מצב ניגון:",
|
|
||||||
"index": "מיקום:",
|
|
||||||
"continue_watching": "המשך לצפות",
|
"continue_watching": "המשך לצפות",
|
||||||
"go_back": "חזור",
|
"go_back": "חזור",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "הצג עוד",
|
"show_more": "הצג עוד",
|
||||||
"show_less": "הצג פחות",
|
"show_less": "הצג פחות",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "הבא",
|
"next": "הבא",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Nincs",
|
"None": "Nincs",
|
||||||
"OnlyForced": "Csak Kényszerített"
|
"OnlyForced": "Csak Kényszerített"
|
||||||
},
|
},
|
||||||
"text_color": "Szövegszín",
|
|
||||||
"background_color": "Háttérszín",
|
|
||||||
"outline_color": "Körvonal színe",
|
|
||||||
"outline_thickness": "Körvonal Vastagsága",
|
|
||||||
"background_opacity": "Háttér Áttetszőség",
|
|
||||||
"outline_opacity": "Körvonal Áttetszőség",
|
|
||||||
"bold_text": "Félkövér Szöveg",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Fekete",
|
|
||||||
"Gray": "Szürke",
|
|
||||||
"Silver": "Ezüst",
|
|
||||||
"White": "Fehér",
|
|
||||||
"Maroon": "Sötétvörös",
|
|
||||||
"Red": "Piros",
|
|
||||||
"Fuchsia": "Fukszia",
|
|
||||||
"Yellow": "Sárga",
|
|
||||||
"Olive": "Oliva",
|
|
||||||
"Green": "Zöld",
|
|
||||||
"Teal": "Türkiz",
|
|
||||||
"Lime": "Lime",
|
|
||||||
"Purple": "Lila",
|
|
||||||
"Navy": "Sötétkék",
|
|
||||||
"Blue": "Kék",
|
|
||||||
"Aqua": "Türkizkék"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Nincs",
|
|
||||||
"Thin": "Vékony",
|
|
||||||
"Normal": "Normál",
|
|
||||||
"Thick": "Vastag"
|
|
||||||
},
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Egyéb",
|
"other_title": "Egyéb",
|
||||||
"video_orientation": "Videó Tájolás",
|
"video_orientation": "Videó Tájolás",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Ismeretlen"
|
"UNKNOWN": "Ismeretlen"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Biztonsági Sáv a Vezérlőkben",
|
"safe_area_in_controls": "Biztonsági Sáv a Vezérlőkben",
|
||||||
"video_player": "Videólejátszó",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Kísérleti + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Könyvtárak Elrejtése",
|
"hide_libraries": "Könyvtárak Elrejtése",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Max. Auto. Epizódlejátszás",
|
"max_auto_play_episode_count": "Max. Auto. Epizódlejátszás",
|
||||||
"disabled": "Letiltva"
|
"disabled": "Letiltva"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Letöltések"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Bővítmények",
|
"plugins_title": "Bővítmények",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Ez az integráció még korai stádiumban van. Számíts a változásokra.",
|
|
||||||
"server_url": "Szerver URL",
|
"server_url": "Szerver URL",
|
||||||
"server_url_hint": "Példa: http(s)://a-te-szolgáltatód.url\n(adj meg portot, ha szükséges)",
|
"server_url_hint": "Példa: http(s)://a-te-szolgáltatód.url\n(adj meg portot, ha szükséges)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Tudj Meg Többet a Marlinról",
|
"read_more_about_marlin": "Tudj Meg Többet a Marlinról",
|
||||||
"save_button": "Mentés",
|
"save_button": "Mentés",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Mentve",
|
"saved": "Mentve"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
|
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Rendszer"
|
"system": "Rendszer"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Hiba a Fájlok Törlésekor",
|
"error_deleting_files": "Hiba a Fájlok Törlésekor"
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
|
||||||
"background_downloads_disabled": "Background downloads disabled"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Letöltések",
|
"downloads_title": "Letöltések",
|
||||||
"series": "Sorozatok",
|
"series": "Sorozatok",
|
||||||
"movies": "Filmek",
|
"movies": "Filmek",
|
||||||
"queue": "Sor",
|
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
"queue_hint": "A sor és a letöltések az alkalmazás újraindításakor elvesznek",
|
|
||||||
"no_items_in_queue": "Nincs Elem a Sorban",
|
|
||||||
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
||||||
"delete_all_movies_button": "Összes Film Törlése",
|
"delete_all_movies_button": "Összes Film Törlése",
|
||||||
"delete_all_series_button": "Összes Sorozat Törlése",
|
"delete_all_series_button": "Összes Sorozat Törlése",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
|
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Letöltés Törölve",
|
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Nem Sikerült Törölni a Letöltést",
|
"could_not_delete_download": "Nem Sikerült Törölni a Letöltést",
|
||||||
"download_paused": "Letöltés Szüneteltetve",
|
|
||||||
"could_not_pause_download": "Nem Sikerült Szüneteltetni a Letöltést",
|
|
||||||
"download_resumed": "Letöltés Folytatva",
|
|
||||||
"could_not_resume_download": "Nem Sikerült Folytatni a Letöltést",
|
|
||||||
"download_completed": "Letöltés Befejezve",
|
"download_completed": "Letöltés Befejezve",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "A(z) {{item}} letöltése sikertelen - {{error}}",
|
"download_failed_for_item": "A(z) {{item}} letöltése sikertelen - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Minden fájl, mappa és feladat sikeresen törölve",
|
|
||||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
"go_to_downloads": "Ugrás a Letöltésekhez",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Keresés...",
|
"search": "Keresés...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
||||||
"message_from_server": "Üzenet a szervertől: {{message}}",
|
"message_from_server": "Üzenet a szervertől: {{message}}",
|
||||||
"next_episode": "Következő Epizód",
|
"next_episode": "Következő Epizód",
|
||||||
"refresh_tracks": "Sávok Frissítése",
|
|
||||||
"audio_tracks": "Hangsávok:",
|
|
||||||
"playback_state": "Lejátszás Állapota:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Folytatás",
|
"continue_watching": "Folytatás",
|
||||||
"go_back": "Vissza",
|
"go_back": "Vissza",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Több Megjelenítése",
|
"show_more": "Több Megjelenítése",
|
||||||
"show_less": "Kevesebb Megjelenítése",
|
"show_less": "Kevesebb Megjelenítése",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"error_title": "Errore",
|
"error_title": "Errore",
|
||||||
"login_title": "Accesso",
|
"login_title": "Accesso",
|
||||||
"login_to_title": "Accedi a",
|
"login_to_title": "Accedi a",
|
||||||
"select_user": "Select a user to log in",
|
"select_user": "Seleziona un utente per accedere",
|
||||||
"add_user_to_login": "Add a user to log in",
|
"add_user_to_login": "Aggiungi un utente per accedere",
|
||||||
"add_user": "Add User",
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nome utente",
|
"username_placeholder": "Nome utente",
|
||||||
"password_placeholder": "Password",
|
"password_placeholder": "Password",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"connect_button": "Connetti",
|
"connect_button": "Connetti",
|
||||||
"previous_servers": "server precedente",
|
"previous_servers": "server precedente",
|
||||||
"clear_button": "Cancella",
|
"clear_button": "Cancella",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "Scorri per rimuovere",
|
||||||
"search_for_local_servers": "Ricerca dei server locali",
|
"search_for_local_servers": "Ricerca dei server locali",
|
||||||
"searching": "Cercando...",
|
"searching": "Cercando...",
|
||||||
"servers": "Server",
|
"servers": "Server",
|
||||||
@@ -41,46 +41,46 @@
|
|||||||
"session_expired": "Session Expired",
|
"session_expired": "Session Expired",
|
||||||
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
|
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
|
||||||
"remove_saved_login": "Remove Saved Login",
|
"remove_saved_login": "Remove Saved Login",
|
||||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
"remove_saved_login_description": "Questo rimuoverà le tue credenziali salvate per questo server. Dovrai inserire nuovamente il tuo nome utente e la password la prossima volta.",
|
||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "Account {{count}}",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
"remove_account_description": "Questo rimuoverà le credenziali salvate per {{username}}.",
|
||||||
"remove_server": "Remove Server",
|
"remove_server": "Remove Server",
|
||||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
"remove_server_description": "Questo rimuoverà {{server}} e tutti gli account salvati dall'elenco.",
|
||||||
"select_your_server": "Select Your Server",
|
"select_your_server": "Select Your Server",
|
||||||
"add_server_to_get_started": "Add a server to get started",
|
"add_server_to_get_started": "Aggiungi un server per iniziare",
|
||||||
"add_server": "Add Server",
|
"add_server": "Add Server",
|
||||||
"change_server": "Change Server"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
"save_for_later": "Save this account",
|
"save_for_later": "Salva questo account",
|
||||||
"security_option": "Security Option",
|
"security_option": "Security Option",
|
||||||
"no_protection": "No protection",
|
"no_protection": "Nessuna Protezione",
|
||||||
"no_protection_desc": "Quick login without authentication",
|
"no_protection_desc": "Accesso rapido senza autenticazione",
|
||||||
"pin_code": "PIN code",
|
"pin_code": "Codice PIN",
|
||||||
"pin_code_desc": "4-digit PIN required when switching",
|
"pin_code_desc": "PIN di 4 cifre richiesto quando si cambia utente",
|
||||||
"password": "Re-enter password",
|
"password": "Inserisci nuovamente la password",
|
||||||
"password_desc": "Password required when switching",
|
"password_desc": "Password richiesta quando si cambia",
|
||||||
"save_button": "Save",
|
"save_button": "Salva",
|
||||||
"cancel_button": "Cancel"
|
"cancel_button": "Annulla"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"enter_pin": "Enter PIN",
|
"enter_pin": "Inserisci il PIN",
|
||||||
"enter_pin_for": "Enter PIN for {{username}}",
|
"enter_pin_for": "Inserisci PIN per {{username}}",
|
||||||
"enter_4_digits": "Enter 4 digits",
|
"enter_4_digits": "Inserisci 4 cifre",
|
||||||
"invalid_pin": "Invalid PIN",
|
"invalid_pin": "PIN non valido",
|
||||||
"setup_pin": "Set Up PIN",
|
"setup_pin": "Set Up PIN",
|
||||||
"confirm_pin": "Confirm PIN",
|
"confirm_pin": "Conferma PIN",
|
||||||
"pins_dont_match": "PINs don't match",
|
"pins_dont_match": "I PIN non corrispondono",
|
||||||
"forgot_pin": "Forgot PIN?",
|
"forgot_pin": "Hai dimenticato il PIN?",
|
||||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
"forgot_pin_desc": "Le credenziali salvate verranno rimosse"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"enter_password": "Enter Password",
|
"enter_password": "Enter Password",
|
||||||
"enter_password_for": "Enter password for {{username}}",
|
"enter_password_for": "Inserire la password per {{username}}",
|
||||||
"invalid_password": "Invalid password"
|
"invalid_password": "Password errata"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"checking_server_connection": "Controllo connessione server...",
|
"checking_server_connection": "Controllo connessione server...",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"oops": "Ops!",
|
"oops": "Ops!",
|
||||||
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
|
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
|
||||||
"continue_watching": "Continua a guardare",
|
"continue_watching": "Continua a guardare",
|
||||||
"continue": "Continue",
|
"continue": "Continua",
|
||||||
"next_up": "Prossimo",
|
"next_up": "Prossimo",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
|
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"switch_user": "Switch User on This Server",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "current"
|
"current": "attuale"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categorie"
|
"title": "Categorie"
|
||||||
@@ -143,37 +143,37 @@
|
|||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
"theme_music": "Theme Music",
|
"theme_music": "Theme Music",
|
||||||
"display_size": "Display Size",
|
"display_size": "Display Size",
|
||||||
"display_size_small": "Small",
|
"display_size_small": "Piccolo",
|
||||||
"display_size_default": "Default",
|
"display_size_default": "Predefinito",
|
||||||
"display_size_large": "Large",
|
"display_size_large": "Grande",
|
||||||
"display_size_extra_large": "Extra Large"
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Rete",
|
||||||
"local_network": "",
|
"local_network": "Rete locale",
|
||||||
"auto_switch_enabled": "Auto-switch when at home",
|
"auto_switch_enabled": "Cambia automaticamente quando sei in casa",
|
||||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||||
"local_url": "Local URL",
|
"local_url": "URL locale",
|
||||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
"home_wifi_networks": "Home WiFi Networks",
|
"home_wifi_networks": "Home WiFi Networks",
|
||||||
"add_current_network": "Add \"{{ssid}}\"",
|
"add_current_network": "Aggiungi \"{{ssid}}\"",
|
||||||
"not_connected_to_wifi": "Not connected to WiFi",
|
"not_connected_to_wifi": "Not connected to WiFi",
|
||||||
"no_networks_configured": "No networks configured",
|
"no_networks_configured": "Nessuna rete configurata",
|
||||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||||
"current_wifi": "WiFi Attuale",
|
"current_wifi": "WiFi Attuale",
|
||||||
"using_url": "Sta utilizzando",
|
"using_url": "Sta utilizzando",
|
||||||
"local": "Local URL",
|
"local": "URL locale",
|
||||||
"remote": "Remote URL",
|
"remote": "URL remoto",
|
||||||
"not_connected": "Not connected",
|
"not_connected": "Non connesso",
|
||||||
"current_server": "Current Server",
|
"current_server": "Current Server",
|
||||||
"remote_url": "Remote URL",
|
"remote_url": "URL remoto",
|
||||||
"active_url": "Active URL",
|
"active_url": "URL Attivo",
|
||||||
"not_configured": "Not configured",
|
"not_configured": "Non configurato",
|
||||||
"network_added": "Network added",
|
"network_added": "Rete aggiunta",
|
||||||
"network_already_added": "Network already added",
|
"network_already_added": "Rete già inserita",
|
||||||
"no_wifi_connected": "Not connected to WiFi",
|
"no_wifi_connected": "Not connected to WiFi",
|
||||||
"permission_denied": "Location permission denied",
|
"permission_denied": "Autorizzazione alla posizione negata",
|
||||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||||
},
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
@@ -202,9 +202,9 @@
|
|||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
"cache_auto": "Auto",
|
"cache_auto": "Automatico",
|
||||||
"cache_yes": "Enabled",
|
"cache_yes": "Abilitato",
|
||||||
"cache_no": "Disabled",
|
"cache_no": "Disabilitato",
|
||||||
"buffer_duration": "Buffer Duration",
|
"buffer_duration": "Buffer Duration",
|
||||||
"max_cache_size": "Max Cache Size",
|
"max_cache_size": "Max Cache Size",
|
||||||
"max_backward_cache": "Max Backward Cache"
|
"max_backward_cache": "Max Backward Cache"
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"vo_driver": {
|
"vo_driver": {
|
||||||
"title": "Video Output",
|
"title": "Video Output",
|
||||||
"vo_mode": "VO Driver",
|
"vo_mode": "VO Driver",
|
||||||
"gpu_next": "gpu-next (Recommended)",
|
"gpu_next": "gpu-next (Consigliato)",
|
||||||
"gpu": "gpu"
|
"gpu": "gpu"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
"right_side_volume": "Controllo Volume Laterale Destro",
|
"right_side_volume": "Controllo Volume Laterale Destro",
|
||||||
"right_side_volume_description": "Scorri verso l'alto/verso il basso per regolare il volume",
|
"right_side_volume_description": "Scorri verso l'alto/verso il basso per regolare il volume",
|
||||||
"hide_volume_slider": "Hide Volume Slider",
|
"hide_volume_slider": "Hide Volume Slider",
|
||||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||||
"hide_brightness_slider": "Hide Brightness Slider",
|
"hide_brightness_slider": "Hide Brightness Slider",
|
||||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Nessuno",
|
"None": "Nessuno",
|
||||||
"OnlyForced": "Solo forzati"
|
"OnlyForced": "Solo forzati"
|
||||||
},
|
},
|
||||||
"text_color": "Colore Del Testo",
|
|
||||||
"background_color": "Colore Di Sfondo",
|
|
||||||
"outline_color": "Colore Contorno",
|
|
||||||
"outline_thickness": "Spessore Contorno",
|
|
||||||
"background_opacity": "Opacità Dello Sfondo",
|
|
||||||
"outline_opacity": "Opacità Contorno",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Nero",
|
|
||||||
"Gray": "Grigio",
|
|
||||||
"Silver": "Argento",
|
|
||||||
"White": "Bianco",
|
|
||||||
"Maroon": "Maroon",
|
|
||||||
"Red": "Rosso",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Giallo",
|
|
||||||
"Olive": "Olive",
|
|
||||||
"Green": "Verde",
|
|
||||||
"Teal": "Teal",
|
|
||||||
"Lime": "Lime",
|
|
||||||
"Purple": "Viola",
|
|
||||||
"Navy": "Marina",
|
|
||||||
"Blue": "Blu",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Nessuno",
|
|
||||||
"Thin": "Sottile",
|
|
||||||
"Normal": "Normale",
|
|
||||||
"Thick": "Spessa"
|
|
||||||
},
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Altro",
|
"other_title": "Altro",
|
||||||
"video_orientation": "Orientamento del video",
|
"video_orientation": "Orientamento del video",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Sconosciuto"
|
"UNKNOWN": "Sconosciuto"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Area sicura per i controlli",
|
"safe_area_in_controls": "Area sicura per i controlli",
|
||||||
"video_player": "Video player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Sperimentale + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
||||||
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
|
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
|
||||||
"hide_libraries": "Nascondi Librerie",
|
"hide_libraries": "Nascondi Librerie",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Numero Massimo Di Episodi Riproduzione Automatica",
|
"max_auto_play_episode_count": "Numero Massimo Di Episodi Riproduzione Automatica",
|
||||||
"disabled": "Disabilitato"
|
"disabled": "Disabilitato"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Scaricamento"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugin",
|
"plugins_title": "Plugin",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
|
|
||||||
"server_url": "URL del Server",
|
"server_url": "URL del Server",
|
||||||
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
|
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
|
||||||
"server_url_placeholder": "URL di Jellyseerr...",
|
"server_url_placeholder": "URL di Jellyseerr...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Leggi di più su Marlin.",
|
"read_more_about_marlin": "Leggi di più su Marlin.",
|
||||||
"save_button": "Salva",
|
"save_button": "Salva",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Salvato",
|
"saved": "Salvato"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Errore nella cancellazione dei file",
|
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||||
"background_downloads_enabled": "Scaricamento in background abilitato",
|
|
||||||
"background_downloads_disabled": "Scaricamento in background disabilitato"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Scaricati",
|
"downloads_title": "Scaricati",
|
||||||
"series": "Serie TV",
|
"series": "Serie TV",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Coda",
|
|
||||||
"other_media": "Altri supporti",
|
"other_media": "Altri supporti",
|
||||||
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
|
|
||||||
"no_items_in_queue": "Nessun elemento in coda",
|
|
||||||
"no_downloaded_items": "Nessun elemento scaricato",
|
"no_downloaded_items": "Nessun elemento scaricato",
|
||||||
"delete_all_movies_button": "Cancella tutti i film",
|
"delete_all_movies_button": "Cancella tutti i film",
|
||||||
"delete_all_series_button": "Cancella tutte le serie TV",
|
"delete_all_series_button": "Cancella tutte le serie TV",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
|
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
|
||||||
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
||||||
"failed_to_delete_media": "Impossibile eliminare altri media",
|
"failed_to_delete_media": "Impossibile eliminare altri media",
|
||||||
"download_deleted": "Download Eliminato",
|
|
||||||
"download_cancelled": "Scaricamento annullato",
|
"download_cancelled": "Scaricamento annullato",
|
||||||
"could_not_delete_download": "Impossibile Eliminare Il Download",
|
"could_not_delete_download": "Impossibile Eliminare Il Download",
|
||||||
"download_paused": "Download In Pausa",
|
|
||||||
"could_not_pause_download": "Impossibile Sbloccare Il Download",
|
|
||||||
"download_resumed": "Download Ripreso",
|
|
||||||
"could_not_resume_download": "Impossibile Riprendere Il Download",
|
|
||||||
"download_completed": "Scaricamento completato",
|
"download_completed": "Scaricamento completato",
|
||||||
"download_failed": "Scaricamento non riuscito",
|
"download_failed": "Scaricamento non riuscito",
|
||||||
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
|
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} è già in download",
|
"item_already_downloading": "{{item}} è già in download",
|
||||||
"all_files_deleted": "Tutti i Download Eliminati con Successo",
|
"all_files_deleted": "Tutti i Download Eliminati con Successo",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} cancellati",
|
"files_deleted_by_type": "{{count}} {{type}} cancellati",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
|
|
||||||
"failed_to_clean_cache_directory": "Pulizia della directory della cache non riuscita",
|
|
||||||
"could_not_get_download_url_for_item": "Impossibile ottenere l'URL di download per {{itemName}}",
|
"could_not_get_download_url_for_item": "Impossibile ottenere l'URL di download per {{itemName}}",
|
||||||
"go_to_downloads": "Vai agli elementi scaricati",
|
|
||||||
"file_deleted": "{{item}} cancellato"
|
"file_deleted": "{{item}} cancellato"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Nulla",
|
"none": "Nulla",
|
||||||
"track": "Traccia",
|
"track": "Traccia",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
||||||
"message_from_server": "Messaggio dal server",
|
"message_from_server": "Messaggio dal server",
|
||||||
"next_episode": "Prossimo Episodio",
|
"next_episode": "Prossimo Episodio",
|
||||||
"refresh_tracks": "Aggiorna tracce",
|
|
||||||
"audio_tracks": "Tracce audio:",
|
|
||||||
"playback_state": "Stato della riproduzione:",
|
|
||||||
"index": "Indice:",
|
|
||||||
"continue_watching": "Continua a guardare",
|
"continue_watching": "Continua a guardare",
|
||||||
"go_back": "Indietro",
|
"go_back": "Indietro",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Mostra di più",
|
"show_more": "Mostra di più",
|
||||||
"show_less": "Mostra di meno",
|
"show_less": "Mostra di meno",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Prossimo",
|
"next": "Prossimo",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "なし",
|
"None": "なし",
|
||||||
"OnlyForced": "強制のみ"
|
"OnlyForced": "強制のみ"
|
||||||
},
|
},
|
||||||
"text_color": "テキストの色",
|
|
||||||
"background_color": "背景色",
|
|
||||||
"outline_color": "アウトラインの色",
|
|
||||||
"outline_thickness": "概要 厚さ",
|
|
||||||
"background_opacity": "背景の透明度",
|
|
||||||
"outline_opacity": "アウトラインの透明度",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "ブラック",
|
|
||||||
"Gray": "グレー",
|
|
||||||
"Silver": "シルバー",
|
|
||||||
"White": "白",
|
|
||||||
"Maroon": "Maroon",
|
|
||||||
"Red": "赤",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "黄色",
|
|
||||||
"Olive": "オリーブ",
|
|
||||||
"Green": "緑",
|
|
||||||
"Teal": "ティール",
|
|
||||||
"Lime": "黄緑",
|
|
||||||
"Purple": "パープル",
|
|
||||||
"Navy": "海軍format@@0",
|
|
||||||
"Blue": "青",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "なし",
|
|
||||||
"Thin": "細いです",
|
|
||||||
"Normal": "標準",
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "その他",
|
"other_title": "その他",
|
||||||
"video_orientation": "動画の向き",
|
"video_orientation": "動画の向き",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "不明"
|
"UNKNOWN": "不明"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "コントロールの安全エリア",
|
"safe_area_in_controls": "コントロールの安全エリア",
|
||||||
"video_player": "Video player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "カスタムメニューのリンクを表示",
|
"show_custom_menu_links": "カスタムメニューのリンクを表示",
|
||||||
"show_large_home_carousel": "大きなヒーロー(Beta)",
|
"show_large_home_carousel": "大きなヒーロー(Beta)",
|
||||||
"hide_libraries": "ライブラリを非表示",
|
"hide_libraries": "ライブラリを非表示",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "自動再生エピソードの最大数",
|
"max_auto_play_episode_count": "自動再生エピソードの最大数",
|
||||||
"disabled": "無効"
|
"disabled": "無効"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "ダウンロード"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "プラグイン",
|
"plugins_title": "プラグイン",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "この統合はまだ初期段階です。状況が変化する可能性があります。",
|
|
||||||
"server_url": "サーバーURL",
|
"server_url": "サーバーURL",
|
||||||
"server_url_hint": "例: http(s)://your-host.url\n(必要に応じてポートを追加)",
|
"server_url_hint": "例: http(s)://your-host.url\n(必要に応じてポートを追加)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Marlinについて詳しく読む。",
|
"read_more_about_marlin": "Marlinについて詳しく読む。",
|
||||||
"save_button": "保存",
|
"save_button": "保存",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "保存しました",
|
"saved": "保存しました"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
|
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "システム"
|
"system": "システム"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "ファイルの削除エラー",
|
"error_deleting_files": "ファイルの削除エラー"
|
||||||
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
|
|
||||||
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "ダウンロード",
|
"downloads_title": "ダウンロード",
|
||||||
"series": "TVシリーズ",
|
"series": "TVシリーズ",
|
||||||
"movies": "映画",
|
"movies": "映画",
|
||||||
"queue": "キュー",
|
|
||||||
"other_media": "その他のメディア",
|
"other_media": "その他のメディア",
|
||||||
"queue_hint": "アプリを再起動するとキューとダウンロードは失われます",
|
|
||||||
"no_items_in_queue": "キューにアイテムがありません",
|
|
||||||
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
||||||
"delete_all_movies_button": "すべての映画を削除",
|
"delete_all_movies_button": "すべての映画を削除",
|
||||||
"delete_all_series_button": "すべてのシリーズを削除",
|
"delete_all_series_button": "すべてのシリーズを削除",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
|
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
|
||||||
"deleted_media_successfully": "他のメディアを削除しました!",
|
"deleted_media_successfully": "他のメディアを削除しました!",
|
||||||
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
||||||
"download_deleted": "ダウンロードが削除されました",
|
|
||||||
"download_cancelled": "ダウンロードをキャンセルしました",
|
"download_cancelled": "ダウンロードをキャンセルしました",
|
||||||
"could_not_delete_download": "ダウンロードを削除できませんでした",
|
"could_not_delete_download": "ダウンロードを削除できませんでした",
|
||||||
"download_paused": "ダウンロードを一時停止しました",
|
|
||||||
"could_not_pause_download": "ダウンロードを一時停止できませんでした",
|
|
||||||
"download_resumed": "ダウンロード再開",
|
|
||||||
"could_not_resume_download": "ダウンロードを再開できませんでした",
|
|
||||||
"download_completed": "ダウンロードが完了しました",
|
"download_completed": "ダウンロードが完了しました",
|
||||||
"download_failed": "ダウンロードに失敗しました",
|
"download_failed": "ダウンロードに失敗しました",
|
||||||
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
|
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "すべてのファイル、フォルダ、ジョブが正常に削除されました",
|
|
||||||
"failed_to_clean_cache_directory": "キャッシュディレクトリのクリーンアップに失敗しました",
|
|
||||||
"could_not_get_download_url_for_item": "{{itemName}} のダウンロードURLを取得できませんでした",
|
"could_not_get_download_url_for_item": "{{itemName}} のダウンロードURLを取得できませんでした",
|
||||||
"go_to_downloads": "ダウンロードに移動",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "検索...",
|
"search": "検索...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
||||||
"message_from_server": "サーバーからのメッセージ",
|
"message_from_server": "サーバーからのメッセージ",
|
||||||
"next_episode": "次のエピソード",
|
"next_episode": "次のエピソード",
|
||||||
"refresh_tracks": "トラックを更新",
|
|
||||||
"audio_tracks": "音声トラック:",
|
|
||||||
"playback_state": "再生状態:",
|
|
||||||
"index": "インデックス:",
|
|
||||||
"continue_watching": "視聴を続ける",
|
"continue_watching": "視聴を続ける",
|
||||||
"go_back": "戻る",
|
"go_back": "戻る",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "もっと見る",
|
"show_more": "もっと見る",
|
||||||
"show_less": "少なく表示",
|
"show_less": "少なく表示",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "次",
|
"next": "次",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "None",
|
"None": "None",
|
||||||
"OnlyForced": "OnlyForced"
|
"OnlyForced": "OnlyForced"
|
||||||
},
|
},
|
||||||
"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": "검정색",
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"video_orientation": "Video Orientation",
|
"video_orientation": "Video Orientation",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "컨트롤 안전 영역",
|
"safe_area_in_controls": "컨트롤 안전 영역",
|
||||||
"video_player": "Video Player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
|
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
|
||||||
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
|
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
|
||||||
"hide_libraries": "라이브러리 숨기기",
|
"hide_libraries": "라이브러리 숨기기",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Read More About Marlin.",
|
"read_more_about_marlin": "Read More About Marlin.",
|
||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved",
|
"saved": "Saved"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "시리즈 추천",
|
"enable_series_recommendations": "시리즈 추천",
|
||||||
"enable_promoted_watchlists": "추천 관심 목록",
|
"enable_promoted_watchlists": "추천 관심 목록",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "서버에서 설정 새로고침"
|
"refresh_from_server": "서버에서 설정 새로고침"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "관심 목록 통합 기능 활성화",
|
"watchlist_enabler": "관심 목록 통합 기능 활성화"
|
||||||
"watchlist_button": "관심 목록 연동 켜기/끄기"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "음악 캐시가 삭제되었습니다",
|
"music_cache_cleared": "음악 캐시가 삭제되었습니다",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error Deleting Files",
|
"error_deleting_files": "Error Deleting Files"
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
|
||||||
"background_downloads_disabled": "Background downloads disabled"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "TV-Series",
|
"series": "TV-Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
|
||||||
"no_items_in_queue": "No Items in Queue",
|
|
||||||
"no_downloaded_items": "No Downloaded Items",
|
"no_downloaded_items": "No Downloaded Items",
|
||||||
"delete_all_movies_button": "Delete All Movies",
|
"delete_all_movies_button": "Delete All Movies",
|
||||||
"delete_all_series_button": "Delete All TV-Series",
|
"delete_all_series_button": "Delete All TV-Series",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
|
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Download Deleted",
|
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Could Not Delete Download",
|
"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": "Download Completed",
|
"download_completed": "Download Completed",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
|
||||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
"go_to_downloads": "Go to Downloads",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
"next_episode": "Next Episode",
|
"next_episode": "Next Episode",
|
||||||
"refresh_tracks": "Refresh Tracks",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Playback State:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
"go_back": "Go Back",
|
"go_back": "Go Back",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Show More",
|
"show_more": "Show More",
|
||||||
"show_less": "Show Less",
|
"show_less": "Show Less",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Geen",
|
"None": "Geen",
|
||||||
"OnlyForced": "Alleen Geforceerd"
|
"OnlyForced": "Alleen Geforceerd"
|
||||||
},
|
},
|
||||||
"text_color": "Tekst kleur",
|
|
||||||
"background_color": "Achtergrond Kleur",
|
|
||||||
"outline_color": "Kleur omlijning",
|
|
||||||
"outline_thickness": "Dikte omlijning",
|
|
||||||
"background_opacity": "Transparantie achtergrond",
|
|
||||||
"outline_opacity": "Doorzichtigheid omlijning",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Zwart",
|
|
||||||
"Gray": "Grijs",
|
|
||||||
"Silver": "Zilver",
|
|
||||||
"White": "Wit",
|
|
||||||
"Maroon": "Kastanjebruin",
|
|
||||||
"Red": "Rood",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Geel",
|
|
||||||
"Olive": "Olijf",
|
|
||||||
"Green": "Groen",
|
|
||||||
"Teal": "Groenblauw",
|
|
||||||
"Lime": "Lichtgroen",
|
|
||||||
"Purple": "Paars",
|
|
||||||
"Navy": "Marine",
|
|
||||||
"Blue": "Blauw",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Geen",
|
|
||||||
"Thin": "Dun",
|
|
||||||
"Normal": "normaal",
|
|
||||||
"Thick": "Dikke"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Kleur ondertiteling",
|
|
||||||
"subtitle_background_color": "Achtergrondkleur",
|
|
||||||
"subtitle_font": "Lettertype ondertitels",
|
|
||||||
"ksplayer_title": "KSPlayer Instellingen",
|
|
||||||
"hardware_decode": "Hardware Acceleratie",
|
|
||||||
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "VLC ondertitel instellingen",
|
|
||||||
"hint": "Aanpassen van ondertiteling voor VLC-speler. Wijzigingen worden toegepast bij het afspelen.",
|
|
||||||
"text_color": "Tekstkleur",
|
|
||||||
"background_color": "Achtergrondkleur",
|
|
||||||
"background_opacity": "Doorzichtigheid achtergrond",
|
|
||||||
"outline_color": "Kleur omlijning",
|
|
||||||
"outline_opacity": "Omtrek opaciteit",
|
|
||||||
"outline_thickness": "Omtrek dikte",
|
|
||||||
"bold": "Bold Text",
|
|
||||||
"margin": "Bottom Margin"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Videospeler",
|
|
||||||
"video_player": "Videospeler",
|
|
||||||
"video_player_description": "Kies welke videospeler gebruikt moet worden op iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Andere",
|
"other_title": "Andere",
|
||||||
"video_orientation": "Video oriëntatie",
|
"video_orientation": "Video oriëntatie",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Onbekend"
|
"UNKNOWN": "Onbekend"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Veilig gebied in bedieningen",
|
"safe_area_in_controls": "Veilig gebied in bedieningen",
|
||||||
"video_player": "Video player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimentele + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
||||||
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
||||||
"hide_libraries": "Verberg Bibliotheken",
|
"hide_libraries": "Verberg Bibliotheken",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
|
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
|
||||||
"disabled": "Uitgeschakeld"
|
"disabled": "Uitgeschakeld"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Muziek",
|
"title": "Muziek",
|
||||||
"playback_title": "Afspelen",
|
"playback_title": "Afspelen",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Uitbreidingen",
|
"plugins_title": "Uitbreidingen",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Deze integratie is nog in een vroeg stadium. Verwacht dat zaken nog veranderen.",
|
|
||||||
"server_url": "Server-URL",
|
"server_url": "Server-URL",
|
||||||
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
|
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Lees meer over Marlin.",
|
"read_more_about_marlin": "Lees meer over Marlin.",
|
||||||
"save_button": "Opslaan",
|
"save_button": "Opslaan",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Opgeslagen",
|
"saved": "Opgeslagen"
|
||||||
"refreshed": "Instellingen zijn vernieuwd vanaf server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Ververs Instellingen van Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Streamystats inschakelen",
|
|
||||||
"disable_streamystats": "Streamystats Uitschakelen",
|
"disable_streamystats": "Streamystats Uitschakelen",
|
||||||
"enable_search": "Gebruik voor Zoeken",
|
"enable_search": "Gebruik voor Zoeken",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Vul de URL van de Streamystats server in. De URL moet http of https bevatten en optioneel de poort.",
|
"streamystats_search_hint": "Vul de URL van de Streamystats server in. De URL moet http of https bevatten en optioneel de poort.",
|
||||||
"read_more_about_streamystats": "Lees Meer over Streamystats.",
|
"read_more_about_streamystats": "Lees Meer over Streamystats.",
|
||||||
"save_button": "Opslaan",
|
|
||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"features_title": "Functies",
|
"features_title": "Functies",
|
||||||
"home_sections_title": "Thuis Secties",
|
|
||||||
"enable_movie_recommendations": "Film Aanbevelingen",
|
"enable_movie_recommendations": "Film Aanbevelingen",
|
||||||
"enable_series_recommendations": "Series Aanbevelingen",
|
"enable_series_recommendations": "Series Aanbevelingen",
|
||||||
"enable_promoted_watchlists": "Gepromote Kijklijst",
|
"enable_promoted_watchlists": "Gepromote Kijklijst",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
|
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} gecached",
|
"music_cache_size": "{{size}} gecached",
|
||||||
"music_cache_cleared": "Muziek cache gewist",
|
"music_cache_cleared": "Muziek cache gewist",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Systeem"
|
"system": "Systeem"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Fout bij het verwijderen van bestanden",
|
"error_deleting_files": "Fout bij het verwijderen van bestanden"
|
||||||
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
|
|
||||||
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "Wachtrij",
|
|
||||||
"other_media": "Andere media",
|
"other_media": "Andere media",
|
||||||
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
|
|
||||||
"no_items_in_queue": "Geen items in wachtrij",
|
|
||||||
"no_downloaded_items": "Geen gedownloade items",
|
"no_downloaded_items": "Geen gedownloade items",
|
||||||
"delete_all_movies_button": "Verwijder alle films",
|
"delete_all_movies_button": "Verwijder alle films",
|
||||||
"delete_all_series_button": "Verwijder alle Series",
|
"delete_all_series_button": "Verwijder alle Series",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
|
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
|
||||||
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
||||||
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
||||||
"download_deleted": "Download verwijderd",
|
|
||||||
"download_cancelled": "Download geannuleerd",
|
"download_cancelled": "Download geannuleerd",
|
||||||
"could_not_delete_download": "Kon download niet verwijderen",
|
"could_not_delete_download": "Kon download niet verwijderen",
|
||||||
"download_paused": "Download gepauzeerd",
|
|
||||||
"could_not_pause_download": "Kan niet pauzeren download",
|
|
||||||
"download_resumed": "Download hervat",
|
|
||||||
"could_not_resume_download": "Kon de download niet hervatten",
|
|
||||||
"download_completed": "Download afgerond",
|
"download_completed": "Download afgerond",
|
||||||
"download_failed": "Download Mislukt",
|
"download_failed": "Download Mislukt",
|
||||||
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
|
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} wordt al gedownload",
|
"item_already_downloading": "{{item}} wordt al gedownload",
|
||||||
"all_files_deleted": "Alle Bestanden Succesvol Gedownload",
|
"all_files_deleted": "Alle Bestanden Succesvol Gedownload",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} verwijderd",
|
"files_deleted_by_type": "{{count}} {{type}} verwijderd",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
|
|
||||||
"failed_to_clean_cache_directory": "Opschonen cachemap mislukt",
|
|
||||||
"could_not_get_download_url_for_item": "Kan download-URL voor {{itemName}} niet ophalen",
|
"could_not_get_download_url_for_item": "Kan download-URL voor {{itemName}} niet ophalen",
|
||||||
"go_to_downloads": "Ga naar downloads",
|
|
||||||
"file_deleted": "{{item}} verwijderd"
|
"file_deleted": "{{item}} verwijderd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Geen",
|
"none": "Geen",
|
||||||
"track": "Spoor",
|
"track": "Spoor",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
"ok": "Oké",
|
"ok": "Oké",
|
||||||
"remove": "Verwijderen",
|
"remove": "Verwijderen",
|
||||||
"next": "Volgende",
|
|
||||||
"back": "Terug",
|
"back": "Terug",
|
||||||
"continue": "Doorgaan",
|
"continue": "Doorgaan",
|
||||||
"verifying": "Verifiëren...",
|
"verifying": "Verifiëren...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Zoek...",
|
"search": "Zoek...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
||||||
"message_from_server": "Bericht van de server",
|
"message_from_server": "Bericht van de server",
|
||||||
"next_episode": "Volgende Aflevering",
|
"next_episode": "Volgende Aflevering",
|
||||||
"refresh_tracks": "Tracks verversen",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Afspeelstatus:",
|
|
||||||
"index": "Index:",
|
|
||||||
"continue_watching": "Verder kijken",
|
"continue_watching": "Verder kijken",
|
||||||
"go_back": "Terug",
|
"go_back": "Terug",
|
||||||
"downloaded_file_title": "Je hebt dit bestand gedownload",
|
"downloaded_file_title": "Je hebt dit bestand gedownload",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Toon meer",
|
"show_more": "Toon meer",
|
||||||
"show_less": "Toon minder",
|
"show_less": "Toon minder",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Volgende ",
|
"next": "Volgende ",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Afspeellijsten",
|
"playlists": "Afspeellijsten",
|
||||||
"tracks": "Nummers"
|
"tracks": "Nummers"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Alle"
|
|
||||||
},
|
|
||||||
"recently_added": "Recent toegevoegd",
|
"recently_added": "Recent toegevoegd",
|
||||||
"recently_played": "Onlangs afgespeeld",
|
"recently_played": "Onlangs afgespeeld",
|
||||||
"frequently_played": "Vaak afgespeeld",
|
"frequently_played": "Vaak afgespeeld",
|
||||||
"explore": "Ontdek",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Afspelen",
|
"play": "Afspelen",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"switch_user": "Switch User on This Server",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "current"
|
"current": "nåværende"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Ingen",
|
"None": "Ingen",
|
||||||
"OnlyForced": "Enkelt"
|
"OnlyForced": "Enkelt"
|
||||||
},
|
},
|
||||||
"text_color": "Tekst farge",
|
|
||||||
"background_color": "Bakgrunnsfarge",
|
|
||||||
"outline_color": "Omrissets farge",
|
|
||||||
"outline_thickness": "Omriss Tykkelse",
|
|
||||||
"background_opacity": "Bakgrunns gjennomsiktighet",
|
|
||||||
"outline_opacity": "Omrissets gjennomsiktighet",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Svart",
|
|
||||||
"Gray": "Grå",
|
|
||||||
"Silver": "Sølv",
|
|
||||||
"White": "Hvit",
|
|
||||||
"Maroon": "Rødbrun",
|
|
||||||
"Red": "Rød",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Gul",
|
|
||||||
"Olive": "Olivengrønn",
|
|
||||||
"Green": "Grønn",
|
|
||||||
"Teal": "Blågrønn",
|
|
||||||
"Lime": "Limegrønn",
|
|
||||||
"Purple": "Lilla",
|
|
||||||
"Navy": "Marineblå",
|
|
||||||
"Blue": "Blå",
|
|
||||||
"Aqua": "Vann"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Ingen",
|
|
||||||
"Thin": "Tynn",
|
|
||||||
"Normal": "Vanlig",
|
|
||||||
"Thick": "Tykk"
|
|
||||||
},
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Annet",
|
"other_title": "Annet",
|
||||||
"video_orientation": "Video Retning",
|
"video_orientation": "Video Retning",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Ukjent"
|
"UNKNOWN": "Ukjent"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sikker sone i kontroller",
|
"safe_area_in_controls": "Sikker sone i kontroller",
|
||||||
"video_player": "Video Spiller",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (eksperimentell + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Vis tilpassede menylenker",
|
"show_custom_menu_links": "Vis tilpassede menylenker",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maks automatisk avspilling Episode Telling",
|
"max_auto_play_episode_count": "Maks automatisk avspilling Episode Telling",
|
||||||
"disabled": "Deaktivert"
|
"disabled": "Deaktivert"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Nedlastinger"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Utvidelser",
|
"plugins_title": "Utvidelser",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Denne integreringen er i tidlige faser. Forvent ting å forandre.",
|
|
||||||
"server_url": "URL til server",
|
"server_url": "URL til server",
|
||||||
"server_url_hint": "Eksempel: http(s)://your-host.url\n(legg til port hvis nødvendig)",
|
"server_url_hint": "Eksempel: http(s)://your-host.url\n(legg til port hvis nødvendig)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Les mer om Marlin.",
|
"read_more_about_marlin": "Les mer om Marlin.",
|
||||||
"save_button": "Lagre",
|
"save_button": "Lagre",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Lagret",
|
"saved": "Lagret"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Slett alle nedlastede filer",
|
"delete_all_downloaded_files": "Slett alle nedlastede filer",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Systemadministrasjon"
|
"system": "Systemadministrasjon"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Feil ved sletting av filer",
|
"error_deleting_files": "Feil ved sletting av filer"
|
||||||
"background_downloads_enabled": "Nedlastinger av bakgrunn aktivert",
|
|
||||||
"background_downloads_disabled": "Bakgrunnsnedlastinger deaktivert"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Nedlastinger",
|
"downloads_title": "Nedlastinger",
|
||||||
"series": "TV-Serier",
|
"series": "TV-Serier",
|
||||||
"movies": "Filmer",
|
"movies": "Filmer",
|
||||||
"queue": "Kø",
|
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
"queue_hint": "Kø og nedlastinger vil gå tapt når appen startes på nytt",
|
|
||||||
"no_items_in_queue": "Ingen elementer i køen",
|
|
||||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||||
"delete_all_movies_button": "Slett alle filmer",
|
"delete_all_movies_button": "Slett alle filmer",
|
||||||
"delete_all_series_button": "Slett alle TV-Serier",
|
"delete_all_series_button": "Slett alle TV-Serier",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-Serier",
|
"failed_to_delete_all_series": "Kunne ikke slette alle TV-Serier",
|
||||||
"deleted_media_successfully": "Slettet andre media vellykket!",
|
"deleted_media_successfully": "Slettet andre media vellykket!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
"download_deleted": "Nedlasting slettet",
|
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Kunne ikke slette nedlasting",
|
"could_not_delete_download": "Kunne ikke slette nedlasting",
|
||||||
"download_paused": "Last ned Pauset",
|
|
||||||
"could_not_pause_download": "Kunne ikke pause nedlasting",
|
|
||||||
"download_resumed": "Nedlastingen er gjenopptatt",
|
|
||||||
"could_not_resume_download": "Kunne ikke fortsette nedlasting",
|
|
||||||
"download_completed": "Nedlasting fullført",
|
"download_completed": "Nedlasting fullført",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Nedlasting feilet for {{item}} – {{error}}",
|
"download_failed_for_item": "Nedlasting feilet for {{item}} – {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobber slettet",
|
|
||||||
"failed_to_clean_cache_directory": "Klarte ikke å tømme mellomlagermappen",
|
|
||||||
"could_not_get_download_url_for_item": "Kunne ikke hente nedlastings-URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Kunne ikke hente nedlastings-URL for {{itemName}}",
|
||||||
"go_to_downloads": "Gå til nedlastinger",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Søk...",
|
"search": "Søk...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
|
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
|
||||||
"message_from_server": "Melding fra tjener: {{message}}",
|
"message_from_server": "Melding fra tjener: {{message}}",
|
||||||
"next_episode": "Neste Episode",
|
"next_episode": "Neste Episode",
|
||||||
"refresh_tracks": "Oppdater sporing",
|
|
||||||
"audio_tracks": "Lyd Tracks:",
|
|
||||||
"playback_state": "Avspillingsstatus:",
|
|
||||||
"index": "Indeks:",
|
|
||||||
"continue_watching": "Fortsett å se",
|
"continue_watching": "Fortsett å se",
|
||||||
"go_back": "Gå tilbake",
|
"go_back": "Gå tilbake",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Vis mer",
|
"show_more": "Vis mer",
|
||||||
"show_less": "Vis mindre",
|
"show_less": "Vis mindre",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Neste",
|
"next": "Neste",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Brak",
|
"None": "Brak",
|
||||||
"OnlyForced": "Tylko wymuszone"
|
"OnlyForced": "Tylko wymuszone"
|
||||||
},
|
},
|
||||||
"text_color": "Kolor tekstu",
|
|
||||||
"background_color": "Kolor tła",
|
|
||||||
"outline_color": "Kolor konturu",
|
|
||||||
"outline_thickness": "Grubość konturu",
|
|
||||||
"background_opacity": "Przezroczystość tła",
|
|
||||||
"outline_opacity": "Przezroczystość konturu",
|
|
||||||
"bold_text": "Tekst pogrubiony",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Czarny",
|
|
||||||
"Gray": "Szary",
|
|
||||||
"Silver": "Srebro",
|
|
||||||
"White": "Biały",
|
|
||||||
"Maroon": "Bordowy",
|
|
||||||
"Red": "Czerwony",
|
|
||||||
"Fuchsia": "Fuksja",
|
|
||||||
"Yellow": "Żółty",
|
|
||||||
"Olive": "Oliwki",
|
|
||||||
"Green": "Zielony",
|
|
||||||
"Teal": "Turkusowy",
|
|
||||||
"Lime": "Limonkowy",
|
|
||||||
"Purple": "Fioletowy",
|
|
||||||
"Navy": "Granatowy",
|
|
||||||
"Blue": "Niebieski",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Brak",
|
|
||||||
"Thin": "Cienka",
|
|
||||||
"Normal": "Normalny",
|
|
||||||
"Thick": "Gruba"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Kolor napisów",
|
|
||||||
"subtitle_background_color": "Kolor tła",
|
|
||||||
"subtitle_font": "Czcionka napisów",
|
|
||||||
"ksplayer_title": "Ustawienia KSPlayer",
|
|
||||||
"hardware_decode": "Dekodowanie sprzętowe",
|
|
||||||
"hardware_decode_description": "Używaj akceleracji sprzętowej dla dekodowania wideo. Wyłącz, jeśli doświadczasz problemów z odtwarzaniem.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "Ustawienia napisów VLC",
|
|
||||||
"hint": "Personalizuj wygląd napisów dla odtwarzacza VLC. Zmiany zajdą przy następnym odtwarzaniu.",
|
|
||||||
"text_color": "Kolor tekstu",
|
|
||||||
"background_color": "Kolor tła",
|
|
||||||
"background_opacity": "Przezroczystość tła",
|
|
||||||
"outline_color": "Kolor obrysu",
|
|
||||||
"outline_opacity": "Przezroczystość obrysu",
|
|
||||||
"outline_thickness": "Grubość obrysu",
|
|
||||||
"bold": "Pogrubiony tekst",
|
|
||||||
"margin": "Dolny margines"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Odtwarzacz wideo",
|
|
||||||
"video_player": "Odtwarzacz wideo",
|
|
||||||
"video_player_description": "Wybierz którego odtwarzacza wideo używać w iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Inne",
|
"other_title": "Inne",
|
||||||
"video_orientation": "Orientacja wideo",
|
"video_orientation": "Orientacja wideo",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Nieznana"
|
"UNKNOWN": "Nieznana"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Bezpieczny obszar w kontrolkach",
|
"safe_area_in_controls": "Bezpieczny obszar w kontrolkach",
|
||||||
"video_player": "Odtwarzacz wideo",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Eksperymentalny + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
|
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
|
||||||
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
|
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
|
||||||
"hide_libraries": "Ukryj biblioteki",
|
"hide_libraries": "Ukryj biblioteki",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maksymalna liczba odcinków automatycznego odtwarzania",
|
"max_auto_play_episode_count": "Maksymalna liczba odcinków automatycznego odtwarzania",
|
||||||
"disabled": "Wyłączone"
|
"disabled": "Wyłączone"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Pobieranie"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Muzyka",
|
"title": "Muzyka",
|
||||||
"playback_title": "Odtwarzanie",
|
"playback_title": "Odtwarzanie",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Wtyczki",
|
"plugins_title": "Wtyczki",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Ta integracja jest na wczesnym etapie. Należy oczekiwać zmian.",
|
|
||||||
"server_url": "URL serwera",
|
"server_url": "URL serwera",
|
||||||
"server_url_hint": "Przykład: http(s)://twoja-nazwa.url\n(dodaj port, jeśli jest wymagany)",
|
"server_url_hint": "Przykład: http(s)://twoja-nazwa.url\n(dodaj port, jeśli jest wymagany)",
|
||||||
"server_url_placeholder": "Adres URL Seerr",
|
"server_url_placeholder": "Adres URL Seerr",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Dowiedz się więcej o Marlin.",
|
"read_more_about_marlin": "Dowiedz się więcej o Marlin.",
|
||||||
"save_button": "Zapisz",
|
"save_button": "Zapisz",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Zapisano",
|
"saved": "Zapisano"
|
||||||
"refreshed": "Ustawienia odświeżone z serwera"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Odśwież ustawienia z serwera"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Włącz Streamystats",
|
|
||||||
"disable_streamystats": "Wyłącz Streamystats",
|
"disable_streamystats": "Wyłącz Streamystats",
|
||||||
"enable_search": "Używaj do wyszukiwania",
|
"enable_search": "Używaj do wyszukiwania",
|
||||||
"url": "Adres URL",
|
"url": "Adres URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Wprowadź adres URL dla twojego serwera Streamystats. URL powinien zawierać http lub https i opcjonalnie port.",
|
"streamystats_search_hint": "Wprowadź adres URL dla twojego serwera Streamystats. URL powinien zawierać http lub https i opcjonalnie port.",
|
||||||
"read_more_about_streamystats": "Dowiedz się więcej o Streamystats.",
|
"read_more_about_streamystats": "Dowiedz się więcej o Streamystats.",
|
||||||
"save_button": "Zapisz",
|
|
||||||
"save": "Zapisz",
|
"save": "Zapisz",
|
||||||
"features_title": "Funkcje",
|
"features_title": "Funkcje",
|
||||||
"home_sections_title": "Sekcja główna",
|
|
||||||
"enable_movie_recommendations": "Rekomendacje filmów",
|
"enable_movie_recommendations": "Rekomendacje filmów",
|
||||||
"enable_series_recommendations": "Rekomendację seriali",
|
"enable_series_recommendations": "Rekomendację seriali",
|
||||||
"enable_promoted_watchlists": "Promowane listy oglądania",
|
"enable_promoted_watchlists": "Promowane listy oglądania",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Odśwież ustawienia z serwera"
|
"refresh_from_server": "Odśwież ustawienia z serwera"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Aktywuj naszą integrację Listy Oglądania",
|
"watchlist_enabler": "Aktywuj naszą integrację Listy Oglądania"
|
||||||
"watchlist_button": "Przelącz integrację Listy Oglądania"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
|
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
|
||||||
"music_cache_title": "Bufor muzyki",
|
"music_cache_title": "Bufor muzyki",
|
||||||
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
|
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
|
||||||
"enable_music_cache": "Włącz bufor muzyki",
|
|
||||||
"clear_music_cache": "Wyczyść bufor muzyki",
|
"clear_music_cache": "Wyczyść bufor muzyki",
|
||||||
"music_cache_size": "Zbuforowano {{size}}",
|
"music_cache_size": "Zbuforowano {{size}}",
|
||||||
"music_cache_cleared": "Wyczyszczono bufor muzyki",
|
"music_cache_cleared": "Wyczyszczono bufor muzyki",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Błąd podczas usuwania plików",
|
"error_deleting_files": "Błąd podczas usuwania plików"
|
||||||
"background_downloads_enabled": "Pobieranie w tle włączone",
|
|
||||||
"background_downloads_disabled": "Pobieranie w tle wyłączone"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Pobrane",
|
"downloads_title": "Pobrane",
|
||||||
"series": "Seriale",
|
"series": "Seriale",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"queue": "Kolejka",
|
|
||||||
"other_media": "Inne media",
|
"other_media": "Inne media",
|
||||||
"queue_hint": "Kolejka i pobierania zostaną utracone po ponownym uruchomieniu aplikacji",
|
|
||||||
"no_items_in_queue": "Brak elementów w kolejce",
|
|
||||||
"no_downloaded_items": "Brak pobranych elementów",
|
"no_downloaded_items": "Brak pobranych elementów",
|
||||||
"delete_all_movies_button": "Usuń wszystkie filmy",
|
"delete_all_movies_button": "Usuń wszystkie filmy",
|
||||||
"delete_all_series_button": "Usuń wszystkie seriale",
|
"delete_all_series_button": "Usuń wszystkie seriale",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Nie udało się usunąć wszystkich seriali",
|
"failed_to_delete_all_series": "Nie udało się usunąć wszystkich seriali",
|
||||||
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
|
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
|
||||||
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
|
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
|
||||||
"download_deleted": "Pobieranie usunięte",
|
|
||||||
"download_cancelled": "Pobieranie anulowane",
|
"download_cancelled": "Pobieranie anulowane",
|
||||||
"could_not_delete_download": "Nie można usunąć pobrania",
|
"could_not_delete_download": "Nie można usunąć pobrania",
|
||||||
"download_paused": "Pobieranie wstrzymane",
|
|
||||||
"could_not_pause_download": "Nie można wstrzymać pobierania",
|
|
||||||
"download_resumed": "Pobieranie wznowione",
|
|
||||||
"could_not_resume_download": "Nie można wznowić pobierania",
|
|
||||||
"download_completed": "Pobieranie zakończone",
|
"download_completed": "Pobieranie zakończone",
|
||||||
"download_failed": "Pobieranie nie powiodło się",
|
"download_failed": "Pobieranie nie powiodło się",
|
||||||
"download_failed_for_item": "Pobieranie nie powiodło się dla {{item}} – {{error}}",
|
"download_failed_for_item": "Pobieranie nie powiodło się dla {{item}} – {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} jest w trakcie pobierania",
|
"item_already_downloading": "{{item}} jest w trakcie pobierania",
|
||||||
"all_files_deleted": "Pomyślnie usunięto wszystkie pobrane",
|
"all_files_deleted": "Pomyślnie usunięto wszystkie pobrane",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} usunięto",
|
"files_deleted_by_type": "{{count}} {{type}} usunięto",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Wszystkie pliki, foldery i zadania zostały pomyślnie usunięte",
|
|
||||||
"failed_to_clean_cache_directory": "Nie udało się wyczyścić katalogu pamięci podręcznej",
|
|
||||||
"could_not_get_download_url_for_item": "Nie można pobrać adresu URL dla {{itemName}}",
|
"could_not_get_download_url_for_item": "Nie można pobrać adresu URL dla {{itemName}}",
|
||||||
"go_to_downloads": "Przejdź do pobranych",
|
|
||||||
"file_deleted": "Usunięto {{item}}"
|
"file_deleted": "Usunięto {{item}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Nic",
|
"none": "Nic",
|
||||||
"track": "Utwór",
|
"track": "Utwór",
|
||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Usuń",
|
"remove": "Usuń",
|
||||||
"next": "Następne",
|
|
||||||
"back": "Poprzednie",
|
"back": "Poprzednie",
|
||||||
"continue": "Kontynuuj",
|
"continue": "Kontynuuj",
|
||||||
"verifying": "Weryfikacja...",
|
"verifying": "Weryfikacja...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Szukaj...",
|
"search": "Szukaj...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
|
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
|
||||||
"message_from_server": "Wiadomość z serwera: {{message}}",
|
"message_from_server": "Wiadomość z serwera: {{message}}",
|
||||||
"next_episode": "Następny odcinek",
|
"next_episode": "Następny odcinek",
|
||||||
"refresh_tracks": "Odśwież ścieżki",
|
|
||||||
"audio_tracks": "Ścieżki audio:",
|
|
||||||
"playback_state": "Stan odtwarzania:",
|
|
||||||
"index": "Indeks:",
|
|
||||||
"continue_watching": "Kontynuuj oglądanie",
|
"continue_watching": "Kontynuuj oglądanie",
|
||||||
"go_back": "Wstecz",
|
"go_back": "Wstecz",
|
||||||
"downloaded_file_title": "Ten plik masz już pobrany",
|
"downloaded_file_title": "Ten plik masz już pobrany",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Pokaż więcej",
|
"show_more": "Pokaż więcej",
|
||||||
"show_less": "Pokaż mniej",
|
"show_less": "Pokaż mniej",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Następny",
|
"next": "Następny",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlisty",
|
"playlists": "Playlisty",
|
||||||
"tracks": "utwory"
|
"tracks": "utwory"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Wszystkie"
|
|
||||||
},
|
|
||||||
"recently_added": "Ostatnio dodano",
|
"recently_added": "Ostatnio dodano",
|
||||||
"recently_played": "Ostatnio odtwarzano",
|
"recently_played": "Ostatnio odtwarzano",
|
||||||
"frequently_played": "Często odtwarzane",
|
"frequently_played": "Często odtwarzane",
|
||||||
"explore": "Odkrywaj",
|
|
||||||
"top_tracks": "Popularne utwory",
|
"top_tracks": "Popularne utwory",
|
||||||
"play": "Odtwórz",
|
"play": "Odtwórz",
|
||||||
"shuffle": "Losuj",
|
"shuffle": "Losuj",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Nenhuma",
|
"None": "Nenhuma",
|
||||||
"OnlyForced": "Somente Forçado"
|
"OnlyForced": "Somente Forçado"
|
||||||
},
|
},
|
||||||
"text_color": "Cor do texto",
|
|
||||||
"background_color": "Cor de fundo",
|
|
||||||
"outline_color": "Cor do contorno",
|
|
||||||
"outline_thickness": "Espessura do Contorno",
|
|
||||||
"background_opacity": "Opacidade de fundo",
|
|
||||||
"outline_opacity": "Opacidade do Contorno",
|
|
||||||
"bold_text": "Texto em negrito",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Preto",
|
|
||||||
"Gray": "Cinzento",
|
|
||||||
"Silver": "Prata",
|
|
||||||
"White": "Branco",
|
|
||||||
"Maroon": "Castanho",
|
|
||||||
"Red": "Vermelho",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Amarelo",
|
|
||||||
"Olive": "Verde-oliva",
|
|
||||||
"Green": "Verde",
|
|
||||||
"Teal": "Verde-azulado",
|
|
||||||
"Lime": "Verde-limão",
|
|
||||||
"Purple": "Roxo",
|
|
||||||
"Navy": "Azul-marinho",
|
|
||||||
"Blue": "Azul",
|
|
||||||
"Aqua": "Água"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Nenhuma",
|
|
||||||
"Thin": "Magro",
|
|
||||||
"Normal": "Normal",
|
|
||||||
"Thick": "Grosso"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Cor da legenda",
|
|
||||||
"subtitle_background_color": "Cor de fundo",
|
|
||||||
"subtitle_font": "Fonte da legenda",
|
|
||||||
"ksplayer_title": "Configurações do KSPlayer",
|
|
||||||
"hardware_decode": "Decodificação por hardware",
|
|
||||||
"hardware_decode_description": "Use aceleração de hardware para decodificação de vídeo. Desative se você tiver problemas de reprodução.",
|
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": "Reprodutor de Vídeo",
|
|
||||||
"video_player": "Reprodutor de Vídeo",
|
|
||||||
"video_player_description": "Escolha qual player de vídeo usar no iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Outros",
|
"other_title": "Outros",
|
||||||
"video_orientation": "Orientação do Vídeo",
|
"video_orientation": "Orientação do Vídeo",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Desconhecido"
|
"UNKNOWN": "Desconhecido"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Área segura nos controles",
|
"safe_area_in_controls": "Área segura nos controles",
|
||||||
"video_player": "Reprodutor de Vídeo",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
|
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
|
||||||
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
|
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Contagem máxima de episódios de reprodução automática",
|
"max_auto_play_episode_count": "Contagem máxima de episódios de reprodução automática",
|
||||||
"disabled": "Desabilitado"
|
"disabled": "Desabilitado"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Música",
|
"title": "Música",
|
||||||
"playback_title": "Reproduzir",
|
"playback_title": "Reproduzir",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Complementos",
|
"plugins_title": "Complementos",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Essa integração está em suas fases iniciais. Espere que as coisas mudem.",
|
|
||||||
"server_url": "URL do servidor",
|
"server_url": "URL do servidor",
|
||||||
"server_url_hint": "Exemplo: http(s)://seu-host.url\n(adicionar porta se necessário)",
|
"server_url_hint": "Exemplo: http(s)://seu-host.url\n(adicionar porta se necessário)",
|
||||||
"server_url_placeholder": "URL do Seerr",
|
"server_url_placeholder": "URL do Seerr",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Leia mais sobre Marlin.",
|
"read_more_about_marlin": "Leia mais sobre Marlin.",
|
||||||
"save_button": "Salvar",
|
"save_button": "Salvar",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Salvo",
|
"saved": "Salvo"
|
||||||
"refreshed": "Configurações atualizadas do servidor"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Atualizar as configurações do servidor"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Ativar Streamystats",
|
|
||||||
"disable_streamystats": "Desativar streamystats",
|
"disable_streamystats": "Desativar streamystats",
|
||||||
"enable_search": "Usar para Pesquisa",
|
"enable_search": "Usar para Pesquisa",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Digite a URL para seu servidor de StreamyStats. A URL deve incluir http ou https e, opcionalmente, a porta.",
|
"streamystats_search_hint": "Digite a URL para seu servidor de StreamyStats. A URL deve incluir http ou https e, opcionalmente, a porta.",
|
||||||
"read_more_about_streamystats": "Leia mais sobre Streamystats.",
|
"read_more_about_streamystats": "Leia mais sobre Streamystats.",
|
||||||
"save_button": "Salvar",
|
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"features_title": "Funcionalidades",
|
"features_title": "Funcionalidades",
|
||||||
"home_sections_title": "Seções da Página Inicial",
|
|
||||||
"enable_movie_recommendations": "Recomendações de filmes",
|
"enable_movie_recommendations": "Recomendações de filmes",
|
||||||
"enable_series_recommendations": "Recomendações de Séries",
|
"enable_series_recommendations": "Recomendações de Séries",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Atualizar Configurações do Servidor"
|
"refresh_from_server": "Atualizar Configurações do Servidor"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Ative nossa integração de Lista de Interesses",
|
"watchlist_enabler": "Ative nossa integração de Lista de Interesses"
|
||||||
"watchlist_button": "Ativar/desativar Lista de Interesses"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Excluir todos os arquivos baixados",
|
"delete_all_downloaded_files": "Excluir todos os arquivos baixados",
|
||||||
"music_cache_title": "Cache de Música",
|
"music_cache_title": "Cache de Música",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"enable_music_cache": "Habilitar Cache de Música",
|
|
||||||
"clear_music_cache": "Limpar Cache de Música",
|
"clear_music_cache": "Limpar Cache de Música",
|
||||||
"music_cache_size": "{{size}} em cache",
|
"music_cache_size": "{{size}} em cache",
|
||||||
"music_cache_cleared": "Cache de música limpo",
|
"music_cache_cleared": "Cache de música limpo",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Erro ao excluir arquivos",
|
"error_deleting_files": "Erro ao excluir arquivos"
|
||||||
"background_downloads_enabled": "Downloads em segundo plano ativados",
|
|
||||||
"background_downloads_disabled": "Downloads em segundo plano desativados"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "TV-Séries",
|
"series": "TV-Séries",
|
||||||
"movies": "Filmes",
|
"movies": "Filmes",
|
||||||
"queue": "Fila",
|
|
||||||
"other_media": "Outras mídias",
|
"other_media": "Outras mídias",
|
||||||
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
|
|
||||||
"no_items_in_queue": "Nenhum item na fila",
|
|
||||||
"no_downloaded_items": "Nenhum item baixado",
|
"no_downloaded_items": "Nenhum item baixado",
|
||||||
"delete_all_movies_button": "Excluir todos os filmes",
|
"delete_all_movies_button": "Excluir todos os filmes",
|
||||||
"delete_all_series_button": "Excluir todas as séries",
|
"delete_all_series_button": "Excluir todas as séries",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Falha ao excluir todas as séries",
|
"failed_to_delete_all_series": "Falha ao excluir todas as séries",
|
||||||
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
|
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
|
||||||
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
||||||
"download_deleted": "Download Excluído",
|
|
||||||
"download_cancelled": "Download Cancelado",
|
"download_cancelled": "Download Cancelado",
|
||||||
"could_not_delete_download": "Não foi possível excluir o download",
|
"could_not_delete_download": "Não foi possível excluir o download",
|
||||||
"download_paused": "Download Pausado",
|
|
||||||
"could_not_pause_download": "Não foi possível Pausar o Download",
|
|
||||||
"download_resumed": "Download Retomado",
|
|
||||||
"could_not_resume_download": "Não foi possível retomar o download",
|
|
||||||
"download_completed": "Download concluído",
|
"download_completed": "Download concluído",
|
||||||
"download_failed": "Download Falhou",
|
"download_failed": "Download Falhou",
|
||||||
"download_failed_for_item": "Download Falhou para {{item}} - {{error}}",
|
"download_failed_for_item": "Download Falhou para {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} já está sendo baixado",
|
"item_already_downloading": "{{item}} já está sendo baixado",
|
||||||
"all_files_deleted": "Todos os Downloads Excluídos com Sucesso",
|
"all_files_deleted": "Todos os Downloads Excluídos com Sucesso",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} excluído",
|
"files_deleted_by_type": "{{count}} {{type}} excluído",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Todos os arquivos, pastas e trabalhos excluídos com sucesso",
|
|
||||||
"failed_to_clean_cache_directory": "Falha ao limpar o diretório de cache",
|
|
||||||
"could_not_get_download_url_for_item": "Não foi possível obter o URL de download para {{itemName}}",
|
"could_not_get_download_url_for_item": "Não foi possível obter o URL de download para {{itemName}}",
|
||||||
"go_to_downloads": "Ir para Downloads",
|
|
||||||
"file_deleted": "{{item}} deletado"
|
"file_deleted": "{{item}} deletado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Nenhum",
|
"none": "Nenhum",
|
||||||
"track": "Faixa",
|
"track": "Faixa",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Apagar",
|
"delete": "Apagar",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Buscar...",
|
"search": "Buscar...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
|
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
|
||||||
"message_from_server": "Mensagem do Servidor: {{message}}",
|
"message_from_server": "Mensagem do Servidor: {{message}}",
|
||||||
"next_episode": "Próximo Episódio",
|
"next_episode": "Próximo Episódio",
|
||||||
"refresh_tracks": "Atualizar Faixas",
|
|
||||||
"audio_tracks": "Faixas de Áudio:",
|
|
||||||
"playback_state": "Estado de Reprodução:",
|
|
||||||
"index": "Índice",
|
|
||||||
"continue_watching": "Continuar assistindo",
|
"continue_watching": "Continuar assistindo",
|
||||||
"go_back": "Voltar atrás",
|
"go_back": "Voltar atrás",
|
||||||
"downloaded_file_title": "Você já fez o download deste arquivo",
|
"downloaded_file_title": "Você já fez o download deste arquivo",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Mostrar mais",
|
"show_more": "Mostrar mais",
|
||||||
"show_less": "Mostrar menos",
|
"show_less": "Mostrar menos",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Próximo",
|
"next": "Próximo",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "faixas"
|
"tracks": "faixas"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "Tudo"
|
|
||||||
},
|
|
||||||
"recently_added": "Adicionado recentemente",
|
"recently_added": "Adicionado recentemente",
|
||||||
"recently_played": "Reproduzido Recentemente",
|
"recently_played": "Reproduzido Recentemente",
|
||||||
"frequently_played": "Reproduzidos com frequência",
|
"frequently_played": "Reproduzidos com frequência",
|
||||||
"explore": "Explorar",
|
|
||||||
"top_tracks": "Músicas populares",
|
"top_tracks": "Músicas populares",
|
||||||
"play": "Reproduzir",
|
"play": "Reproduzir",
|
||||||
"shuffle": "Alteatório",
|
"shuffle": "Alteatório",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -261,43 +261,6 @@
|
|||||||
"None": "Niciuna",
|
"None": "Niciuna",
|
||||||
"OnlyForced": "OnlyForced"
|
"OnlyForced": "OnlyForced"
|
||||||
},
|
},
|
||||||
"text_color": "Culoare text",
|
|
||||||
"background_color": "Culoare fundal",
|
|
||||||
"outline_color": "Culoare contur",
|
|
||||||
"outline_thickness": "Grosime contur",
|
|
||||||
"background_opacity": "Opacitatea fundalului",
|
|
||||||
"outline_opacity": "Opacitatea conturului",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Negru",
|
|
||||||
"Gray": "Gri",
|
|
||||||
"Silver": "Argint",
|
|
||||||
"White": "Alb",
|
|
||||||
"Maroon": "Maro",
|
|
||||||
"Red": "Roșu",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Galben",
|
|
||||||
"Olive": "Oliv",
|
|
||||||
"Green": "Verde",
|
|
||||||
"Teal": "Turcoaz",
|
|
||||||
"Lime": "Verde-Deschis",
|
|
||||||
"Purple": "Violet",
|
|
||||||
"Navy": "Marină",
|
|
||||||
"Blue": "Albastru",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "Nimic",
|
|
||||||
"Thin": "Subțire",
|
|
||||||
"Normal": "Normală",
|
|
||||||
"Thick": "Grozav"
|
|
||||||
},
|
|
||||||
"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_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": "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",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -315,25 +278,6 @@
|
|||||||
"bottom": "Bottom"
|
"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": {
|
||||||
"other_title": "Altele",
|
"other_title": "Altele",
|
||||||
"video_orientation": "Orientarea video",
|
"video_orientation": "Orientarea video",
|
||||||
@@ -351,11 +295,6 @@
|
|||||||
"UNKNOWN": "Necunoscut"
|
"UNKNOWN": "Necunoscut"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Zona sigură pentru controale",
|
"safe_area_in_controls": "Zona sigură pentru controale",
|
||||||
"video_player": "Player video",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
|
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
|
||||||
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
|
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
|
||||||
"hide_libraries": "Ascunde bibliotecile",
|
"hide_libraries": "Ascunde bibliotecile",
|
||||||
@@ -367,9 +306,6 @@
|
|||||||
"max_auto_play_episode_count": "Maxim episoade redare automată",
|
"max_auto_play_episode_count": "Maxim episoade redare automată",
|
||||||
"disabled": "Dezactivat"
|
"disabled": "Dezactivat"
|
||||||
},
|
},
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Descărcări"
|
|
||||||
},
|
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -384,7 +320,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugin-uri",
|
"plugins_title": "Plugin-uri",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Această integrare este în stadii incipiente. Așteptați-vă ca lucrurile să se schimbe.",
|
|
||||||
"server_url": "URL Server",
|
"server_url": "URL Server",
|
||||||
"server_url_hint": "Exemplu: http(s)://your-host.url\n(adăugați portul dacă este necesar)",
|
"server_url_hint": "Exemplu: http(s)://your-host.url\n(adăugați portul dacă este necesar)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
@@ -413,23 +348,18 @@
|
|||||||
"read_more_about_marlin": "Citește mai multe despre Marlin.",
|
"read_more_about_marlin": "Citește mai multe despre Marlin.",
|
||||||
"save_button": "Salvează",
|
"save_button": "Salvează",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Salvat",
|
"saved": "Salvat"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"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.",
|
"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.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -445,8 +375,7 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
"watchlist_enabler": "Enable our Watchlist integration"
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -457,7 +386,6 @@
|
|||||||
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
|
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"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",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -467,8 +395,6 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "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_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_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -490,15 +416,12 @@
|
|||||||
"system": "Sistem"
|
"system": "Sistem"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Eroare la ștergerea fișierelor",
|
"error_deleting_files": "Eroare la ștergerea fișierelor"
|
||||||
"background_downloads_enabled": "Descărcări în fundal activate",
|
|
||||||
"background_downloads_disabled": "Descărcări în fundal dezactivate"
|
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -518,10 +441,7 @@
|
|||||||
"downloads_title": "Descărcări",
|
"downloads_title": "Descărcări",
|
||||||
"series": "Seriale",
|
"series": "Seriale",
|
||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"queue": "Coadă",
|
|
||||||
"other_media": "Alte suporturi",
|
"other_media": "Alte suporturi",
|
||||||
"queue_hint": "Descărcările se vor pierde la repornirea aplicației",
|
|
||||||
"no_items_in_queue": "Niciun articol în coadă",
|
|
||||||
"no_downloaded_items": "Niciun element descărcat",
|
"no_downloaded_items": "Niciun element descărcat",
|
||||||
"delete_all_movies_button": "Șterge toate filmele",
|
"delete_all_movies_button": "Șterge toate filmele",
|
||||||
"delete_all_series_button": "Șterge toate serialele",
|
"delete_all_series_button": "Șterge toate serialele",
|
||||||
@@ -546,13 +466,8 @@
|
|||||||
"failed_to_delete_all_series": "Nu s-au putut șterge toate serialele",
|
"failed_to_delete_all_series": "Nu s-au putut șterge toate serialele",
|
||||||
"deleted_media_successfully": "Alte fișiere șterse cu succes!",
|
"deleted_media_successfully": "Alte fișiere șterse cu succes!",
|
||||||
"failed_to_delete_media": "Ștergerea altor fișiere media a eșuat",
|
"failed_to_delete_media": "Ștergerea altor fișiere media a eșuat",
|
||||||
"download_deleted": "Descărcare ştearsă",
|
|
||||||
"download_cancelled": "Descărcare anulată",
|
"download_cancelled": "Descărcare anulată",
|
||||||
"could_not_delete_download": "Nu s-a putut șterge descărcarea",
|
"could_not_delete_download": "Nu s-a putut șterge descărcarea",
|
||||||
"download_paused": "Descărcare întreruptă",
|
|
||||||
"could_not_pause_download": "Nu s-a putut întrerupe descărcarea",
|
|
||||||
"download_resumed": "Descărcare din nou",
|
|
||||||
"could_not_resume_download": "Nu s-a putut relua descărcarea",
|
|
||||||
"download_completed": "Descărcare completă",
|
"download_completed": "Descărcare completă",
|
||||||
"download_failed": "Descărcare eșuată",
|
"download_failed": "Descărcare eșuată",
|
||||||
"download_failed_for_item": "Descărcarea a eșuat {{item}} - {{error}}",
|
"download_failed_for_item": "Descărcarea a eșuat {{item}} - {{error}}",
|
||||||
@@ -562,10 +477,7 @@
|
|||||||
"item_already_downloading": "{{item}} se descarcă deja",
|
"item_already_downloading": "{{item}} se descarcă deja",
|
||||||
"all_files_deleted": "Toate descărcările au fost șterse cu succes",
|
"all_files_deleted": "Toate descărcările au fost șterse cu succes",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} au fost șterse",
|
"files_deleted_by_type": "{{count}} {{type}} au fost șterse",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Toate fișierele, folderele și lucrările au fost șterse cu succes",
|
|
||||||
"failed_to_clean_cache_directory": "Curățarea directorului cache a eșuat",
|
|
||||||
"could_not_get_download_url_for_item": "Nu s-a putut obține URL-ul de descărcare pentru {{itemName}}",
|
"could_not_get_download_url_for_item": "Nu s-a putut obține URL-ul de descărcare pentru {{itemName}}",
|
||||||
"go_to_downloads": "Accesați descărcările",
|
|
||||||
"file_deleted": "{{item}} șters"
|
"file_deleted": "{{item}} șters"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,16 +495,17 @@
|
|||||||
"none": "Nimic",
|
"none": "Nimic",
|
||||||
"track": "Limbă audio",
|
"track": "Limbă audio",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"refresh": "Refresh"
|
"episodes": "Episodes",
|
||||||
|
"movies": "Movies",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Caută...",
|
"search": "Caută...",
|
||||||
@@ -691,10 +604,6 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
|
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
|
||||||
"message_from_server": "Mesaj de la server: {{message}}",
|
"message_from_server": "Mesaj de la server: {{message}}",
|
||||||
"next_episode": "Episodul următor",
|
"next_episode": "Episodul următor",
|
||||||
"refresh_tracks": "Reîmprospătare piese",
|
|
||||||
"audio_tracks": "Audio:",
|
|
||||||
"playback_state": "Stare de redare:",
|
|
||||||
"index": "Indice:",
|
|
||||||
"continue_watching": "Continuă să vizionezi",
|
"continue_watching": "Continuă să vizionezi",
|
||||||
"go_back": "Înapoi",
|
"go_back": "Înapoi",
|
||||||
"downloaded_file_title": "Aveţi acest fişier descărcat",
|
"downloaded_file_title": "Aveţi acest fişier descărcat",
|
||||||
@@ -723,7 +632,8 @@
|
|||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -761,7 +671,6 @@
|
|||||||
"show_more": "Arată mai mult",
|
"show_more": "Arată mai mult",
|
||||||
"show_less": "Arată mai puțin",
|
"show_less": "Arată mai puțin",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -784,7 +693,8 @@
|
|||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Continue from {{time}}",
|
||||||
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Următorul",
|
"next": "Următorul",
|
||||||
@@ -888,13 +798,9 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -1028,7 +934,6 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"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...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user