mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-24 14:50:26 +01:00
Compare commits
38 Commits
fix/series
...
fix/androi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f99590fd9 | ||
|
|
3b926e0061 | ||
|
|
b256e99fc8 | ||
|
|
e660b98871 | ||
|
|
ce66f0256e | ||
|
|
2ec6594462 | ||
|
|
18f01fa4ab | ||
|
|
df56d62acd | ||
|
|
872d14786e | ||
|
|
5bf07b4798 | ||
|
|
c3dceedad0 | ||
|
|
bf3dc4a366 | ||
|
|
3e81291843 | ||
|
|
7703a1c76f | ||
|
|
7983c68b9f | ||
|
|
335f1efb2c | ||
|
|
434cb3bd39 | ||
|
|
7a6daa011d | ||
|
|
149e3b1b17 | ||
|
|
f00dad02ba | ||
|
|
b7ec841118 | ||
|
|
03864b2a9a | ||
|
|
96116e0451 | ||
|
|
938918fa06 | ||
|
|
a4b6f456f2 | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
168bf2e54e | ||
|
|
6f0230c2ca | ||
|
|
d12beee529 | ||
|
|
02ffac167b | ||
|
|
4eb734c99f | ||
|
|
b7bae0072f | ||
|
|
1685571406 | ||
|
|
36ed7539a2 | ||
|
|
8f82ac481a | ||
|
|
a242ff69fd |
29
.gitattributes
vendored
29
.gitattributes
vendored
@@ -1 +1,28 @@
|
||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
||||
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
||||
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-only scripts must stay CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binary assets — never touched / never normalised
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.mp3 binary
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.pdf binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
*.p12 binary
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,10 +75,13 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you using?
|
||||
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
||||
options:
|
||||
- 0.54.1
|
||||
- 0.51.0
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- 0.28.0
|
||||
- Older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
|
||||
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"
|
||||
64
.github/renovate.json
vendored
64
.github/renovate.json
vendored
@@ -30,9 +30,17 @@
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
],
|
||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
|
||||
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
|
||||
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
|
||||
"datasourceTemplate": "npm",
|
||||
"depNameTemplate": "bun"
|
||||
}
|
||||
],
|
||||
"customDatasources": {
|
||||
@@ -44,22 +52,42 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
||||
"matchPackageNames": [
|
||||
"expo",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-native",
|
||||
"react-native-web",
|
||||
"expo-*",
|
||||
"@expo/*"
|
||||
],
|
||||
"groupName": "Expo SDK",
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
||||
"matchDatasources": ["maven"],
|
||||
"registryUrls": [
|
||||
"https://dl.google.com/dl/android/maven2/",
|
||||
"https://repo.maven.apache.org/maven2/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
129
.github/workflows/artifact-comment.yml
vendored
129
.github/workflows/artifact-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
comment-artifacts:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
||||
name: 📦 Post Build Artifacts
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
|
||||
|
||||
// Log current status of each build for debugging
|
||||
buildRuns.forEach(run => {
|
||||
@@ -184,21 +184,35 @@ jobs:
|
||||
const latestAndroidRun = findBestRun('Android APK Build');
|
||||
const latestIOSRun = findBestRun('iOS IPA Build');
|
||||
|
||||
// Map our build targets to their job display names. Exact name is
|
||||
// tried first so a signed target never collides with its
|
||||
// "(Unsigned)" sibling (whose name contains the signed name).
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)'],
|
||||
'iOS': ['🍎 Build iOS IPA (Phone)'],
|
||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
|
||||
'tvOS': ['🍎 Build tvOS IPA'],
|
||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
|
||||
};
|
||||
|
||||
// Prefer an exact name match over a substring match so
|
||||
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
|
||||
const findJobForTarget = (jobs, jobNames) =>
|
||||
jobs.find(j => jobNames.some(name => j.name === name)) ||
|
||||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
|
||||
|
||||
// Format a millisecond duration as "Xm Ys".
|
||||
const fmtDuration = (ms) => {
|
||||
const min = Math.floor(ms / 60000);
|
||||
const sec = Math.floor((ms % 60000) / 1000);
|
||||
return `${min}m ${sec}s`;
|
||||
};
|
||||
|
||||
// For the consolidated workflow, get individual job statuses
|
||||
if (latestAppsRun) {
|
||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||
|
||||
// Map job names to our build targets. Declared outside the try so
|
||||
// the catch fallback can reuse the same keys.
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
||||
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all jobs for this workflow run
|
||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
@@ -229,10 +243,8 @@ jobs:
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||
);
|
||||
|
||||
const job = findJobForTarget(jobs.jobs, jobNames);
|
||||
|
||||
if (job) {
|
||||
buildStatuses[platform] = {
|
||||
name: job.name,
|
||||
@@ -358,6 +370,43 @@ jobs:
|
||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
||||
});
|
||||
|
||||
// Pull per-job durations from the latest successful develop build so
|
||||
// in-progress / queued targets can show a realistic ETA instead of
|
||||
// an open-ended spinner. Best-effort: any failure just drops the ETA.
|
||||
let referenceDurations = {};
|
||||
try {
|
||||
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'build-apps.yml',
|
||||
branch: 'develop',
|
||||
status: 'success',
|
||||
per_page: 1
|
||||
});
|
||||
|
||||
if (devRuns.workflow_runs.length > 0) {
|
||||
const refRun = devRuns.workflow_runs[0];
|
||||
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: refRun.id
|
||||
});
|
||||
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = findJobForTarget(refJobs.jobs, jobNames);
|
||||
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
|
||||
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
|
||||
}
|
||||
}
|
||||
console.log(`Reference durations from develop run ${refRun.id}:`,
|
||||
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
|
||||
} else {
|
||||
console.log('No successful develop build found for ETA reference');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch develop reference durations:', error.message);
|
||||
}
|
||||
|
||||
// Build comment body with progressive status for individual builds
|
||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
||||
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
||||
@@ -369,9 +418,9 @@ jobs:
|
||||
const buildTargets = [
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
|
||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
|
||||
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||
];
|
||||
|
||||
@@ -387,11 +436,12 @@ jobs:
|
||||
let status = '⏳ Pending';
|
||||
let downloadLink = '*Waiting for build...*';
|
||||
|
||||
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||
// is merged - show them as disabled instead of stuck pending.
|
||||
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||
// Signed tvOS stays disabled until EAS has tvOS provisioning
|
||||
// profiles (app + TopShelf targets); non-interactive builds can't
|
||||
// create them. Unsigned tvOS builds, so it flows through normally.
|
||||
if (target.name === 'tvOS') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
|
||||
} else if (matchingStatus) {
|
||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||
status = '✅ Complete';
|
||||
@@ -406,11 +456,9 @@ jobs:
|
||||
let durationInfo = '';
|
||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
||||
}
|
||||
|
||||
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
@@ -420,10 +468,16 @@ jobs:
|
||||
downloadLink = '*Build cancelled*';
|
||||
} else if (matchingStatus.status === 'in_progress') {
|
||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||
downloadLink = '*Build in progress...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
|
||||
: '*Build in progress...*';
|
||||
} else if (matchingStatus.status === 'queued') {
|
||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||
downloadLink = '*Waiting to start...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
|
||||
: '*Waiting to start...*';
|
||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||
// Workflow completed but conclusion not yet available (rare edge case)
|
||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||
@@ -444,7 +498,22 @@ jobs:
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
// Show installation instructions if we have any artifacts
|
||||
// Static rundown of the build optimisations + what each artifact
|
||||
// installs on. Always shown (even mid-build) so testers know what
|
||||
// to expect before downloads are ready.
|
||||
commentBody += `<details>\n`;
|
||||
commentBody += `<summary>📦 Build details & device compatibility</summary>\n\n`;
|
||||
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
|
||||
commentBody += `| Artifact | Architectures | Installs on |\n`;
|
||||
commentBody += `|---|---|---|\n`;
|
||||
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
|
||||
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
|
||||
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
|
||||
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
|
||||
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
|
||||
commentBody += `</details>\n\n`;
|
||||
|
||||
// Installation instructions only matter once something is downloadable.
|
||||
if (allArtifacts.length > 0) {
|
||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||
|
||||
163
.github/workflows/build-apps.yml
vendored
163
.github/workflows/build-apps.yml
vendored
@@ -11,13 +11,23 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||
# run (artifacts + logs) without needing Expo access.
|
||||
env:
|
||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
jobs:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
name: 🤖 Build Android APK (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
@@ -28,12 +38,12 @@ jobs:
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -43,31 +53,40 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
@@ -76,12 +95,16 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
|
||||
# sideload target). Overrides app.json buildArchs for this build only,
|
||||
# so local `bun run android` (x86_64 emulator) is unaffected.
|
||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
@@ -95,12 +118,16 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-android-tv:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
name: 🤖 Build Android APK (TV)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
@@ -111,12 +138,12 @@ jobs:
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -126,31 +153,40 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
@@ -159,12 +195,15 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
|
||||
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
|
||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
@@ -178,16 +217,20 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -197,15 +240,16 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -219,10 +263,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -231,7 +275,9 @@ jobs:
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
run: eas build -p ios --local --non-interactive
|
||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
||||
# the production version tier and stops them inflating the store build counter.
|
||||
run: eas build -p ios --local --non-interactive --profile ci
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -243,16 +289,20 @@ jobs:
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone-unsigned:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -262,15 +312,16 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -284,7 +335,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -301,18 +352,24 @@ jobs:
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-tv:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix below.
|
||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
||||
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
|
||||
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
|
||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -322,15 +379,16 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -344,10 +402,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -356,7 +414,7 @@ jobs:
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: eas build -p ios --local --non-interactive
|
||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -377,10 +435,11 @@ jobs:
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -390,15 +449,16 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -412,7 +472,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -428,3 +488,6 @@ jobs:
|
||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
11
.github/workflows/check-lockfile.yml
vendored
11
.github/workflows/check-lockfile.yml
vendored
@@ -13,13 +13,13 @@ concurrency:
|
||||
jobs:
|
||||
check-lockfile:
|
||||
name: 🔍 Check bun.lock and package.json consistency
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
@@ -29,14 +29,17 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 🛡️ Verify lockfile consistency
|
||||
run: |
|
||||
|
||||
13
.github/workflows/ci-codeql.yml
vendored
13
.github/workflows/ci-codeql.yml
vendored
@@ -8,11 +8,14 @@ on:
|
||||
schedule:
|
||||
- cron: '24 2 * * *'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: 🔎 Analyze with CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -24,16 +27,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
4
.github/workflows/conflict.yml
vendored
4
.github/workflows/conflict.yml
vendored
@@ -10,14 +10,14 @@ on:
|
||||
jobs:
|
||||
label:
|
||||
name: 🏷️ Labeling Merge Conflicts
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 🚩 Apply merge conflict label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: '⚔️ merge-conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
|
||||
102
.github/workflows/crowdin.yml
vendored
102
.github/workflows/crowdin.yml
vendored
@@ -1,51 +1,51 @@
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
7
.github/workflows/detect-duplicate.yml
vendored
7
.github/workflows/detect-duplicate.yml
vendored
@@ -15,18 +15,19 @@ jobs:
|
||||
detect:
|
||||
name: 🔍 Find similar issues
|
||||
if: github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
|
||||
28
.github/workflows/linting.yml
vendored
28
.github/workflows/linting.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
@@ -46,12 +46,12 @@ jobs:
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -65,11 +65,10 @@ jobs:
|
||||
|
||||
expo-doctor:
|
||||
name: 🚑 Expo Doctor Check
|
||||
if: false
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
@@ -78,17 +77,21 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: 🚑 Run Expo Doctor
|
||||
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
|
||||
# without failing the gate (some checks are known-noisy for this setup).
|
||||
continue-on-error: true
|
||||
run: bun expo-doctor
|
||||
|
||||
code_quality:
|
||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,10 +100,11 @@ jobs:
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
@@ -109,12 +113,14 @@ jobs:
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
# renovate: datasource=node-version depName=node versioning=node
|
||||
node-version: "24.17.0"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
4
.github/workflows/notification.yml
vendored
4
.github/workflows/notification.yml
vendored
@@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: 🚨 Notify Discord on Failure
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -22,8 +22,9 @@ on:
|
||||
jobs:
|
||||
approve:
|
||||
name: 🔐 Approve release
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
environment: production
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: ✅ Release approved
|
||||
run: echo "Release approved for ${{ github.sha }}"
|
||||
@@ -31,7 +32,7 @@ jobs:
|
||||
build:
|
||||
name: 🚀 ${{ matrix.name }}
|
||||
needs: approve
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
@@ -63,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
@@ -72,15 +73,16 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -176,13 +178,13 @@ jobs:
|
||||
name: 📦 Draft GitHub Release
|
||||
needs: build
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
show-progress: false
|
||||
|
||||
50
.github/workflows/trivy-scan.yml
vendored
Normal file
50
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: 🛡️ Trivy Security Scan
|
||||
|
||||
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
||||
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
||||
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
||||
# upload needs a write token that fork PRs don't get).
|
||||
on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
schedule:
|
||||
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: trivy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
name: 🔎 Filesystem scan
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
# Trivy's own action caches the vulnerability DB + binary internally
|
||||
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
||||
# step is needed — it only duplicated the cache.
|
||||
- name: 🔎 Run Trivy filesystem scan
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
scanners: vuln,secret,misconfig
|
||||
ignore-unfixed: true
|
||||
severity: CRITICAL,HIGH
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
|
||||
- name: 📤 Upload results to code scanning
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-fs
|
||||
124
.github/workflows/update-issue-form.yml
vendored
124
.github/workflows/update-issue-form.yml
vendored
@@ -1,67 +1,103 @@
|
||||
name: 🐛 Update Bug Report Template
|
||||
name: 🐛 Update Issue Form Versions
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # Run on every published release on any branch
|
||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
||||
types: [released]
|
||||
schedule:
|
||||
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
||||
workflow_dispatch:
|
||||
|
||||
# Fixed group so a release event and the weekly cron can't race on the same
|
||||
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
||||
concurrency:
|
||||
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
group: update-issue-form
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-bug-report:
|
||||
update-issue-form:
|
||||
name: 🔢 Populate version dropdown
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
|
||||
- name: 🔍 Extract minor version from app.json
|
||||
id: minor
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const fs = require('fs-extra');
|
||||
const semver = require('semver');
|
||||
const content = fs.readJsonSync('./app.json');
|
||||
const version = content.expo.version;
|
||||
const minorVersion = semver.minor(version);
|
||||
return minorVersion.toString();
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 📝 Update bug report version
|
||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||
with:
|
||||
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||
dry_run: no-push
|
||||
- name: 🔢 Populate version dropdown from GitHub releases
|
||||
id: populate
|
||||
run: bun scripts/update-issue-form.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
- name: ⚙️ Update bug report node version dropdown
|
||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||
with:
|
||||
dropdown: _node_version
|
||||
package: node
|
||||
semver: '>=24.0.0'
|
||||
dry_run: no-push
|
||||
|
||||
- name: 📬 Commit and create pull request
|
||||
- name: 📬 Create pull request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
||||
branch: ci/update-issue-form
|
||||
base: develop
|
||||
delete-branch: true
|
||||
labels: ⚙️ ci, 🤖 github-actions
|
||||
title: 'chore(): Update bug report template to match release version'
|
||||
commit-message: "chore: update issue form version dropdown"
|
||||
title: "chore: update issue form version dropdown"
|
||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
||||
body: |
|
||||
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
# 📦 Pull Request
|
||||
|
||||
## 📝 Description
|
||||
|
||||
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
||||
|
||||
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
||||
|
||||
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
|
||||
N/A — automated maintenance.
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
|
||||
N/A — issue-template metadata only, no app UI.
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||
- [x] Verified that changes behave as expected for all platforms
|
||||
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||
- [x] No secrets, hardcoded credentials, or private config files are included
|
||||
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
|
||||
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
||||
|
||||
- name: 🔀 Enable auto-merge
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
||||
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
||||
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
||||
# Auto-merge is still worth enabling: once checks run and reviews land,
|
||||
# the PR merges itself.
|
||||
run: |
|
||||
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
||||
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
@@ -21,10 +20,8 @@ web-build/
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
# Module-specific Builds
|
||||
modules/mpv-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
# Native module build outputs (any module)
|
||||
modules/*/android/build/
|
||||
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
@@ -69,10 +66,6 @@ certs/
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
|
||||
@@ -143,14 +143,6 @@ interface ModalOptions {
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||
- Simple content modal
|
||||
- Modal with custom snap points
|
||||
- Complex component in modal
|
||||
- Success/error modals triggered from functions
|
||||
|
||||
## Default Styling
|
||||
|
||||
The modal uses these default styles (can be overridden via options):
|
||||
|
||||
@@ -1,3 +1,47 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
// Build metadata, injected into `extra.build` and read at runtime via
|
||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||
const git = (args) => {
|
||||
try {
|
||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildMeta = {
|
||||
commit:
|
||||
(
|
||||
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
||||
process.env.GITHUB_SHA ||
|
||||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
||||
git(["rev-parse", "HEAD"]) ||
|
||||
""
|
||||
).slice(0, 7) || null,
|
||||
branch:
|
||||
process.env.EAS_BUILD_GIT_BRANCH ||
|
||||
process.env.GITHUB_HEAD_REF ||
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
||||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
||||
null,
|
||||
profile:
|
||||
process.env.EAS_BUILD_PROFILE ||
|
||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||
null,
|
||||
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
||||
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
||||
runNumber:
|
||||
process.env.GITHUB_RUN_NUMBER ||
|
||||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
||||
null,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
config.extra = { ...config.extra, build: buildMeta };
|
||||
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
|
||||
3
app.json
3
app.json
@@ -107,6 +107,9 @@
|
||||
],
|
||||
"expo-localization",
|
||||
"expo-asset",
|
||||
"expo-audio",
|
||||
"expo-image",
|
||||
"expo-sharing",
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{
|
||||
|
||||
@@ -161,9 +161,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||
</Text>
|
||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
@@ -194,7 +192,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items", { defaultValue: "No items" })}
|
||||
{t("home.no_items")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -137,12 +137,12 @@ export default function DownloadsPage() {
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||
t("home.downloads.toasts.deleted_all_series_successfully"),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
{t("home.downloads.series")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
{t("home.downloads.delete_all_series_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
|
||||
@@ -179,18 +179,15 @@ export default function SettingsTV() {
|
||||
// Handle clearing all cache in the entire app
|
||||
const handleClearCache = async () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
|
||||
t(
|
||||
"home.settings.storage.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.",
|
||||
),
|
||||
t("home.settings.storage.clear_all_cache_confirm"),
|
||||
t("home.settings.storage.clear_all_cache_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel", "Cancel"),
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok", "OK"),
|
||||
text: t("common.ok"),
|
||||
onPress: async () => {
|
||||
try {
|
||||
// 1. Clear React Query Cache (memory & MMKV)
|
||||
@@ -243,11 +240,8 @@ export default function SettingsTV() {
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
Alert.alert(
|
||||
t("home.settings.toasts.error_deleting_files", "Error"),
|
||||
t(
|
||||
"home.settings.storage.clear_all_cache_error_desc",
|
||||
"An error occurred while clearing the cache.",
|
||||
),
|
||||
t("home.settings.toasts.error_deleting_files"),
|
||||
t("home.settings.storage.clear_all_cache_error_desc"),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: false,
|
||||
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
||||
textColor: "#fff",
|
||||
// Android: placeholder and icon color
|
||||
hintTextColor: "#fff",
|
||||
headerIconColor: "#fff",
|
||||
|
||||
@@ -456,10 +456,23 @@ export default function DirectPlayerPage() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
||||
// episode's player) mounts while the old 4K decoder is still alive,
|
||||
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
||||
// later React unmount cleanup is still safe.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
// Pre-libmpv-1.0 used `stop()`:
|
||||
// videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
// Resume inactivity timer when leaving player (TV only)
|
||||
resumeInactivityTimer();
|
||||
// Release the keep-awake wakelock acquired during playback so it
|
||||
// doesn't follow us back to the home screen and block the TV
|
||||
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
||||
// and only released on the "paused" event; without this, navigating
|
||||
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
||||
deactivateKeepAwake();
|
||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1105,6 +1118,15 @@ export default function DirectPlayerPage() {
|
||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
||||
// decoder + surface buffers are freed before the new player screen
|
||||
// mounts. Without this, Expo Router briefly holds two simultaneous
|
||||
// mpv instances during the transition (~768 MB of surface buffers
|
||||
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
||||
// devices. Native stop() is idempotent so the subsequent React
|
||||
// unmount cleanup is still safe.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
}, [
|
||||
nextItem,
|
||||
@@ -1115,6 +1137,7 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue,
|
||||
router,
|
||||
isPlaybackStopped,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
|
||||
@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Image } from "expo-image";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
@@ -100,6 +101,22 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
||||
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
||||
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
||||
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
||||
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
||||
try {
|
||||
Image.configureCache({
|
||||
maxMemoryCost: Platform.isTV
|
||||
? 8 * 1024 * 1024 // ~8 MB on TV
|
||||
: 128 * 1024 * 1024, // ~128 MB on mobile
|
||||
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
||||
});
|
||||
} catch {
|
||||
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
|
||||
@@ -3,7 +3,6 @@ declare global {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Example Usage of Global Modal
|
||||
*
|
||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||
* You can delete this file after understanding how it works.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* Example 1: Simple Content Modal
|
||||
*/
|
||||
export const SimpleModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This is a simple modal with just some text content.
|
||||
</Text>
|
||||
<Text className='text-neutral-400'>
|
||||
Swipe down or tap outside to close.
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Modal with Custom Snap Points
|
||||
*/
|
||||
export const CustomSnapPointsExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6' style={{ minHeight: 400 }}>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Custom Snap Points
|
||||
</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This modal has custom snap points (25%, 50%, 90%).
|
||||
</Text>
|
||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||
<Text className='text-white'>
|
||||
Try dragging the modal to different heights!
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"],
|
||||
enableDynamicSizing: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Complex Component in Modal
|
||||
*/
|
||||
const SettingsModalContent = () => {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
const settings = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Notifications",
|
||||
icon: "notifications-outline" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||
{
|
||||
id: 3,
|
||||
title: "Auto-play",
|
||||
icon: "play-outline" as const,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||
|
||||
{settings.map((setting, index) => (
|
||||
<View
|
||||
key={setting.id}
|
||||
className={`flex-row items-center justify-between py-4 ${
|
||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center gap-3'>
|
||||
<Ionicons name={setting.icon} size={24} color='white' />
|
||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${
|
||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={hideModal}
|
||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||
>
|
||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(<SettingsModalContent />);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||
*/
|
||||
export const useShowSuccessModal = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
return (message: string) => {
|
||||
showModal(
|
||||
<View className='p-6 items-center'>
|
||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||
<Ionicons name='checkmark' size={48} color='white' />
|
||||
</View>
|
||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||
<Text className='text-white text-center'>{message}</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Demo Component
|
||||
*/
|
||||
export const GlobalModalDemo = () => {
|
||||
const showSuccess = useShowSuccessModal();
|
||||
|
||||
return (
|
||||
<View className='p-6 gap-4'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Global Modal Examples
|
||||
</Text>
|
||||
|
||||
<SimpleModalExample />
|
||||
<CustomSnapPointsExample />
|
||||
<ComplexModalExample />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showSuccess("Operation completed successfully!")}
|
||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
const isPresentedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
} else if (isPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
isPresentedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
if (index >= 0) {
|
||||
isPresentedRef.current = true;
|
||||
} else if (index === -1 && isPresentedRef.current) {
|
||||
isPresentedRef.current = false;
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source],
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||
if (!url)
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className='flex flex-col'
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||
/>
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch the image before starting the crossfade
|
||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||
// decoded ARGB) is too large to pin in the memory cache on every
|
||||
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
initialNumToRender={4}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={false}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
style={{ overflow: "visible" }}
|
||||
|
||||
@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
||||
// out of the memory cache avoids bloat when the user cycles through
|
||||
// hero items quickly.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[library],
|
||||
[api, library],
|
||||
);
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
|
||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login")}
|
||||
label={t("login.login_button")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch the image before starting the crossfade
|
||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, _setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className='h-1 bg-red-600 w-full' />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type PosterProps = {
|
||||
id?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(
|
||||
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!url || !id)
|
||||
return (
|
||||
<View
|
||||
className='border border-neutral-900'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentPoster;
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
||||
TextInput fallback on Android TV (the native module is Apple-only). */}
|
||||
{Platform.OS === "ios" ? (
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{/* No horizontal margin here: the native tvOS search bar centers
|
||||
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
style={{
|
||||
height: 56,
|
||||
width: "100%",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 20,
|
||||
fontSize: 28,
|
||||
color: "#fff",
|
||||
}}
|
||||
placeholder={t("search.search")}
|
||||
placeholderTextColor='rgba(255,255,255,0.4)'
|
||||
onChangeText={setSearch}
|
||||
defaultValue=''
|
||||
autoFocus={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { settings } = useSettings();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||
<ListItem
|
||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
|
||||
</>
|
||||
) : (
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Application from "expo-application";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getVersionInfo } from "@/utils/version";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
// Graduated build identifier — see utils/version.ts:
|
||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
||||
const { display: version } = getVersionInfo();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
<Image
|
||||
placeholder={{ blurhash }}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{ uri: imageUrl }}
|
||||
recyclingKey={item.Id}
|
||||
cachePolicy='memory-disk'
|
||||
contentFit='cover'
|
||||
style={{
|
||||
|
||||
@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
{info?.demuxerMaxBytes !== undefined
|
||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
||||
")"
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.estimatedVfFps !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||
|
||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||
{ label: "English", value: "eng" },
|
||||
{ label: "Spanish", value: "spa" },
|
||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||
{ label: "Hindi", value: "hin" },
|
||||
{ label: "Arabic", value: "ara" },
|
||||
{ label: "French", value: "fra" },
|
||||
{ label: "Russian", value: "rus" },
|
||||
{ label: "Portuguese", value: "por" },
|
||||
{ label: "Japanese", value: "jpn" },
|
||||
{ label: "German", value: "deu" },
|
||||
{ label: "Italian", value: "ita" },
|
||||
{ label: "Korean", value: "kor" },
|
||||
{ label: "Turkish", value: "tur" },
|
||||
{ label: "Dutch", value: "nld" },
|
||||
{ label: "Polish", value: "pol" },
|
||||
{ label: "Vietnamese", value: "vie" },
|
||||
{ label: "Thai", value: "tha" },
|
||||
{ label: "Indonesian", value: "ind" },
|
||||
{ label: "Greek", value: "ell" },
|
||||
{ label: "Swedish", value: "swe" },
|
||||
{ label: "Danish", value: "dan" },
|
||||
{ label: "Norwegian", value: "nor" },
|
||||
{ label: "Finnish", value: "fin" },
|
||||
{ label: "Czech", value: "ces" },
|
||||
{ label: "Hungarian", value: "hun" },
|
||||
{ label: "Romanian", value: "ron" },
|
||||
{ label: "Ukrainian", value: "ukr" },
|
||||
{ label: "Hebrew", value: "heb" },
|
||||
{ label: "Bengali", value: "ben" },
|
||||
{ label: "Punjabi", value: "pan" },
|
||||
{ label: "Tagalog", value: "tgl" },
|
||||
{ label: "Swahili", value: "swa" },
|
||||
{ label: "Malay", value: "msa" },
|
||||
{ label: "Persian", value: "fas" },
|
||||
{ label: "Urdu", value: "urd" },
|
||||
];
|
||||
16
eas.json
16
eas.json
@@ -52,7 +52,7 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"bun": "1.3.5",
|
||||
"bun": "1.3.14",
|
||||
"environment": "production",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"bun": "1.3.5",
|
||||
"bun": "1.3.14",
|
||||
"environment": "production",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"bun": "1.3.5",
|
||||
"bun": "1.3.14",
|
||||
"environment": "production",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
},
|
||||
"production_tv": {
|
||||
"bun": "1.3.5",
|
||||
"bun": "1.3.14",
|
||||
"environment": "production",
|
||||
"autoIncrement": true,
|
||||
"env": {
|
||||
@@ -97,6 +97,14 @@
|
||||
"credentialsSource": "local",
|
||||
"config": "ios-production.yml"
|
||||
}
|
||||
},
|
||||
"ci": {
|
||||
"extends": "production",
|
||||
"autoIncrement": false
|
||||
},
|
||||
"ci_tv": {
|
||||
"extends": "production_tv",
|
||||
"autoIncrement": false
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router],
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type * as ImageColorsType from "react-native-image-colors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||
const ImageColors = Platform.isTV
|
||||
? null
|
||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
return null;
|
||||
}, [api, item, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (disabled) return;
|
||||
if (source?.uri) {
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
if (_primary && _text) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||
|
||||
if (isTv) return;
|
||||
};
|
||||
@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
const fetchSSID = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
const result = await getSSID();
|
||||
console.log("[WiFi Debug] Native module SSID:", result);
|
||||
setSSID(result);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -27,6 +28,7 @@ class DownloadService : Service() {
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
private var isForegroundStarted = false
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
@@ -36,6 +38,12 @@ class DownloadService : Service() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
|
||||
wakeLock?.acquire()
|
||||
|
||||
Log.d(TAG, "Wake lock acquired")
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
@@ -93,6 +101,8 @@ class DownloadService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
Log.d(TAG, "Wake lock released")
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -53,5 +53,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
// libmpv from Maven Central
|
||||
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -3,13 +3,14 @@ package expo.modules.mpvplayer
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* MPV renderer that wraps libmpv for video playback.
|
||||
@@ -35,6 +36,30 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
|
||||
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
|
||||
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
|
||||
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
|
||||
* → ANR. We force software decoding here.
|
||||
*
|
||||
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
|
||||
* false positive would needlessly drop shipping hardware to software decoding. The
|
||||
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
|
||||
* emulator build fingerprint, none of which appear on real devices.
|
||||
*/
|
||||
private fun isEmulator(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase()
|
||||
if (hardware == "goldfish" || hardware == "ranchu") return true
|
||||
|
||||
val product = Build.PRODUCT
|
||||
if (product == "sdk" || product.startsWith("sdk_")) return true
|
||||
|
||||
val fingerprint = Build.FINGERPRINT
|
||||
return fingerprint.startsWith("generic") ||
|
||||
fingerprint.contains("emulator", ignoreCase = true)
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||
fun onPauseChanged(isPaused: Boolean)
|
||||
@@ -51,8 +76,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
private var surface: Surface? = null
|
||||
private var isRunning = false
|
||||
private var isStopping = false
|
||||
|
||||
|
||||
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
||||
// player screen gets a fresh mpv handle and drops the reference on stop.
|
||||
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
||||
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
||||
// so we mirror Findroid and let the JVM GC + native finalization path
|
||||
// reclaim resources. Only one player is alive at a time in this app.
|
||||
private var mpv: MPVLib? = null
|
||||
|
||||
// Cached state
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
@@ -112,100 +144,108 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
|
||||
try {
|
||||
MPVLib.create(context)
|
||||
MPVLib.addObserver(this)
|
||||
|
||||
/**
|
||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||
*
|
||||
* Technical Background:
|
||||
* ====================
|
||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
||||
* even when subtitle tracks are properly detected and loaded.
|
||||
*
|
||||
* Why This Is Necessary:
|
||||
* =====================
|
||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
||||
*
|
||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
||||
* configured directory, mpv either:
|
||||
* - Fails silently (subtitles don't appear)
|
||||
* - Falls back to a default font that may not support the required character set
|
||||
* - Crashes or produces rendering errors
|
||||
*
|
||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
||||
*
|
||||
* Reference:
|
||||
* =========
|
||||
* This workaround is documented in the mpv-android project:
|
||||
* https://github.com/mpv-android/mpv-android/issues/96
|
||||
*
|
||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
||||
*/
|
||||
// Create mpv config directory and copy font files
|
||||
// Per-instance handle — see class-level comment. Each player gets
|
||||
// its own mpv; we drop the reference in stop().
|
||||
val mpv = MPVLib.create(context)
|
||||
this.mpv = mpv
|
||||
mpv.addObserver(this)
|
||||
|
||||
// Resolved once — TV gets the memory-pressure customizations
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||
// This needs to be named `subfont.ttf` else it won't work
|
||||
arrayOf("subfont.ttf").forEach { fileName ->
|
||||
val file = File(mpvDir, fileName)
|
||||
if (file.exists()) return@forEach
|
||||
context.assets
|
||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||
.copyTo(FileOutputStream(file))
|
||||
|
||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||
// persists its font index across runs instead of re-walking
|
||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||
// holds onto. Without this we see "No usable fontconfig
|
||||
// configuration file found, using fallback" on every re-init.
|
||||
try {
|
||||
val cacheDir = context.cacheDir.absolutePath
|
||||
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
||||
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
||||
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
||||
Os.setenv("HOME", configDir, true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
||||
}
|
||||
MPVLib.setOptionString("config", "yes")
|
||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
||||
|
||||
mpv?.setOptionString("config", "yes")
|
||||
mpv?.setOptionString("config-dir", mpvDir.path)
|
||||
|
||||
// Configure mpv options before initialization (based on Findroid)
|
||||
this.voDriver = voDriver
|
||||
MPVLib.setOptionString("vo", voDriver)
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
mpv?.setOptionString("vo", voDriver)
|
||||
mpv?.setOptionString("gpu-context", "android")
|
||||
mpv?.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Hardware video decoding
|
||||
// TV: zero-copy (mediacodec) for better performance on low-power devices
|
||||
// Mobile: copy mode (mediacodec-copy) for better compatibility
|
||||
val isTV = isTvDevice()
|
||||
if (isTV) {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec")
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
} else {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||
// Hardware decoder codecs (shared)
|
||||
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
|
||||
// Pause on initial cache fill (shared default). The actual
|
||||
// cache mode, cache-secs, and demuxer cache sizes come from
|
||||
// user preferences and are applied per-load in load().
|
||||
mpv?.setOptionString("cache-pause-initial", "yes")
|
||||
|
||||
// Hardware decode path + TV-only memory options. Demuxer cache
|
||||
// sizes and cache-secs are NOT set here — they come from user
|
||||
// preferences via load().
|
||||
// - Emulator: software decode. Its MediaCodec can't bind an
|
||||
// output surface (surface 0x0); HEVC then fails cleanly and
|
||||
// mpv auto-falls-back to software, but H.264 "opens"
|
||||
// deceptively and wedges the core with no fallback (black
|
||||
// video, then any command — seek/pause — deadlocks the UI
|
||||
// thread → ANR). hwdec=no makes every codec render via the
|
||||
// gpu-next VO. Real devices unaffected.
|
||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
||||
// low-power devices) + fast profile.
|
||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTV -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
mpv?.setOptionString("profile", "fast")
|
||||
// Don't retain already-played content for backward
|
||||
// seeking over a network source — Jellyfin can re-fetch
|
||||
// on demand. Saves up to ~30 MiB on long seeks and
|
||||
// reduces swap pressure.
|
||||
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
||||
// Larger audio buffer to absorb page-fault stalls
|
||||
// (default ~0.2s). Cheap insurance against the audio
|
||||
// underruns that happen when the kernel is swap-thrashing.
|
||||
mpv?.setOptionString("audio-buffer", "0.5")
|
||||
}
|
||||
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
||||
}
|
||||
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
|
||||
// Cache settings for better network streaming
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-pause-initial", "yes")
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
||||
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
||||
|
||||
// Seeking optimization - faster seeking at the cost of less precision
|
||||
// Use keyframe seeking by default (much faster for network streams)
|
||||
MPVLib.setOptionString("hr-seek", "no")
|
||||
mpv?.setOptionString("hr-seek", "no")
|
||||
// Drop frames during seeking for faster response
|
||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||
|
||||
// Subtitle settings
|
||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||
MPVLib.setOptionString("sub-use-margins", "no")
|
||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||
MPVLib.setOptionString("subs-fallback", "yes")
|
||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||
mpv?.setOptionString("sub-use-margins", "no")
|
||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||
mpv?.setOptionString("subs-fallback", "yes")
|
||||
|
||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
MPVLib.setOptionString("keep-open", "always")
|
||||
|
||||
MPVLib.initialize()
|
||||
|
||||
mpv?.setOptionString("force-window", "no")
|
||||
mpv?.setOptionString("keep-open", "always")
|
||||
|
||||
mpv.initialize()
|
||||
|
||||
// Observe properties
|
||||
observeProperties()
|
||||
|
||||
@@ -218,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (isStopping) return
|
||||
if (!isRunning) return
|
||||
|
||||
isStopping = true
|
||||
isRunning = false
|
||||
|
||||
try {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||
}
|
||||
|
||||
isStopping = false
|
||||
|
||||
val m = mpv
|
||||
mpv = null
|
||||
|
||||
// Clear cached media state on the main thread so the next player
|
||||
// screen doesn't observe stale position/duration values during the
|
||||
// (async) teardown below.
|
||||
currentUrl = null
|
||||
currentHeaders = null
|
||||
pendingExternalSubtitles = emptyList()
|
||||
initialSubtitleId = null
|
||||
initialAudioId = null
|
||||
cachedPosition = 0.0
|
||||
cachedDuration = 0.0
|
||||
cachedCacheSeconds = 0.0
|
||||
|
||||
if (m == null) return
|
||||
|
||||
// Teardown runs on a background daemon thread. mpv's "stop" command
|
||||
// flushes the demuxer queue and releases the MediaCodec hardware
|
||||
// decoder — synchronous JNI work that can block for hundreds of ms
|
||||
// on TV hardware. Running it on the main thread produced a visible
|
||||
// delay/stutter between pressing "exit" and the confirm alert
|
||||
// appearing. The local `m` keeps the MPVLib instance alive for the
|
||||
// lifetime of this thread even though we've already nulled `mpv`.
|
||||
Thread {
|
||||
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
||||
// force-window=yes, mpv tears down the decoder at stop time but
|
||||
// tries to keep the VO alive — which fires an internal
|
||||
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
||||
// reconfig path crashes with "Missing surface pointer" because we
|
||||
// detach the Surface below before mpv's worker reaches the
|
||||
// reconfig step (command() is async). Setting force-window=no
|
||||
// first makes mpv tear VO down cleanly instead of attempting a
|
||||
// doomed re-init, eliminating the fatal VO error and the
|
||||
// "playback won't restart" aftermath.
|
||||
try {
|
||||
m.setOptionString("force-window", "no")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
||||
}
|
||||
try {
|
||||
// Stop playback — flushes demuxer queue and signals MediaCodec
|
||||
// to release its hardware decoders. This is the bulk of what
|
||||
// we can reclaim without calling destroy().
|
||||
m.command(arrayOf("stop"))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.detachSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
||||
}
|
||||
}.also { it.isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
MPVLib.attachSurface(surface)
|
||||
MPVLib.setOptionString("force-window", "yes")
|
||||
mpv?.attachSurface(surface)
|
||||
mpv?.setOptionString("force-window", "yes")
|
||||
// Read back vo to confirm it's still active
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||
}
|
||||
}
|
||||
@@ -270,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
MPVLib.detachSurface()
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
mpv?.detachSurface()
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
@@ -282,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
*/
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
if (isRunning) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
@@ -298,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
MPVLib.command(arrayOf("frame-step"))
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,29 +397,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
startPosition: Double? = null,
|
||||
externalSubtitles: List<String>? = null,
|
||||
initialSubtitleId: Int? = null,
|
||||
initialAudioId: Int? = null
|
||||
initialAudioId: Int? = null,
|
||||
cacheEnabled: String? = null,
|
||||
cacheSeconds: Int? = null,
|
||||
demuxerMaxBytes: Int? = null,
|
||||
demuxerMaxBackBytes: Int? = null
|
||||
) {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
|
||||
_isLoading = true
|
||||
isReadyToSeek = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||
|
||||
|
||||
// Stop previous playback
|
||||
MPVLib.command(arrayOf("stop"))
|
||||
|
||||
mpv?.command(arrayOf("stop"))
|
||||
|
||||
// Set HTTP headers if provided
|
||||
updateHttpHeaders(headers)
|
||||
|
||||
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
||||
// These override the conservative defaults applied in start() so the
|
||||
// TV/mobile settings screen actually takes effect on Android.
|
||||
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
||||
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
||||
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
||||
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
||||
|
||||
// Set start position
|
||||
// Set start position. mpv's time parser requires '.' as the decimal
|
||||
// separator; use Locale.US so devices with other default locales
|
||||
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
||||
if (startPosition != null && startPosition > 0) {
|
||||
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||
} else {
|
||||
MPVLib.setPropertyString("start", "0")
|
||||
mpv?.setPropertyString("start", "0")
|
||||
}
|
||||
|
||||
// Set initial audio track if specified
|
||||
@@ -352,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
// Load the file
|
||||
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||
}
|
||||
|
||||
fun reloadCurrentItem() {
|
||||
@@ -368,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||
MPVLib.setPropertyString("http-header-fields", headerString)
|
||||
mpv?.setPropertyString("http-header-fields", headerString)
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
// Video dimensions for PiP aspect ratio
|
||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
mpv?.setPropertyBoolean("pause", false)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
MPVLib.setPropertyBoolean("pause", true)
|
||||
mpv?.setPropertyBoolean("pause", true)
|
||||
}
|
||||
|
||||
fun togglePause() {
|
||||
@@ -400,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun seekTo(seconds: Double) {
|
||||
val clamped = maxOf(0.0, seconds)
|
||||
cachedPosition = clamped
|
||||
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
}
|
||||
|
||||
fun seekBy(seconds: Double) {
|
||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
_playbackSpeed = speed
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
mpv?.setPropertyDouble("speed", speed)
|
||||
}
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
@@ -423,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "sub") continue
|
||||
|
||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -447,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||
if (trackId < 0) {
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
mpv?.setPropertyInt("sid", trackId)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return MPVLib.getPropertyInt("sid") ?: 0
|
||||
return mpv?.getPropertyInt("sid") ?: 0
|
||||
}
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
MPVLib.setPropertyInt("sub-pos", position)
|
||||
mpv?.setPropertyInt("sub-pos", position)
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||
mpv?.setPropertyDouble("sub-scale", scale)
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||
mpv?.setPropertyString("sub-align-x", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||
mpv?.setPropertyString("sub-align-y", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
MPVLib.setPropertyInt("sub-font-size", size)
|
||||
mpv?.setPropertyInt("sub-font-size", size)
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
MPVLib.setPropertyString("sub-border-style", style)
|
||||
mpv?.setPropertyString("sub-border-style", style)
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String) {
|
||||
MPVLib.setPropertyString("sub-back-color", color)
|
||||
mpv?.setPropertyString("sub-back-color", color)
|
||||
}
|
||||
|
||||
fun setSubtitleAssOverride(mode: String) {
|
||||
MPVLib.setPropertyString("sub-ass-override", mode)
|
||||
mpv?.setPropertyString("sub-ass-override", mode)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
@@ -509,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "audio") continue
|
||||
|
||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
|
||||
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
||||
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
||||
if (channels != null && channels > 0) {
|
||||
track["channels"] = channels
|
||||
}
|
||||
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -538,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
mpv?.setPropertyInt("aid", trackId)
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return MPVLib.getPropertyInt("aid") ?: 0
|
||||
return mpv?.getPropertyInt("aid") ?: 0
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
@@ -551,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
@@ -560,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// Video dimensions
|
||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
info["videoWidth"] = it
|
||||
}
|
||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
info["videoHeight"] = it
|
||||
}
|
||||
|
||||
// Video codec
|
||||
MPVLib.getPropertyString("video-format")?.let {
|
||||
mpv?.getPropertyString("video-format")?.let {
|
||||
info["videoCodec"] = it
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||
info["audioCodec"] = it
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
info["fps"] = it
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["videoBitrate"] = it
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["audioBitrate"] = it
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
info["cacheSeconds"] = it
|
||||
}
|
||||
|
||||
// Configured cache limits — read back from mpv to confirm user
|
||||
// settings actually took effect. mpv stores byte sizes as int64
|
||||
// (bytes); convert to MiB for display.
|
||||
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
||||
info["cacheSecsLimit"] = secs
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||
info["droppedFrames"] = it
|
||||
}
|
||||
|
||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||
MPVLib.getPropertyString("vo")?.let {
|
||||
mpv?.getPropertyString("vo")?.let {
|
||||
info["voDriver"] = it
|
||||
}
|
||||
|
||||
// Active hardware decoder
|
||||
MPVLib.getPropertyString("hwdec-active")?.let {
|
||||
// Active hardware decoder.
|
||||
// hwdec-current yields e.g. "mediacodec",
|
||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||
mpv?.getPropertyString("hwdec-current")?.let {
|
||||
info["hwdec"] = it
|
||||
}
|
||||
|
||||
// Estimated video output fps (renderer-side, after filtering).
|
||||
// Useful for diagnosing display/pipeline drops vs container fps.
|
||||
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
||||
info["estimatedVfFps"] = it
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -704,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
}
|
||||
pendingExternalSubtitles = emptyList()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||
|
||||
/**
|
||||
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
* This provides a consistent interface for the rest of the app.
|
||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
*
|
||||
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
||||
* a fresh, independent handle. Each player creates its own MPVLib instance
|
||||
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
||||
* call `LibMPV.destroy()` — its native implementation has an internal
|
||||
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
||||
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
||||
* handle lives in process-global state until exit) is strictly safer than
|
||||
* crashing.
|
||||
*
|
||||
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
||||
* stays allocated until the next player's allocation displaces it in scudo's
|
||||
* arena. On a TV app where the player is the dominant memory consumer and
|
||||
* only one player is alive at a time, this is acceptable.
|
||||
*/
|
||||
object MPVLib {
|
||||
private const val TAG = "MPVLib"
|
||||
|
||||
private var initialized = false
|
||||
|
||||
// Event observer interface
|
||||
class MPVLib private constructor(private val instance: LibMPV) {
|
||||
|
||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||
interface EventObserver {
|
||||
fun eventProperty(property: String)
|
||||
fun eventProperty(property: String, value: Long)
|
||||
@@ -23,198 +32,144 @@ object MPVLib {
|
||||
fun eventProperty(property: String, value: Double)
|
||||
fun event(eventId: Int)
|
||||
}
|
||||
|
||||
|
||||
private val observers = mutableListOf<EventObserver>()
|
||||
|
||||
// Library event observer that forwards to our observers
|
||||
|
||||
// Library event observer that forwards LibMPV callbacks to our observers.
|
||||
private val libObserver = object : LibMPV.EventObserver {
|
||||
override fun eventProperty(property: String) {
|
||||
override fun eventProperty(property: String) =
|
||||
dispatch { it.eventProperty(property) }
|
||||
|
||||
override fun eventProperty(property: String, value: Long) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: String) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Double) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun event(eventId: Int) =
|
||||
dispatch { it.event(eventId) }
|
||||
|
||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.event(eventId)
|
||||
}
|
||||
observers.forEach(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addObserver(observer: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.add(observer)
|
||||
}
|
||||
synchronized(observers) { observers.add(observer) }
|
||||
}
|
||||
|
||||
|
||||
fun removeObserver(observer: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.remove(observer)
|
||||
}
|
||||
synchronized(observers) { observers.remove(observer) }
|
||||
}
|
||||
|
||||
// MPV Event IDs
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
|
||||
/**
|
||||
* Create and initialize the MPV library
|
||||
*/
|
||||
fun create(context: Context, configDir: String? = null) {
|
||||
if (initialized) return
|
||||
|
||||
try {
|
||||
LibMPV.create(context)
|
||||
LibMPV.addObserver(libObserver)
|
||||
initialized = true
|
||||
Log.i(TAG, "libmpv created successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun initialize() {
|
||||
LibMPV.init()
|
||||
instance.init()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (!initialized) return
|
||||
try {
|
||||
LibMPV.removeObserver(libObserver)
|
||||
LibMPV.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
||||
}
|
||||
initialized = false
|
||||
|
||||
fun attachSurface(surface: android.view.Surface) {
|
||||
instance.attachSurface(surface)
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean = initialized
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
LibMPV.attachSurface(surface)
|
||||
}
|
||||
|
||||
|
||||
fun detachSurface() {
|
||||
LibMPV.detachSurface()
|
||||
instance.detachSurface()
|
||||
}
|
||||
|
||||
fun command(cmd: Array<String?>) {
|
||||
LibMPV.command(cmd)
|
||||
|
||||
fun command(cmd: Array<String>) {
|
||||
instance.command(cmd)
|
||||
}
|
||||
|
||||
|
||||
fun setOptionString(name: String, value: String): Int {
|
||||
return LibMPV.setOptionString(name, value)
|
||||
return instance.setOptionString(name, value)
|
||||
}
|
||||
|
||||
fun getPropertyInt(name: String): Int? {
|
||||
return try {
|
||||
LibMPV.getPropertyInt(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyDouble(name: String): Double? {
|
||||
return try {
|
||||
LibMPV.getPropertyDouble(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyBoolean(name: String): Boolean? {
|
||||
return try {
|
||||
LibMPV.getPropertyBoolean(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyString(name: String): String? {
|
||||
return try {
|
||||
LibMPV.getPropertyString(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getPropertyInt(name: String): Int? = try {
|
||||
instance.getPropertyInt(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyDouble(name: String): Double? = try {
|
||||
instance.getPropertyDouble(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyBoolean(name: String): Boolean? = try {
|
||||
instance.getPropertyBoolean(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyString(name: String): String? = try {
|
||||
instance.getPropertyString(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun setPropertyInt(name: String, value: Int) {
|
||||
LibMPV.setPropertyInt(name, value)
|
||||
instance.setPropertyInt(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyDouble(name: String, value: Double) {
|
||||
LibMPV.setPropertyDouble(name, value)
|
||||
instance.setPropertyDouble(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||
LibMPV.setPropertyBoolean(name, value)
|
||||
instance.setPropertyBoolean(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun setPropertyString(name: String, value: String) {
|
||||
LibMPV.setPropertyString(name, value)
|
||||
instance.setPropertyString(name, value)
|
||||
}
|
||||
|
||||
|
||||
fun observeProperty(name: String, format: Int) {
|
||||
LibMPV.observeProperty(name, format)
|
||||
instance.observeProperty(name, format)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a fresh mpv handle. Each call returns an independent instance —
|
||||
* do not share across players. Attach exactly one [EventObserver] per
|
||||
* player via [addObserver].
|
||||
*/
|
||||
fun create(context: Context): MPVLib {
|
||||
val lib = LibMPV.create(context)
|
||||
?: throw IllegalStateException("LibMPV.create returned null")
|
||||
val wrapper = MPVLib(lib)
|
||||
// The libObserver is attached for the lifetime of this MPVLib
|
||||
// instance and forwards every LibMPV callback to our observers
|
||||
// list. Player-specific observers are added/removed via
|
||||
// addObserver/removeObserver.
|
||||
lib.addObserver(wrapper.libObserver)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// MPV Event IDs (kept here so observers can reference them without
|
||||
// holding a reference to an instance).
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ class MpvPlayerModule : Module() {
|
||||
if (source == null) return@Prop
|
||||
|
||||
val urlString = source["url"] as? String ?: return@Prop
|
||||
|
||||
|
||||
// Parse cache config if provided (mirrors iOS)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||
voDriver = source["voDriver"] as? String
|
||||
voDriver = source["voDriver"] as? String,
|
||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||
)
|
||||
|
||||
view.loadVideo(config)
|
||||
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// Stop playback and release the MediaCodec decoder + demuxer.
|
||||
// Does not synchronously tear down the native mpv handle (see
|
||||
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
||||
// away from the player screen to avoid OOM during screen
|
||||
// transitions on low-RAM devices.
|
||||
AsyncFunction("destroy") { view: MpvPlayerView ->
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
|
||||
@@ -26,7 +26,11 @@ data class VideoLoadConfig(
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: Int? = null,
|
||||
val initialAudioId: Int? = null,
|
||||
val voDriver: String? = null
|
||||
val voDriver: String? = null,
|
||||
val cacheEnabled: String? = null,
|
||||
val cacheSeconds: Int? = null,
|
||||
val demuxerMaxBytes: Int? = null,
|
||||
val demuxerMaxBackBytes: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var activeSurface: Surface? = null
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
|
||||
// PiP state tracking
|
||||
@@ -131,6 +136,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
rendererStarted = true
|
||||
|
||||
pendingSurface?.let { surface ->
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
pendingSurface = null
|
||||
}
|
||||
@@ -149,6 +155,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
surfaceReady = true
|
||||
|
||||
if (rendererStarted) {
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
} else {
|
||||
pendingSurface = surface
|
||||
@@ -207,7 +214,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
startPosition = config.startPosition,
|
||||
externalSubtitles = config.externalSubtitles,
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId
|
||||
initialAudioId = config.initialAudioId,
|
||||
cacheEnabled = config.cacheEnabled,
|
||||
cacheSeconds = config.cacheSeconds,
|
||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||
)
|
||||
|
||||
if (config.autoplay) {
|
||||
@@ -236,6 +247,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback and release decoder resources.
|
||||
*
|
||||
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
||||
* on a background thread (flushing the demuxer and releasing the
|
||||
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
||||
*
|
||||
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
||||
* nativeDestroy has an internal use-after-free on the JNI global ref
|
||||
* path, so the native mpv handle is intentionally left for the JVM GC
|
||||
* / native finalizer rather than torn down synchronously. See
|
||||
* [MPVLib] class doc for the full rationale.
|
||||
*
|
||||
* Call this BEFORE navigating away from the player screen so the
|
||||
* decoder is reclaimed before the next screen (or the next episode's
|
||||
* player) mounts. Otherwise Expo Router renders the new screen first
|
||||
* and you briefly have two mpv instances + two 4K decoders alive —
|
||||
* instant OOM on a 2 GB device.
|
||||
*/
|
||||
fun destroy() {
|
||||
renderer?.stop()
|
||||
}
|
||||
|
||||
fun seekTo(position: Double) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
@@ -479,13 +513,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/**
|
||||
* Proactively tear down the player. Called from onDetachedFromWindow so
|
||||
* the app releases mpv + decoder buffers when the View detaches from the
|
||||
* window. The JS-facing destroy() is intentionally thinner (just
|
||||
* renderer.stop()) — see this thread for why the full teardown was kept
|
||||
* off the JS path.
|
||||
*/
|
||||
fun cleanup() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
surfaceTexture = null
|
||||
renderer?.delegate = null
|
||||
|
||||
// Release the Surface that wraps the SurfaceTexture. These Surface
|
||||
// objects are created in onSurfaceTextureAvailable and were never
|
||||
// released; each playback session previously leaked one. The
|
||||
// SurfaceTexture itself is owned by TextureView and released by it
|
||||
// via onSurfaceTextureDestroyed, so we leave it alone.
|
||||
pendingSurface?.release()
|
||||
pendingSurface = null
|
||||
activeSurface?.release()
|
||||
activeSurface = null
|
||||
surfaceReady = false
|
||||
currentUrl = null
|
||||
rendererStarted = false
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
|
||||
@@ -1020,12 +1020,44 @@ final class MPVLayerRenderer {
|
||||
info["cacheSeconds"] = cacheSeconds
|
||||
}
|
||||
|
||||
// Configured cache limits — read back from mpv to confirm user
|
||||
// settings actually took effect. mpv stores byte sizes as int64
|
||||
// (bytes); convert to MiB for display.
|
||||
var demuxerMaxBytes: Int64 = 0
|
||||
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
||||
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
||||
}
|
||||
var demuxerMaxBackBytes: Int64 = 0
|
||||
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
||||
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
||||
}
|
||||
var cacheSecsLimit: Double = 0
|
||||
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
||||
info["cacheSecsLimit"] = cacheSecsLimit
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
var droppedFrames: Int64 = 0
|
||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||
info["droppedFrames"] = Int(droppedFrames)
|
||||
}
|
||||
|
||||
// Active video output driver
|
||||
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
||||
info["voDriver"] = voDriver
|
||||
}
|
||||
|
||||
// Active hardware decoder
|
||||
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
||||
info["hwdec"] = hwdec
|
||||
}
|
||||
|
||||
// Estimated video output fps (post-filter)
|
||||
var estimatedVfFps: Double = 0
|
||||
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
||||
info["estimatedVfFps"] = estimatedVfFps
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,13 @@ public class MpvPlayerModule: Module {
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
|
||||
// Synchronously destroy mpv instance + decoder before navigating
|
||||
// away from the player screen (cross-platform; matches Android).
|
||||
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
|
||||
@@ -289,6 +289,17 @@ class MpvPlayerView: ExpoView {
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times — the
|
||||
* underlying renderer.stop() guards against re-entry.
|
||||
*
|
||||
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
||||
*/
|
||||
func destroy() {
|
||||
renderer?.stop()
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
cachedPosition = position
|
||||
|
||||
@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
/**
|
||||
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
||||
* Call before navigating away from the player screen so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times.
|
||||
*/
|
||||
destroy: () => Promise<void>;
|
||||
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
||||
// stop: () => Promise<void>;
|
||||
seekTo: (position: number) => Promise<void>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
@@ -154,9 +162,17 @@ export type TechnicalInfo = {
|
||||
videoBitrate?: number;
|
||||
audioBitrate?: number;
|
||||
cacheSeconds?: number;
|
||||
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBytes?: number;
|
||||
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBackBytes?: number;
|
||||
/** Configured cache-secs floor, read back from mpv */
|
||||
cacheSecsLimit?: number;
|
||||
droppedFrames?: number;
|
||||
/** Active video output driver (read from MPV at runtime) */
|
||||
voDriver?: string;
|
||||
/** Active hardware decoder (read from MPV at runtime) */
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".TvRecommendationsReceiver"
|
||||
android:exported="true">
|
||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal object TvRecommendationsPublisher {
|
||||
private const val TAG = "TvRecommendations"
|
||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||
private const val KEY_PAYLOAD = "payload"
|
||||
private const val KEY_CHANNEL_ID = "channelId"
|
||||
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||
private const val KEY_PROGRAM_IDS = "programIds"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||
|
||||
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
|
||||
|
||||
fun clear(context: Context): Boolean {
|
||||
val prefs = preferences(context)
|
||||
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (programIds != null) {
|
||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (allProgramIds != null) {
|
||||
var deletedPrograms = 0
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val programId = programIds.optLong(key, -1L)
|
||||
if (programId > 0L) {
|
||||
contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
deletedPrograms += 1
|
||||
val channelKeys = allProgramIds.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelIdStr = channelKeys.next()
|
||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||
if (programIdsJson.isBlank()) continue
|
||||
|
||||
try {
|
||||
val programIds = JSONObject(programIdsJson)
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val providerId = keys.next()
|
||||
val programId = programIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
deletedPrograms += 1
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
||||
}
|
||||
|
||||
// Notify the channel
|
||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
||||
if (channelId > 0L) {
|
||||
try {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove per-channel pref
|
||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
||||
}
|
||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||
}
|
||||
|
||||
if (channelId > 0L) {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
Log.d(TAG, "clear(): notified channel $channelId")
|
||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (legacyProgramIds != null) {
|
||||
val keys = legacyProgramIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val programId = legacyProgramIds.optLong(key, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
}
|
||||
}
|
||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single preview program from the TvProvider.
|
||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
||||
*/
|
||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
||||
try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
||||
|
||||
// Also remove from stored programIds prefs
|
||||
removeProgramFromPrefs(context, programId)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
||||
try {
|
||||
contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
||||
val prefs = preferences(context)
|
||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||
try {
|
||||
val channelMap = JSONObject(programIdsJson)
|
||||
val channelKeys = channelMap.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelId = channelKeys.next()
|
||||
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||
val providerKeys = inner.keys()
|
||||
while (providerKeys.hasNext()) {
|
||||
val providerId = providerKeys.next()
|
||||
if (inner.optLong(providerId, -1L) == programId) {
|
||||
inner.remove(providerId)
|
||||
if (inner.length() == 0) {
|
||||
channelMap.remove(channelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
||||
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
||||
if (sections.length() == 0) {
|
||||
Log.w(TAG, "synchronize(): no sections in payload")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||
val prefs = preferences(context)
|
||||
val allNextProgramIds = JSONObject()
|
||||
var totalActive = 0
|
||||
var totalDeleted = 0
|
||||
|
||||
val previousProgramIds = preferences(context)
|
||||
.getString(KEY_PROGRAM_IDS, null)
|
||||
?.let(::JSONObject)
|
||||
?: JSONObject()
|
||||
val nextProgramIds = JSONObject()
|
||||
val activeProviderIds = mutableSetOf<String>()
|
||||
for (sectionIndex in 0 until sections.length()) {
|
||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||
val items = section.optJSONArray("items") ?: JSONArray()
|
||||
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.optJSONObject(index) ?: continue
|
||||
val providerId = item.optString("id")
|
||||
if (providerId.isBlank()) continue
|
||||
|
||||
val programId = upsertPreviewProgram(
|
||||
context = context,
|
||||
channelId = channelId,
|
||||
item = item,
|
||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||
weight = index
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
if (programId > 0L) {
|
||||
activeProviderIds += providerId
|
||||
nextProgramIds.put(providerId, programId)
|
||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPrograms = 0
|
||||
val previousKeys = previousProgramIds.keys()
|
||||
while (previousKeys.hasNext()) {
|
||||
val providerId = previousKeys.next()
|
||||
if (activeProviderIds.contains(providerId)) continue
|
||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||
if (!isChannelBrowsable(context, channelId)) {
|
||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
}
|
||||
|
||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
val prefKey = "programIds_$channelId"
|
||||
val previousProgramIds = prefs.getString(prefKey, null)
|
||||
?.let(::JSONObject)
|
||||
?: JSONObject()
|
||||
val nextProgramIds = JSONObject()
|
||||
val activeProviderIds = mutableSetOf<String>()
|
||||
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.optJSONObject(index) ?: continue
|
||||
val providerId = item.optString("id")
|
||||
if (providerId.isBlank()) continue
|
||||
|
||||
val programId = upsertPreviewProgram(
|
||||
context = context,
|
||||
channelId = channelId,
|
||||
item = item,
|
||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||
weight = index
|
||||
)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
|
||||
if (programId > 0L) {
|
||||
activeProviderIds += providerId
|
||||
nextProgramIds.put(providerId, programId)
|
||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPrograms = 0
|
||||
val previousKeys = previousProgramIds.keys()
|
||||
while (previousKeys.hasNext()) {
|
||||
val providerId = previousKeys.next()
|
||||
if (activeProviderIds.contains(providerId)) continue
|
||||
|
||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(context, programId)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
}
|
||||
}
|
||||
|
||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
||||
totalActive += activeProviderIds.size
|
||||
totalDeleted += deletedPrograms
|
||||
|
||||
logProviderState(context, channelId)
|
||||
}
|
||||
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putLong(KEY_CHANNEL_ID, channelId)
|
||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||
.apply()
|
||||
|
||||
logProviderState(context, channelId)
|
||||
// Store all channel program IDs for clear() to use
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to check if a channel is browsable.
|
||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
||||
*/
|
||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
||||
true // Assume browsable if we can't check, to avoid blocking updates
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to verify a channel actually exists.
|
||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
||||
* we must first check whether the channel was deleted by the system
|
||||
* or if the update simply failed for another reason.
|
||||
*/
|
||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||
val prefs = preferences(context)
|
||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||
val channelKey = getChannelKey(displayName)
|
||||
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (existingChannelId > 0L) {
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||
val exists = channelExistsInProvider(context, existingChannelId)
|
||||
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
if (exists) {
|
||||
// Channel exists — update it in place, never recreate
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
}
|
||||
|
||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
||||
return existingChannelId
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
||||
return existingChannelId
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||
// Channel truly doesn't exist in provider — recreate
|
||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||
prefs.edit().remove(channelKey).apply()
|
||||
}
|
||||
|
||||
// Create a new channel
|
||||
val channel = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
val channelUri = contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
) ?: return -1L
|
||||
val channelUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val channelId = ContentUris.parseId(channelUri)
|
||||
prefs.edit().putLong(channelKey, channelId).apply()
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
storeChannelLogo(context, channelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun getChannelKey(displayName: String): String {
|
||||
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||
}
|
||||
|
||||
private fun upsertPreviewProgram(
|
||||
context: Context,
|
||||
channelId: Long,
|
||||
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
|
||||
builder.setDescription(it)
|
||||
}
|
||||
|
||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||
val imageUri = Uri.parse(it)
|
||||
val uniqueImageUrl = appendCacheBuster(it)
|
||||
val imageUri = Uri.parse(uniqueImageUrl)
|
||||
builder.setPosterArtUri(imageUri)
|
||||
builder.setThumbnailUri(imageUri)
|
||||
}
|
||||
|
||||
|
||||
val contentValues = builder.build().toContentValues()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (previousProgramId > 0L) {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||
return previousProgramId
|
||||
if (updatedRows > 0) {
|
||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||
return previousProgramId
|
||||
}
|
||||
|
||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
||||
}
|
||||
|
||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||
}
|
||||
|
||||
val insertedUri = contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
) ?: return -1L
|
||||
val insertedUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val programId = ContentUris.parseId(insertedUri)
|
||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||
return programId
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a stable cache key derived from the image URL.
|
||||
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||
* that changes whenever the image content changes, so a deterministic
|
||||
* hash of the URL is sufficient — the param only changes when the URL
|
||||
* (and therefore the image) actually changes, avoiding unnecessary
|
||||
* re-downloads on every sync.
|
||||
*/
|
||||
private fun appendCacheBuster(imageUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||
return "$imageUrl${separator}_v=$hash"
|
||||
}
|
||||
|
||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(deepLink)
|
||||
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
|
||||
|
||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||
val bitmap = applicationIconBitmap(context) ?: return
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
try {
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
||||
}
|
||||
|
||||
private fun preferences(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private fun logProviderState(context: Context, channelId: Long) {
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
|
||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||
}
|
||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
}
|
||||
} catch (error: SecurityException) {
|
||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ContentUris
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
|
||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
||||
return
|
||||
when (intent.action) {
|
||||
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||
if (programId > 0L) {
|
||||
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import type { View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||
|
||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
||||
// On Android the component is never rendered, but we must avoid calling
|
||||
// `requireNativeView` at module-scope because it would crash on import.
|
||||
const NativeView: React.ComponentType<
|
||||
TvSearchViewProps & React.RefAttributes<View>
|
||||
> = requireNativeView("TvSearchModule");
|
||||
> =
|
||||
Platform.OS === "ios"
|
||||
? requireNativeView("TvSearchModule")
|
||||
: ((() => null) as any);
|
||||
|
||||
/**
|
||||
* Forwards its ref to the underlying native view so it can be used as a
|
||||
|
||||
@@ -15,7 +15,6 @@ const WifiSsidModule =
|
||||
*/
|
||||
export async function getSSID(): Promise<string | null> {
|
||||
if (!WifiSsidModule) {
|
||||
console.log("[WifiSsid] Module not available on this platform");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
50
package.json
50
package.json
@@ -22,56 +22,58 @@
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "1.2.0",
|
||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||
"@expo/metro-runtime": "~56.0.13",
|
||||
"@expo/metro-runtime": "~56.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "~56.0.14",
|
||||
"@expo/ui": "~56.0.17",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "5.2.14",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.28",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "2.0.3",
|
||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query-persist-client": "^5.100.14",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~56.0.6",
|
||||
"expo": "~56.0.11",
|
||||
"expo-application": "~56.0.3",
|
||||
"expo-asset": "~56.0.15",
|
||||
"expo-audio": "~56.0.11",
|
||||
"expo-background-task": "~56.0.15",
|
||||
"expo-asset": "~56.0.17",
|
||||
"expo-audio": "~56.0.12",
|
||||
"expo-background-task": "~56.0.18",
|
||||
"expo-blur": "~56.0.3",
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-build-properties": "~56.0.18",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-device": "~56.0.4",
|
||||
"expo-font": "~56.0.5",
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.9",
|
||||
"expo-image": "~56.0.11",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.12",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-localization": "~56.0.6",
|
||||
"expo-location": "~56.0.14",
|
||||
"expo-notifications": "~56.0.14",
|
||||
"expo-router": "~56.2.7",
|
||||
"expo-location": "~56.0.17",
|
||||
"expo-notifications": "~56.0.17",
|
||||
"expo-router": "~56.2.10",
|
||||
"expo-screen-orientation": "~56.0.5",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sharing": "~56.0.14",
|
||||
"expo-sharing": "~56.0.17",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.15",
|
||||
"expo-task-manager": "~56.0.18",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"i18next": "^26.3.0",
|
||||
"jotai": "2.20.0",
|
||||
@@ -126,14 +128,15 @@
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "^18.19.130",
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.7",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"lint-staged": "17.0.7",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
@@ -141,6 +144,7 @@
|
||||
"exclude": [
|
||||
"react-native-google-cast",
|
||||
"react-native-udp",
|
||||
"react-native-track-player",
|
||||
"@jellyfin/sdk"
|
||||
],
|
||||
"listUnknownPackages": false
|
||||
|
||||
@@ -27,6 +27,9 @@ module.exports = function withCustomPlugin(config) {
|
||||
// https://github.com/expo/expo/issues/32558
|
||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||
|
||||
// NDK version required by libmpv 1.0.0
|
||||
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
||||
|
||||
// Increase memory
|
||||
config = setGradlePropertiesValue(
|
||||
config,
|
||||
|
||||
@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
|
||||
} else {
|
||||
// Transcoding - estimate from bitrate
|
||||
const process = processes.find((p) => p.id === processId);
|
||||
console.log(
|
||||
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
|
||||
process ? "yes" : "no",
|
||||
);
|
||||
if (process) {
|
||||
console.log(`[DPL] Process bitrate:`, {
|
||||
key: process.maxBitrate.key,
|
||||
value: process.maxBitrate.value,
|
||||
runTimeTicks: process.item.RunTimeTicks,
|
||||
});
|
||||
if (process.maxBitrate.value && process.item.RunTimeTicks) {
|
||||
const { estimateDownloadSize } = require("@/utils/download");
|
||||
estimatedTotalBytes = estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
);
|
||||
console.log(
|
||||
`[DPL] Calculated estimatedTotalBytes:`,
|
||||
estimatedTotalBytes,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
|
||||
);
|
||||
}
|
||||
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
|
||||
const { estimateDownloadSize } = require("@/utils/download");
|
||||
estimatedTotalBytes = estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||
import { APP_VERSION } from "@/utils/version";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -53,7 +54,7 @@ const initialApi = (() => {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
const jellyfinInstance = new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
return new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
createContext,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
@@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
|
||||
["episodes"],
|
||||
] as const;
|
||||
|
||||
// Query keys that depend on per-user playback state (resume position, played
|
||||
// status, favorites) and should be refreshed when the server reports a
|
||||
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
|
||||
// episode does not pointlessly refetch "recently added" or suggestions.
|
||||
const USER_DATA_CHANGE_QUERY_KEYS = [
|
||||
["home", "continueAndNextUp"],
|
||||
["home", "resumeItems"],
|
||||
["home", "nextUp-all"],
|
||||
["home", "heroItems"],
|
||||
["resumeItems"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
] as const;
|
||||
|
||||
interface WebSocketMessage {
|
||||
MessageType: string;
|
||||
Data: any;
|
||||
@@ -38,10 +52,30 @@ interface WebSocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler invoked for every message of a given `MessageType`. Receives the
|
||||
* message `Data` payload and the full message.
|
||||
*/
|
||||
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
|
||||
|
||||
interface WebSocketContextType {
|
||||
ws: WebSocket | null;
|
||||
isConnected: boolean;
|
||||
/**
|
||||
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
|
||||
* message, so bursts arriving in the same tick are coalesced and lost. Kept
|
||||
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
|
||||
*/
|
||||
lastMessage: WebSocketMessage | null;
|
||||
/**
|
||||
* Subscribe to a given message type. The handler is called synchronously for
|
||||
* every matching message (no coalescing, unlike `lastMessage`). Returns an
|
||||
* unsubscribe function to call on cleanup.
|
||||
*/
|
||||
subscribe: (
|
||||
messageType: string,
|
||||
handler: WebSocketMessageHandler,
|
||||
) => () => void;
|
||||
sendMessage: (message: any) => void;
|
||||
clearLastMessage: () => void;
|
||||
}
|
||||
@@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
return getOrSetDeviceId();
|
||||
@@ -63,8 +96,76 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const userDataChangeDebounceRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null);
|
||||
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
|
||||
// another path (foreground, network reconnect, effect re-run) can cancel a
|
||||
// pending one — an untracked timer would later open a second socket.
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
|
||||
// subscribing/dispatching never triggers a re-render.
|
||||
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(messageType: string, handler: WebSocketMessageHandler) => {
|
||||
const listeners = listenersRef.current;
|
||||
let handlers = listeners.get(messageType);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
listeners.set(messageType, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers?.delete(handler);
|
||||
// Only drop the map entry if it still points at THIS set. After an
|
||||
// unsubscribe + re-subscribe for the same type, a stale second call to
|
||||
// this cleanup would otherwise delete the new subscribers' set and
|
||||
// silently stop delivering their messages.
|
||||
if (
|
||||
handlers &&
|
||||
handlers.size === 0 &&
|
||||
listeners.get(messageType) === handlers
|
||||
) {
|
||||
listeners.delete(messageType);
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dispatchMessage = useCallback((message: WebSocketMessage) => {
|
||||
const handlers = listenersRef.current.get(message.MessageType);
|
||||
if (!handlers || handlers.size === 0) return;
|
||||
// Copy to tolerate handlers that unsubscribe during dispatch.
|
||||
for (const handler of [...handlers]) {
|
||||
// Isolate each handler so one throwing subscriber can't abort the rest
|
||||
// (and isn't misreported as a parse failure by the outer onmessage catch).
|
||||
try {
|
||||
handler(message.Data, message);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error handling WebSocket message type "${message.MessageType}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
// Cancel any reconnect queued by a previous onerror before opening a new
|
||||
// socket, so we never end up with two live sockets — each would double the
|
||||
// message fan-out and double-invalidate queries.
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
return;
|
||||
}
|
||||
@@ -85,6 +186,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
newWebSocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||
@@ -96,9 +201,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
// Don't log errors - this is expected when offline or server unreachable
|
||||
setIsConnected(false);
|
||||
|
||||
// Replace any still-pending reconnect so only one is ever queued; the
|
||||
// previously untracked handle could leak and open a second socket.
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
setTimeout(() => {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = null;
|
||||
connectWebSocket();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
@@ -113,7 +224,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
newWebSocket.onmessage = (e) => {
|
||||
try {
|
||||
const message = JSON.parse(e.data);
|
||||
setLastMessage(message); // Store the last message in context
|
||||
// Legacy single-slot state, still consumed by useWebsockets.
|
||||
setLastMessage(message);
|
||||
// Pub/sub: deliver to every subscriber without coalescing.
|
||||
dispatchMessage(message);
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
@@ -124,9 +238,13 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
if (keepAliveInterval) {
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
newWebSocket.close();
|
||||
};
|
||||
}, [api, deviceId, isNetworkConnected]);
|
||||
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
|
||||
|
||||
const handleLibraryChanged = useCallback(
|
||||
(data: any) => {
|
||||
@@ -157,47 +275,80 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
if (lastMessage.MessageType === "Play") {
|
||||
handlePlayCommand(lastMessage.Data);
|
||||
} else if (lastMessage.MessageType === "LibraryChanged") {
|
||||
handleLibraryChanged(lastMessage.Data);
|
||||
}
|
||||
}, [lastMessage, router, handleLibraryChanged]);
|
||||
const handleUserDataChanged = useCallback(
|
||||
(data: any) => {
|
||||
// Jellyfin sends UserDataChanged when playback position, played status
|
||||
// or favorites change (e.g. finishing an episode). Only the
|
||||
// progression-based home sections care about it.
|
||||
if (!((data?.UserDataList?.length ?? 0) > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finishing an item can emit several UserDataChanged messages, so
|
||||
// debounce to invalidate the affected sections only once.
|
||||
if (userDataChangeDebounceRef.current) {
|
||||
clearTimeout(userDataChangeDebounceRef.current);
|
||||
}
|
||||
userDataChangeDebounceRef.current = setTimeout(() => {
|
||||
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
|
||||
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Refresh library-dependent queries when the server reports a change.
|
||||
useEffect(
|
||||
() => subscribe("LibraryChanged", handleLibraryChanged),
|
||||
[subscribe, handleLibraryChanged],
|
||||
);
|
||||
|
||||
// Refresh "Continue Watching" / "Next Up" when playback state changes.
|
||||
useEffect(
|
||||
() => subscribe("UserDataChanged", handleUserDataChanged),
|
||||
[subscribe, handleUserDataChanged],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (libraryChangeDebounceRef.current) {
|
||||
clearTimeout(libraryChangeDebounceRef.current);
|
||||
}
|
||||
if (userDataChangeDebounceRef.current) {
|
||||
clearTimeout(userDataChangeDebounceRef.current);
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePlayCommand = useCallback(
|
||||
(data: any) => {
|
||||
if (!data?.ItemIds?.length) {
|
||||
return;
|
||||
}
|
||||
const handlePlayCommand = useCallback((data: any) => {
|
||||
if (!data?.ItemIds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = data.ItemIds[0];
|
||||
const itemId = data.ItemIds[0];
|
||||
|
||||
router.push({
|
||||
pathname: "/(auth)/player/direct-player",
|
||||
params: {
|
||||
itemId: itemId,
|
||||
playCommand: data.PlayCommand || "PlayNow",
|
||||
audioIndex: data.AudioStreamIndex?.toString(),
|
||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||
mediaSourceId: data.MediaSourceId || "",
|
||||
bitrateValue: "",
|
||||
offline: "false",
|
||||
},
|
||||
});
|
||||
},
|
||||
[router],
|
||||
router.push({
|
||||
pathname: "/(auth)/player/direct-player",
|
||||
params: {
|
||||
itemId: itemId,
|
||||
playCommand: data.PlayCommand || "PlayNow",
|
||||
audioIndex: data.AudioStreamIndex?.toString(),
|
||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||
mediaSourceId: data.MediaSourceId || "",
|
||||
bitrateValue: "",
|
||||
offline: "false",
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Server-initiated "Play me this item" remote command.
|
||||
useEffect(
|
||||
() => subscribe("Play", handlePlayCommand),
|
||||
[subscribe, handlePlayCommand],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -267,7 +418,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, []);
|
||||
return (
|
||||
<WebSocketContext.Provider
|
||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||
value={{
|
||||
ws,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
subscribe,
|
||||
sendMessage,
|
||||
clearLastMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
|
||||
273
scripts/check-i18n-keys.mjs
Normal file
273
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* i18n key checker for Streamyfin.
|
||||
*
|
||||
* Detects:
|
||||
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
|
||||
* that does not exist in the source locale (translations/en.json). These are bugs —
|
||||
* the app renders the raw key. Always fails CI.
|
||||
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
|
||||
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
|
||||
* These are dead weight that also clutter every locale on Crowdin.
|
||||
*
|
||||
* Dynamic usage is handled conservatively:
|
||||
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
|
||||
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
|
||||
* whitelist keys (in Streamyfin these are user-defined section
|
||||
* titles, not translation keys).
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
return v === undefined ? true : v;
|
||||
};
|
||||
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
|
||||
const JSON_OUT = !!flag("json", false);
|
||||
const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
|
||||
srcDirs: ["."],
|
||||
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
excludeDirs: [
|
||||
"node_modules",
|
||||
"ios",
|
||||
"android",
|
||||
".expo",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"translations",
|
||||
"scripts",
|
||||
],
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
if (config.excludeDirs.includes(name)) continue;
|
||||
walk(full, files);
|
||||
} else if (config.srcExtensions.includes(extname(name))) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
// ---- scan code ----
|
||||
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
|
||||
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
|
||||
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
|
||||
const files = config.srcDirs.flatMap((d) =>
|
||||
walk(join(ROOT, d === "." ? "" : d) || ROOT),
|
||||
);
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const clean = stripComments(text);
|
||||
codeBlob += `\n${clean}`;
|
||||
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
|
||||
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
|
||||
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
|
||||
for (const m of clean.matchAll(TPL_DYN_RE)) {
|
||||
const prefix = m[1];
|
||||
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
|
||||
else {
|
||||
const idx = clean.slice(0, m.index).split("\n").length;
|
||||
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prefixList = [...dynamicPrefixes];
|
||||
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere 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
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
|
||||
// ---- compute ----
|
||||
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
||||
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
|
||||
// affects the UNUSED calculation, never MISSING.
|
||||
const missing = [...usedStatic]
|
||||
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
||||
.sort();
|
||||
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
|
||||
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
|
||||
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
|
||||
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- report ----
|
||||
if (JSON_OUT) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sourceKeys: sourceKeys.length,
|
||||
missing,
|
||||
unused,
|
||||
dynamicPrefixes: prefixList,
|
||||
fullyDynamic,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
|
||||
);
|
||||
if (prefixList.length)
|
||||
console.log(
|
||||
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
|
||||
);
|
||||
if (fullyDynamic.length)
|
||||
console.log(
|
||||
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
|
||||
);
|
||||
|
||||
if (missing.length) {
|
||||
console.log(
|
||||
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
|
||||
);
|
||||
for (const k of missing) console.log(` - ${k}`);
|
||||
} else console.log("\n✅ No missing keys.");
|
||||
|
||||
if (UNUSED_MODE !== "off") {
|
||||
if (unused.length) {
|
||||
console.log(
|
||||
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
);
|
||||
} else console.log("\n✅ No unused keys.");
|
||||
}
|
||||
}
|
||||
|
||||
const fail =
|
||||
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
|
||||
process.exit(fail ? 1 : 0);
|
||||
46
scripts/i18n-keys.config.json
Normal file
46
scripts/i18n-keys.config.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"localesDir": "translations",
|
||||
"sourceLocale": "en",
|
||||
"srcDirs": [
|
||||
"app",
|
||||
"components",
|
||||
"hooks",
|
||||
"providers",
|
||||
"utils",
|
||||
"modules",
|
||||
"packages",
|
||||
"constants"
|
||||
],
|
||||
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
"excludeDirs": [
|
||||
"node_modules",
|
||||
"ios",
|
||||
"android",
|
||||
".expo",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"translations"
|
||||
],
|
||||
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
|
||||
"ignoreUnused": [
|
||||
"watchlists.add_to_watchlist",
|
||||
"watchlists.remove_from_watchlist",
|
||||
"watchlists.create_one_first",
|
||||
"watchlists.no_compatible_watchlists",
|
||||
"pin.confirm_pin",
|
||||
"pin.pins_dont_match",
|
||||
"player.search_subtitles",
|
||||
"player.subtitle_search",
|
||||
"player.subtitle_download_hint",
|
||||
"player.subtitle_tracks",
|
||||
"player.using_jellyfin_server",
|
||||
"player.swipe_down_settings",
|
||||
"player.stopPlayback",
|
||||
"player.stopPlayingTitle",
|
||||
"player.stopPlayingConfirm",
|
||||
"home.settings.other.show_large_home_carousel",
|
||||
"home.settings.logs.delete_all_logs",
|
||||
"home.suggested_episodes"
|
||||
]
|
||||
}
|
||||
@@ -302,7 +302,7 @@ function parseArgs(argv: string[]): BuildOptions {
|
||||
if (!configArg) {
|
||||
throw new Error("--configuration requires an argument");
|
||||
}
|
||||
options.configuration = (configArg as "Debug" | "Release") || "Debug";
|
||||
options.configuration = configArg as "Debug" | "Release";
|
||||
break;
|
||||
}
|
||||
case "--device":
|
||||
@@ -997,10 +997,6 @@ async function waitForSimulatorBoot(
|
||||
}
|
||||
} catch {
|
||||
// Simulator not found or not booted yet, continue polling
|
||||
if (pollIntervalMs > 1000) {
|
||||
// Only log if we've been waiting a while to avoid spam
|
||||
// console.warn("Simulator polling failed, retrying...");
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
|
||||
@@ -140,7 +140,16 @@ function runTypeCheck() {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
||||
// --pretty false: TS 6 enables pretty output even when piped, which breaks
|
||||
// the line-based error parser below
|
||||
const runnerArgs = [
|
||||
"-p",
|
||||
"tsconfig.json",
|
||||
"--noEmit",
|
||||
"--pretty",
|
||||
"false",
|
||||
...extraArgs,
|
||||
];
|
||||
let execArgs = null;
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
|
||||
122
scripts/update-issue-form.mjs
Normal file
122
scripts/update-issue-form.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Populates the "Streamyfin Version" dropdown in the issue report form with the
|
||||
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
|
||||
* release events + a weekly cron (and manually via workflow_dispatch).
|
||||
*
|
||||
* Source: published, non-draft, non-prerelease GitHub releases, newest first.
|
||||
* Non-version sentinels (e.g. "older", "TestFlight/Development build") are
|
||||
* preserved at the end of the list.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/update-issue-form.mjs # rewrite the form in place
|
||||
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
|
||||
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
|
||||
*
|
||||
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
appendFileSync,
|
||||
readFileSync as read,
|
||||
writeFileSync as write,
|
||||
} from "node:fs";
|
||||
|
||||
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
|
||||
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
|
||||
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
|
||||
const LIMIT =
|
||||
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
|
||||
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
||||
const DRY = process.argv.includes("--dry-run");
|
||||
|
||||
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
|
||||
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
|
||||
|
||||
// 1. Fetch the latest published releases (newest first) — drafts and prereleases
|
||||
// aren't a full release users run, so they don't belong in the dropdown.
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"release",
|
||||
"list",
|
||||
"--repo",
|
||||
REPO,
|
||||
"--exclude-drafts",
|
||||
"--exclude-pre-releases",
|
||||
"--limit",
|
||||
String(LIMIT),
|
||||
"--json",
|
||||
"tagName",
|
||||
"--jq",
|
||||
".[].tagName",
|
||||
],
|
||||
// Bounded timeout so a stuck gh process fails the job fast instead of
|
||||
// holding the workflow open until the job-level timeout.
|
||||
{ encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
const seen = new Set();
|
||||
const versions = [];
|
||||
for (const tag of raw.split("\n")) {
|
||||
if (!tag) continue;
|
||||
const ver = tag.trim().replace(/^v/, "");
|
||||
if (!isVersion(ver) || seen.has(ver)) continue;
|
||||
seen.add(ver);
|
||||
versions.push(ver);
|
||||
}
|
||||
|
||||
if (!versions.length) {
|
||||
console.error("No release versions found — leaving the form untouched.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. rewrite the dropdown options, preserving non-version sentinels
|
||||
// (e.g. "older", "TestFlight/Development build") at the end of the list.
|
||||
const lines = read(FORM, "utf8").split("\n");
|
||||
const idIdx = lines.findIndex((l) =>
|
||||
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
|
||||
);
|
||||
if (idIdx === -1)
|
||||
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
|
||||
const optIdx = lines.findIndex(
|
||||
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
|
||||
);
|
||||
if (optIdx === -1)
|
||||
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
|
||||
|
||||
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
|
||||
let end = optIdx + 1;
|
||||
const sentinels = [];
|
||||
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
|
||||
const val = lines[end].replace(/^\s*-\s+/, "");
|
||||
if (!isVersion(val)) sentinels.push(val);
|
||||
end++;
|
||||
}
|
||||
|
||||
const newOptions = [...versions, ...sentinels].map(
|
||||
(v) => `${itemIndent}- ${v}`,
|
||||
);
|
||||
const updated = [
|
||||
...lines.slice(0, optIdx + 1),
|
||||
...newOptions,
|
||||
...lines.slice(end),
|
||||
].join("\n");
|
||||
|
||||
console.log(
|
||||
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
|
||||
);
|
||||
if (DRY) {
|
||||
console.log("--dry-run: not writing.");
|
||||
} else {
|
||||
write(FORM, updated);
|
||||
console.log(`Updated ${FORM}.`);
|
||||
}
|
||||
|
||||
// Expose the resulting list for the workflow (PR description).
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`versions=${versions.join(", ")}\n`,
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ final class TopShelfProvider: TVTopShelfContentProvider {
|
||||
|
||||
let item = TVTopShelfSectionedItem(identifier: cacheItem.id)
|
||||
item.title = cacheItem.title
|
||||
item.imageShape = .poster
|
||||
item.imageShape = .hdtv
|
||||
item.displayAction = TVTopShelfAction(url: route)
|
||||
|
||||
if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) {
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "لا شيء",
|
||||
"OnlyForced": "فقط الإجبارية"
|
||||
},
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"bold_text": "خط عريض",
|
||||
"colors": {
|
||||
"Black": "أسود",
|
||||
"Gray": "رمادي",
|
||||
"Silver": "فضي",
|
||||
"White": "أبيض",
|
||||
"Maroon": "أحمر داكن",
|
||||
"Red": "أحمر",
|
||||
"Fuchsia": "وردي",
|
||||
"Yellow": "أصفر",
|
||||
"Olive": "أخضر زيتوني",
|
||||
"Green": "أخضر",
|
||||
"Teal": "أزرق مخضر",
|
||||
"Lime": "ليموني",
|
||||
"Purple": "بنفسجي",
|
||||
"Navy": "كحلي",
|
||||
"Blue": "أزرق",
|
||||
"Aqua": "أزرق بحري"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "لا شيء",
|
||||
"Thin": "نحيف",
|
||||
"Normal": "عادي",
|
||||
"Thick": "سميك"
|
||||
},
|
||||
"subtitle_color": "لون الترجمة",
|
||||
"subtitle_background_color": "لون الخلفية",
|
||||
"subtitle_font": "خط الترجمة",
|
||||
"ksplayer_title": "إعدادات KSPlayer",
|
||||
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
||||
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "إعدادات ترجمة VLC",
|
||||
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"bold": "خط عريض",
|
||||
"margin": "الهامش السفلي"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "مشغل الفيديو",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "أخرى",
|
||||
"video_orientation": "اتجاه الفيديو",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "غير معروف"
|
||||
},
|
||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||
},
|
||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||
"hide_libraries": "إخفاء المكتبات",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||
"disabled": "معطل"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات"
|
||||
},
|
||||
"music": {
|
||||
"title": "الموسيقى",
|
||||
"playback_title": "التشغيل",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "الإضافات",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "هذا الربط في مراحله الأولى. توقع حدوث تغييرات.",
|
||||
"server_url": "رابط الخادم",
|
||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
||||
"server_url_placeholder": "رابط Seerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||
"save_button": "حفظ",
|
||||
"toasts": {
|
||||
"saved": "تم الحفظ",
|
||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
"saved": "تم الحفظ"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "تفعيل Streamystats",
|
||||
"disable_streamystats": "تعطيل Streamystats",
|
||||
"enable_search": "استخدم للبحث",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
||||
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
||||
"save_button": "حفظ",
|
||||
"save": "حفظ",
|
||||
"features_title": "المميزات",
|
||||
"home_sections_title": "أقسام الرئيسية",
|
||||
"enable_movie_recommendations": "توصيات الأفلام",
|
||||
"enable_series_recommendations": "توصيات المسلسلات",
|
||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "النظام"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "خطأ في حذف الملفات",
|
||||
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
||||
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
||||
"error_deleting_files": "خطأ في حذف الملفات"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"tvseries": "مسلسلات",
|
||||
"series": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"other_media": "وسائط أخرى",
|
||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||
"delete_all_series_button": "حذف جميع المسلسلات",
|
||||
"delete_all_button": "حذف الكل",
|
||||
"delete_all_other_media_button": "حذف الوسائط الأخرى",
|
||||
"active_download": "التنزيل الجاري",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
||||
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||
"download_deleted": "تم حذف التنزيل",
|
||||
"download_cancelled": "تم إلغاء التنزيل",
|
||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
||||
"download_resumed": "تم استئناف التنزيل",
|
||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
||||
"download_completed": "اكتمل التنزيل",
|
||||
"download_failed": "فشل التنزيل",
|
||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
||||
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
||||
"files_deleted_by_type": "تم حذف {{count}} {{type}}",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
||||
"failed_to_clean_cache_directory": "فشل تنظيف مجلد ذاكرة التخزين المؤقت",
|
||||
"could_not_get_download_url_for_item": "تعذر الحصول على عنوان URL للتنزيل لـ{{itemName}}",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||
"file_deleted": "تم حذف {{item}}"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "لا شيء",
|
||||
"track": "أغنية",
|
||||
"cancel": "إلغاء",
|
||||
"stop": "Stop",
|
||||
"delete": "حذف",
|
||||
"ok": "حسناً",
|
||||
"remove": "إزالة",
|
||||
"next": "التالي",
|
||||
"back": "رجوع",
|
||||
"continue": "متابعة",
|
||||
"verifying": "جارٍ التحقق...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "بحث...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||
"next_episode": "الحلقة التالية",
|
||||
"refresh_tracks": "تحديث المسارات",
|
||||
"audio_tracks": "مسارات الصوت:",
|
||||
"playback_state": "حالة التشغيل:",
|
||||
"index": "الفِهْرِس:",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"go_back": "رجوع",
|
||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "التالي",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "قوائم التشغيل",
|
||||
"tracks": "الأغاني"
|
||||
},
|
||||
"filters": {
|
||||
"all": "الكل"
|
||||
},
|
||||
"recently_added": "أضيف مؤخرًا",
|
||||
"recently_played": "تم تشغيله مؤخرًا",
|
||||
"frequently_played": "الأكثر تشغيلاً",
|
||||
"explore": "اكتشف",
|
||||
"top_tracks": "أفضل الأغاني",
|
||||
"play": "تشغيل",
|
||||
"shuffle": "ترتيب عشوائي",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Cap",
|
||||
"OnlyForced": "Només els forçats"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Black",
|
||||
"Gray": "Gray",
|
||||
"Silver": "Silver",
|
||||
"White": "White",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Red",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Yellow",
|
||||
"Olive": "Olive",
|
||||
"Green": "Green",
|
||||
"Teal": "Teal",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Purple",
|
||||
"Navy": "Navy",
|
||||
"Blue": "Blue",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Cap",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Altres",
|
||||
"video_orientation": "Orientació del vídeo",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Desconeguda"
|
||||
},
|
||||
"safe_area_in_controls": "Àrea segura als controls",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Oculta biblioteques",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||
"disabled": "Desactivat"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descàrregues"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Connectors",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Aquesta integració es troba en una versió primerenca. Espereu que les coses canviïn.",
|
||||
"server_url": "URL del servidor",
|
||||
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
|
||||
"server_url_placeholder": "URL de Jellyseerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||
"save_button": "Desa",
|
||||
"toasts": {
|
||||
"saved": "Desat",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Desat"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error en suprimir fitxers",
|
||||
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
||||
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
|
||||
"error_deleting_files": "Error en suprimir fitxers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descàrregues",
|
||||
"tvseries": "Sèries",
|
||||
"series": "Sèries",
|
||||
"movies": "Pel·lícules",
|
||||
"queue": "Cua",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
|
||||
"no_items_in_queue": "No hi ha elements a la cua",
|
||||
"no_downloaded_items": "No hi ha elements descarregats",
|
||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||
"delete_all_tvseries_button": "Suprimeix totes les sèries",
|
||||
"delete_all_series_button": "Suprimeix totes les sèries",
|
||||
"delete_all_button": "Suprimeix-ho tot",
|
||||
"delete_all_other_media_button": "Delete other media",
|
||||
"active_download": "Descàrrega activa",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
||||
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
||||
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
||||
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
|
||||
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
|
||||
"deleted_all_series_successfully": "S'han suprimit totes les sèries correctament!",
|
||||
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Descàrrega cancel·lada",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Descàrrega completada",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Ves a les descàrregues",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||
"message_from_server": "Missatge del servidor: {{message}}",
|
||||
"next_episode": "Episodi següent",
|
||||
"refresh_tracks": "Actualitzar pistes",
|
||||
"audio_tracks": "Pistes d'àudio:",
|
||||
"playback_state": "Estat de reproducció:",
|
||||
"index": "Índex:",
|
||||
"continue_watching": "Continuar veient",
|
||||
"go_back": "Enrere",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Mostra més",
|
||||
"show_less": "Mostra menys",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Següent",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Nic",
|
||||
"OnlyForced": "Pouze vynucené"
|
||||
},
|
||||
"text_color": "Barva textu",
|
||||
"background_color": "Barva pozadí",
|
||||
"outline_color": "Barva obrysu",
|
||||
"outline_thickness": "Obrys tloušťky",
|
||||
"background_opacity": "Průhlednost pozadí",
|
||||
"outline_opacity": "Průhlednost obrysu",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Černý",
|
||||
"Gray": "Šedá",
|
||||
"Silver": "Stříbro",
|
||||
"White": "Bílý",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Červená",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Žlutá",
|
||||
"Olive": "Olivy",
|
||||
"Green": "Zelená",
|
||||
"Teal": "Modrozelený",
|
||||
"Lime": "Světle zelená",
|
||||
"Purple": "Fialová",
|
||||
"Navy": "Námořní loď",
|
||||
"Blue": "Modrá",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nic",
|
||||
"Thin": "Tenké",
|
||||
"Normal": "Normální",
|
||||
"Thick": "Tlustá"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Ostatní",
|
||||
"video_orientation": "Orientace videa",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Neznámý"
|
||||
},
|
||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
||||
"video_player": "Video přehrávač",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (experimentální + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||
"hide_libraries": "Skrýt knihovny",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
||||
"disabled": "Zakázáno"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Stahování"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Pluginy",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Tato integrace je v raných fázích. Očekávejte změnu situace.",
|
||||
"server_url": "URL serveru",
|
||||
"server_url_hint": "Příklad: http(s)://your-host.url\n(přidat port, pokud je vyžadováno)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
||||
"save_button": "Uložit",
|
||||
"toasts": {
|
||||
"saved": "Uloženo",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Uloženo"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Systém"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Chyba při mazání souborů",
|
||||
"background_downloads_enabled": "Stahování na pozadí povoleno",
|
||||
"background_downloads_disabled": "Stahování na pozadí zakázáno"
|
||||
"error_deleting_files": "Chyba při mazání souborů"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Stahování",
|
||||
"tvseries": "Televizní série",
|
||||
"series": "Televizní série",
|
||||
"movies": "Filmy",
|
||||
"queue": "Fronta",
|
||||
"other_media": "Ostatní média",
|
||||
"queue_hint": "Fronta a stahování budou ztraceny při restartu aplikace",
|
||||
"no_items_in_queue": "Žádné položky ve frontě",
|
||||
"no_downloaded_items": "Žádné stažené položky",
|
||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||
"delete_all_tvseries_button": "Odstranit všechny TV-série",
|
||||
"delete_all_series_button": "Odstranit všechny TV-série",
|
||||
"delete_all_button": "Smazat vše",
|
||||
"delete_all_other_media_button": "Odstranit ostatní média",
|
||||
"active_download": "Aktivní stahování",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
|
||||
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
|
||||
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
|
||||
"deleted_all_tvseries_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
||||
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
|
||||
"deleted_all_series_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
||||
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||
"download_deleted": "Stahování smazáno",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Stahování nelze odstranit",
|
||||
"download_paused": "Stahování pozastaveno",
|
||||
"could_not_pause_download": "Nelze pozastavit stahování",
|
||||
"download_resumed": "Stahování obnoveno",
|
||||
"could_not_resume_download": "Nelze pokračovat v stahování",
|
||||
"download_completed": "Stahování dokončeno",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Všechny soubory, složky a úlohy byly úspěšně odstraněny",
|
||||
"failed_to_clean_cache_directory": "Nepodařilo se vyčistit adresář mezipaměti",
|
||||
"could_not_get_download_url_for_item": "Nelze získat URL pro stažení {{itemName}}",
|
||||
"go_to_downloads": "Přejít na stahování",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Hledat...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||
"next_episode": "Další epizoda",
|
||||
"refresh_tracks": "Obnovit skladby",
|
||||
"audio_tracks": "Zvukové stopy:",
|
||||
"playback_state": "Stav přehrávání:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Pokračovat ve sledování",
|
||||
"go_back": "Zpět",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Zobrazit více",
|
||||
"show_less": "Zobrazit méně",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Další",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Ingen",
|
||||
"OnlyForced": "Kun tvungne undertekster"
|
||||
},
|
||||
"text_color": "Tekst Farve",
|
||||
"background_color": "Baggrunds Farve",
|
||||
"outline_color": "Omrids Farve",
|
||||
"outline_thickness": "Omrids Tykkelse",
|
||||
"background_opacity": "Baggrunds Gennemsigtighed",
|
||||
"outline_opacity": "Omrids Gennemsigtighed",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Sort",
|
||||
"Gray": "Grå",
|
||||
"Silver": "Sølv",
|
||||
"White": "Hvid",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Rød",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gul",
|
||||
"Olive": "Oliven",
|
||||
"Green": "Grøn",
|
||||
"Teal": "Grønblåt",
|
||||
"Lime": "Limegrøn",
|
||||
"Purple": "Lilla",
|
||||
"Navy": "Flåden",
|
||||
"Blue": "Blå",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ingen",
|
||||
"Thin": "Tynd",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Tyk"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Andet",
|
||||
"video_orientation": "Videoorientering",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Ukendt"
|
||||
},
|
||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
||||
"video_player": "Videospiller",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentel + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Skjul biblioteker",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Denne integration er i en tidlig fase. Forvent, at tingene ændres.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Eksempel: http(s)://din-host.url\n(tilføj port hvis nødvendigt)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Læs mere om Marlin.",
|
||||
"save_button": "Gem",
|
||||
"toasts": {
|
||||
"saved": "Gemt",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Gemt"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fejl ved sletning af filer",
|
||||
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
||||
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
||||
"error_deleting_files": "Fejl ved sletning af filer"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-serier",
|
||||
"series": "TV-serier",
|
||||
"movies": "Film",
|
||||
"queue": "Kø",
|
||||
"other_media": "Andre medier",
|
||||
"queue_hint": "Kø og downloads vil gå tabt ved genstart af appen",
|
||||
"no_items_in_queue": "Ingen elementer i køen",
|
||||
"no_downloaded_items": "Ingen downloadede elementer",
|
||||
"delete_all_movies_button": "Slet alle film",
|
||||
"delete_all_tvseries_button": "Slet alle TV-serier",
|
||||
"delete_all_series_button": "Slet alle TV-serier",
|
||||
"delete_all_button": "Slet alle",
|
||||
"delete_all_other_media_button": "Slet andre medier",
|
||||
"active_download": "Aktiv download",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
|
||||
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
|
||||
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
|
||||
"deleted_all_tvseries_successfully": "Alle TV-serier er slettet med succes!",
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
|
||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||
"download_deleted": "Download Slettet",
|
||||
"download_cancelled": "Download afbrudt",
|
||||
"could_not_delete_download": "Kunne Ikke Slette Download",
|
||||
"download_paused": "Download Pauset",
|
||||
"could_not_pause_download": "Kunne Ikke Pause Download",
|
||||
"download_resumed": "Download Genoprettet",
|
||||
"could_not_resume_download": "Kunne Ikke Genoptage Download",
|
||||
"download_completed": "Download fuldført",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobs blev slettet med succes",
|
||||
"failed_to_clean_cache_directory": "Kunne ikke rense cache-mappe",
|
||||
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
||||
"go_to_downloads": "Gå til downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Søg...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||
"message_from_server": "Besked fra server: {{message}}",
|
||||
"next_episode": "Næste episode",
|
||||
"refresh_tracks": "Opdater spor",
|
||||
"audio_tracks": "Lydspor:",
|
||||
"playback_state": "Afspilningstilstand:",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Fortsæt med at se",
|
||||
"go_back": "Gå Tilbage",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Vis mere",
|
||||
"show_less": "Vis mindre",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Næste",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"error_title": "Fehler",
|
||||
"login_title": "Anmelden",
|
||||
"login_to_title": "Anmelden bei",
|
||||
"select_user": "Select a user to log in",
|
||||
"add_user_to_login": "Add a user to log in",
|
||||
"select_user": "Benutzer zum Anmelden auswählen",
|
||||
"add_user_to_login": "Zum Anmelden einen Benutzer hinzufügen",
|
||||
"add_user": "Add User",
|
||||
"username_placeholder": "Benutzername",
|
||||
"password_placeholder": "Passwort",
|
||||
@@ -47,9 +47,9 @@
|
||||
"add_account": "Konto hinzufügen",
|
||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
||||
"remove_server": "Remove Server",
|
||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||
"remove_server_description": "Dies wird {{server}} und alle gespeicherten Konten aus Ihrer Liste entfernen.",
|
||||
"select_your_server": "Select Your Server",
|
||||
"add_server_to_get_started": "Add a server to get started",
|
||||
"add_server_to_get_started": "Füge einen Server hinzu, um loszulegen",
|
||||
"add_server": "Add Server",
|
||||
"change_server": "Change Server"
|
||||
},
|
||||
@@ -95,7 +95,7 @@
|
||||
"oops": "Ups!",
|
||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"continue": "Continue",
|
||||
"continue": "Weiter",
|
||||
"next_up": "Als nächstes",
|
||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
@@ -121,9 +121,9 @@
|
||||
"log_out_button": "Abmelden",
|
||||
"switch_user": {
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
"account": "Benutzerkonto",
|
||||
"switch_user": "Switch User on This Server",
|
||||
"current": "current"
|
||||
"current": "aktuell"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien"
|
||||
@@ -143,9 +143,9 @@
|
||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||
"theme_music": "Theme Music",
|
||||
"display_size": "Display Size",
|
||||
"display_size_small": "Small",
|
||||
"display_size_default": "Default",
|
||||
"display_size_large": "Large",
|
||||
"display_size_small": "Klein",
|
||||
"display_size_default": "Standard",
|
||||
"display_size_large": "Groß",
|
||||
"display_size_extra_large": "Extra Large"
|
||||
},
|
||||
"network": {
|
||||
@@ -203,8 +203,8 @@
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
"cache_auto": "Auto",
|
||||
"cache_yes": "Enabled",
|
||||
"cache_no": "Disabled",
|
||||
"cache_yes": "Aktiviert",
|
||||
"cache_no": "Deaktiviert",
|
||||
"buffer_duration": "Buffer Duration",
|
||||
"max_cache_size": "Max Cache Size",
|
||||
"max_backward_cache": "Max Backward Cache"
|
||||
@@ -212,7 +212,7 @@
|
||||
"vo_driver": {
|
||||
"title": "Video Output",
|
||||
"vo_mode": "VO Driver",
|
||||
"gpu_next": "gpu-next (Recommended)",
|
||||
"gpu_next": "gpu-next (empfohlen)",
|
||||
"gpu": "gpu"
|
||||
},
|
||||
"gesture_controls": {
|
||||
@@ -261,79 +261,23 @@
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungene"
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"bold_text": "Fettgedruckter Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Rotbraun",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Magenta",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
"Teal": "Türkis",
|
||||
"Lime": "Hellgrün",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Marineblau",
|
||||
"Blue": "Blau",
|
||||
"Aqua": "Himmelblau"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Keine",
|
||||
"Thin": "Dünn",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Untertitelfarbe",
|
||||
"subtitle_background_color": "Hintergrundfarbe",
|
||||
"subtitle_font": "Untertitel-Schriftart",
|
||||
"ksplayer_title": "KSPlayer Einstellungen",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_hint": "Geben Sie Ihren OpenSubtitles API-Schlüssel ein, um die Client-seitige Untertitelsuche als Fallback zu aktivieren, wenn Ihr Jellyfin-Server keinen Untertitelanbieter konfiguriert hat.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"opensubtitles_api_key_placeholder": "API-Schüssel eingeben ...",
|
||||
"opensubtitles_get_key": "Holen Sie sich Ihren kostenlosen API-Schlüssel unter opensubtitles.com/de/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
"left": "Links",
|
||||
"center": "Mittig",
|
||||
"right": "Rechts",
|
||||
"top": "Oben",
|
||||
"bottom": "Unten"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Untertitel-Einstellungen",
|
||||
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
|
||||
"text_color": "Schriftfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"bold": "Fettgedruckter Text",
|
||||
"margin": "Unterer Abstand"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videoplayer",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Sonstiges",
|
||||
"video_orientation": "Videoausrichtung",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Videoplayer",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"playback_title": "Wiedergabe",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||
},
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
"saved": "Gespeichert"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats aktivieren",
|
||||
"disable_streamystats": "Streamystats deaktivieren",
|
||||
"enable_search": "Zum Suchen verwenden",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||
"save_button": "Speichern",
|
||||
"save": "Gespeichert",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Startseitenbereiche",
|
||||
"enable_movie_recommendations": "Filmempfehlungen",
|
||||
"enable_series_recommendations": "Serienempfehlungen",
|
||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||
"watchlist_button": "Merklisten-Integration umschalten"
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||
"music_cache_title": "Musik-Cache",
|
||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
||||
"enable_music_cache": "Musik-Cache aktivieren",
|
||||
"clear_music_cache": "Musik-Cache leeren",
|
||||
"music_cache_size": "{{size}} gechached",
|
||||
"music_cache_cleared": "Musik-Cache geleert",
|
||||
@@ -466,10 +394,8 @@
|
||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
"clear_all_cache_confirm_desc": "Sind Sie sicher, dass Sie alle zwischengespeicherten Daten löschen möchten? Dadurch werden alle zwischengespeicherten Bilder, Musikdateien, Untertitel und Abfrage-Caches gelöscht. Ihre Einstellungen und Login-Sitzung werden beibehalten.",
|
||||
"clear_all_cache_error_desc": "Beim Löschen des Caches ist ein Fehler aufgetreten."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Einführung",
|
||||
@@ -490,23 +416,20 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"title": "Sicherheit",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
"disabled": "Deaktiviert",
|
||||
"1_minute": "1 Minute",
|
||||
"5_minutes": "5 Minuten",
|
||||
"15_minutes": "15 Minuten",
|
||||
"30_minutes": "30 Minuten",
|
||||
"1_hour": "1 Stunde",
|
||||
"4_hours": "4 Stunden",
|
||||
"24_hours": "24 Stunden"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Serien",
|
||||
"series": "Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||
"delete_all_movies_button": "Alle Filme löschen",
|
||||
"delete_all_tvseries_button": "Alle Serien löschen",
|
||||
"delete_all_series_button": "Alle Serien löschen",
|
||||
"delete_all_button": "Alles löschen",
|
||||
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
||||
"active_download": "Aktiver Download",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
||||
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
"download_cancelled": "Download abgebrochen",
|
||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
||||
"download_paused": "Download pausiert",
|
||||
"could_not_pause_download": "Download konnte nicht angehalten werden",
|
||||
"download_resumed": "Download fortgesetzt",
|
||||
"could_not_resume_download": "Download konnte nicht fortgesetzt werden",
|
||||
"download_completed": "Download abgeschlossen",
|
||||
"download_failed": "Download fehlgeschlagen",
|
||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} Lädt",
|
||||
"all_files_deleted": "Alle Downloads gelöscht",
|
||||
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||
"go_to_downloads": "Zu Downloads gehen",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "Keine",
|
||||
"track": "Spur",
|
||||
"cancel": "Abbrechen",
|
||||
"stop": "Stop",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"remove": "Entfernen",
|
||||
"next": "Weiter",
|
||||
"back": "Zurück",
|
||||
"continue": "Fortsetzen",
|
||||
"verifying": "Verifiziere...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"login": "Anmelden",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Suchen...",
|
||||
@@ -641,7 +554,7 @@
|
||||
"movies": "Filme",
|
||||
"series": "Serien",
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Playlists",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"items": "Elemente"
|
||||
},
|
||||
"options": {
|
||||
@@ -653,7 +566,7 @@
|
||||
"cover": "Cover",
|
||||
"show_titles": "Titel anzeigen",
|
||||
"show_stats": "Statistiken anzeigen",
|
||||
"options_title": "Options"
|
||||
"options_title": "Optionen"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Genres",
|
||||
@@ -662,10 +575,10 @@
|
||||
"filter_by": "Filtern nach",
|
||||
"sort_order": "Sortierreihenfolge",
|
||||
"tags": "Tags",
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
"all": "Alle",
|
||||
"reset": "Zurücksetzen",
|
||||
"asc": "Aufsteigend",
|
||||
"desc": "Absteigend"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
"next_episode": "Nächste Episode",
|
||||
"refresh_tracks": "Spuren aktualisieren",
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsetzen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||
@@ -702,34 +611,35 @@
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nein",
|
||||
"downloaded_file_cancel": "Abbrechen",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"swipe_down_settings": "Für Einstellungen nach unten wischen",
|
||||
"ends_at": "Endet um {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_tracks": "Titel",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"download": "Herunterladen",
|
||||
"subtitle_download_hint": "Heruntergeladene Untertitel werden in Ihrer Bibliothek gespeichert",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"language": "Sprache",
|
||||
"results": "Ergebnisse",
|
||||
"searching": "Suche ...",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||
"add_opensubtitles_key_hint": "OpenSubtitles API-Schlüssel in den Einstellungen für Client-seitigen Fallback hinzufügen",
|
||||
"settings": "Einstellungen",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"stopPlayingTitle": "Wiedergabe von \"{{title}}\" beenden?",
|
||||
"stopPlayingConfirm": "Bist du sicher, dass du die Wiedergabe beenden möchtest?",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
"title": "Kapitel",
|
||||
"chapter_number": "Kapitel {{number}}",
|
||||
"open": "Kapitel öffnen",
|
||||
"close": "Kapitel schließen"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
@@ -754,20 +664,19 @@
|
||||
"quality": "Qualität",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
"label": "Untertitel",
|
||||
"none": "Keine",
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"left": "übrig",
|
||||
"director": "Regisseur*in",
|
||||
"cast": "Besetzung",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Erschien in",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"movies": "Filme",
|
||||
"shows": "Serien",
|
||||
"could_not_load_item": "Konnte Element nicht laden",
|
||||
"none": "Keine",
|
||||
"download": {
|
||||
@@ -782,9 +691,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"resume_playback_description": "Möchtest du dort fortfahren, wo du aufgehört hast oder von Anfang anfangen?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Weiter ab {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Nächste",
|
||||
@@ -796,16 +706,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Für Kinder",
|
||||
"news": "Nachrichten",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"page_of": "Seite {{current}} von {{total}}",
|
||||
"no_programs": "Keine Programme verfügbar",
|
||||
"no_channels": "Keine Kanäle verfügbar",
|
||||
"tabs": {
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
"programs": "Programme",
|
||||
"guide": "Führer",
|
||||
"channels": "Kanäle",
|
||||
"recordings": "Aufzeichnungen",
|
||||
"schedule": "Planung",
|
||||
"series": "Serien"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -851,12 +761,12 @@
|
||||
"decline": "Ablehnen",
|
||||
"requested_by": "Angefragt von {{user}}",
|
||||
"unknown_user": "Unbekannter Nutzer",
|
||||
"select": "Select",
|
||||
"select": "Auswählen",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"n_selected": "{{count}} ausgewählt",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||
@@ -877,7 +787,7 @@
|
||||
"library": "Bibliothek",
|
||||
"custom_links": "Links",
|
||||
"favorites": "Favoriten",
|
||||
"settings": "Settings"
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
},
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recently_played": "Vor kurzem gehört",
|
||||
"frequently_played": "Oft gehört",
|
||||
"explore": "Entdecken",
|
||||
"top_tracks": "Top-Titel",
|
||||
"play": "Abspielen",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1004,34 +910,33 @@
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"title": "Mit TV koppeln",
|
||||
"align_qr": "Den QR-Code innerhalb des Rahmens ausrichten",
|
||||
"enter_code_manually": "Code manuell eingeben",
|
||||
"pairing_enter_credentials": "Anmeldedaten für TV eingeben",
|
||||
"pairing_code_label": "Kopplungscode",
|
||||
"server": "Server",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"authorize_button": "Autorisieren",
|
||||
"authorizing": "Autorisieren...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Done",
|
||||
"done": "Fertig",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"pairing_tv_connecting": "Der Fernseher verbindet sich mit Ihrem Konto",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
"error_invalid_qr": "Ungültiger QR-Code. Bitte scannen Sie den TV-Kopplungscode.",
|
||||
"error_generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"error_permission_denied": "Kameraberechtigung erforderlich zum Scannen von QR-Codes.",
|
||||
"login_as": "Als {{username}} anmelden?",
|
||||
"on_server": "auf {{server}}",
|
||||
"use_different_user": "Verwende einen anderen Benutzer",
|
||||
"open_settings": "Einstellungen öffnen"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
"waiting_for_phone": "Warte auf Telefon...",
|
||||
"scan_with_phone": "Scanne mit der Streamyfin-App auf deinem Handy",
|
||||
"logging_in": "Anmeldung...",
|
||||
"logging_in_description": "Verbinde mit deinem Server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Κανένα",
|
||||
"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_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Άλλο",
|
||||
"video_orientation": "Προσανατολισμός Βίντεο",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Άγνωστο"
|
||||
},
|
||||
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
||||
"video_player": "Αναπαραγωγέας Βίντεο",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Πειραματική + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
||||
"disabled": "Απενεργοποιημένο"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Λήψεις"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Πρόσθετα",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Αυτή η ενσωμάτωση βρίσκεται στα αρχικά της στάδια.",
|
||||
"server_url": "Url Διακομιστή",
|
||||
"server_url_hint": "Παράδειγμα: http(s)://your-host.url\n(προσθέστε θύρα εφόσον απαιτείται)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
||||
"save_button": "Αποθήκευση",
|
||||
"toasts": {
|
||||
"saved": "Αποθηκεύτηκε",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Αποθηκεύτηκε"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Σύστημα"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
|
||||
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
|
||||
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
|
||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Λήψεις",
|
||||
"tvseries": "Τηλεόραση-Σειρά",
|
||||
"series": "Τηλεόραση-Σειρά",
|
||||
"movies": "Ταινίες",
|
||||
"queue": "Ουρά",
|
||||
"other_media": "Άλλα μέσα",
|
||||
"queue_hint": "Ουρά και λήψεις θα χαθούν κατά την επανεκκίνηση της εφαρμογής",
|
||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||
"delete_all_button": "Διαγραφή Όλων",
|
||||
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
|
||||
"active_download": "Ενεργή Λήψη",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
|
||||
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
|
||||
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
|
||||
"deleted_all_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||
"download_deleted": "Η Λήψη Διαγράφηκε",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
||||
"download_paused": "Λήψη Σε Παύση",
|
||||
"could_not_pause_download": "Αδυναμία Παύσης Λήψης",
|
||||
"download_resumed": "Συνέχιση Λήψης",
|
||||
"could_not_resume_download": "Αδυναμία Συνέχισης Λήψης",
|
||||
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Όλα τα αρχεία, οι φάκελοι και οι εργασίες διαγράφηκαν με επιτυχία",
|
||||
"failed_to_clean_cache_directory": "Αποτυχία καθαρισμού φακέλου προσωρινής μνήμης",
|
||||
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
||||
"go_to_downloads": "Μετάβαση στις λήψεις",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Αναζήτηση...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
||||
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
||||
"next_episode": "Επόμενο Επεισόδιο",
|
||||
"refresh_tracks": "Ανανέωση Κομματιών",
|
||||
"audio_tracks": "Κομμάτια Ήχου:",
|
||||
"playback_state": "Κατάσταση Αναπαραγωγής:",
|
||||
"index": "Δείκτης:",
|
||||
"continue_watching": "Συνέχεια Παρακολούθησης",
|
||||
"go_back": "Μετάβαση Πίσω",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Εμφάνιση Περισσότερων",
|
||||
"show_less": "Εμφάνιση Λιγότερων",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Επόμενο",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,14 +229,14 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"tvseries": "Televidaj serioj",
|
||||
"series": "Televidaj serioj",
|
||||
"movies": "Filmoj",
|
||||
"queue": "Vico",
|
||||
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||
"no_items_in_queue": "Neniuj eroj en vico",
|
||||
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
||||
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
||||
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||
"delete_all_series_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||
"delete_all_button": "Forigi ĉion",
|
||||
"active_download": "Aktiva elŝuto",
|
||||
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
||||
@@ -253,8 +253,8 @@
|
||||
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
||||
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
||||
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
||||
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"download_cancelled": "Elŝuto nuligita",
|
||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||
"download_completed": "Elŝuto finita",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Nada",
|
||||
"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_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "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_title": "Otros",
|
||||
"video_orientation": "Orientación de vídeo",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Desconocida"
|
||||
},
|
||||
"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_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas"
|
||||
},
|
||||
"music": {
|
||||
"title": "Música",
|
||||
"playback_title": "Reproducir",
|
||||
@@ -378,13 +314,12 @@
|
||||
"caching_title": "Almacenando en caché",
|
||||
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
|
||||
"lookahead_enabled": "Activar el look-Ahead Cache",
|
||||
"lookahead_count": "",
|
||||
"lookahead_count": "Songs to pre-cache",
|
||||
"max_cache_size": "Tamaño máximo del caché"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Esta integración está en sus primeras etapas. Cuenta con posibles cambios.",
|
||||
"server_url": "URL del servidor",
|
||||
"server_url_hint": "Ejemplo: http(s)://tu-dominio.url\n(añade el puerto si es necesario)",
|
||||
"server_url_placeholder": "URL de Jellyseerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||
"save_button": "Guardar",
|
||||
"toasts": {
|
||||
"saved": "Guardado",
|
||||
"refreshed": "Ajustes del servidor actualizados"
|
||||
},
|
||||
"refresh_from_server": "Actualizar ajustes del servidor"
|
||||
"saved": "Guardado"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Habilitar Streamystats",
|
||||
"disable_streamystats": "Deshabilitar Streamystats",
|
||||
"enable_search": "Usar para la búsqueda",
|
||||
"url": "URL",
|
||||
"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.",
|
||||
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
||||
"save_button": "Guardar",
|
||||
"save": "Guardar",
|
||||
"features_title": "Características",
|
||||
"home_sections_title": "Secciones de inicio",
|
||||
"enable_movie_recommendations": "Recomendaciones de películas",
|
||||
"enable_series_recommendations": "Recomendaciones de series",
|
||||
"enable_promoted_watchlists": "Listas promocionadas",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento",
|
||||
"watchlist_button": "Activar o desactivar la integración de la lista de seguimiento"
|
||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
||||
"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",
|
||||
"enable_music_cache": "Activar Caché de Música",
|
||||
"clear_music_cache": "Borrar Caché de Música",
|
||||
"music_cache_size": "Caché {{Tamaño}}",
|
||||
"music_cache_cleared": "Caché de música eliminado",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error al eliminar archivos",
|
||||
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
||||
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
||||
"error_deleting_files": "Error al eliminar archivos"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
"tvseries": "Series",
|
||||
"series": "Series",
|
||||
"movies": "Películas",
|
||||
"queue": "Cola",
|
||||
"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",
|
||||
"delete_all_movies_button": "Eliminar todas las películas",
|
||||
"delete_all_tvseries_button": "Eliminar todas las series",
|
||||
"delete_all_series_button": "Eliminar todas las series",
|
||||
"delete_all_button": "Eliminar todo",
|
||||
"delete_all_other_media_button": "Eliminar otros medios",
|
||||
"active_download": "Descarga activa",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
||||
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
||||
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
||||
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
||||
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
|
||||
"failed_to_delete_all_series": "Error al eliminar todas las series",
|
||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||
"download_deleted": "Descarga eliminada",
|
||||
"download_cancelled": "Descarga cancelada",
|
||||
"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_failed": "Descarga fallida",
|
||||
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} ya está descargando",
|
||||
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
||||
"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}}",
|
||||
"go_to_downloads": "Ir a descargas",
|
||||
"file_deleted": "{{item}} eliminado"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "Nada",
|
||||
"track": "Pista",
|
||||
"cancel": "Cancelar",
|
||||
"stop": "Stop",
|
||||
"delete": "Borrar",
|
||||
"ok": "Aceptar",
|
||||
"remove": "Eliminar",
|
||||
"next": "Siguiente",
|
||||
"back": "Atrás",
|
||||
"continue": "Continuar",
|
||||
"verifying": "Verificando...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Buscar...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||
"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",
|
||||
"go_back": "Volver",
|
||||
"downloaded_file_title": "Ya tienes este archivo descargado",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Mostrar más",
|
||||
"show_less": "Mostrar menos",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Siguiente",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Listas de reproducción",
|
||||
"tracks": "Canciones"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Todas"
|
||||
},
|
||||
"recently_added": "Recientemente añadido",
|
||||
"recently_played": "Reproducidos Recientemente",
|
||||
"frequently_played": "Reproducido con frecuencia",
|
||||
"explore": "Explorar",
|
||||
"top_tracks": "Canciones Populares",
|
||||
"play": "Reproducir",
|
||||
"shuffle": "Aleatorio",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Ei mitään",
|
||||
"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_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Muut",
|
||||
"video_orientation": "Videon suunta",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Tuntematon"
|
||||
},
|
||||
"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_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
||||
"hide_libraries": "Piilota kirjastot",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
||||
"disabled": "Pois Käytöstä"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Lataukset"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Liitännäiset",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Tämä integraatio on alkuvaiheessa. Odota muutoksia.",
|
||||
"server_url": "Palvelimen URL",
|
||||
"server_url_hint": "Esimerkki: http(s)://verkkotunnus.url\n(lisää portti tarvittaessa)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "Lue lisää Marlinista.",
|
||||
"save_button": "Tallenna",
|
||||
"toasts": {
|
||||
"saved": "Tallennettu",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "Tallennettu"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Järjestelmä"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Virhe tiedostojen poistamisessa",
|
||||
"background_downloads_enabled": "Taustalataukset käytössä",
|
||||
"background_downloads_disabled": "Taustalataukset pois käytöstä"
|
||||
"error_deleting_files": "Virhe tiedostojen poistamisessa"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Lataukset",
|
||||
"tvseries": "TV-sarjat",
|
||||
"series": "TV-sarjat",
|
||||
"movies": "Elokuvat",
|
||||
"queue": "Jonot",
|
||||
"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",
|
||||
"delete_all_movies_button": "Poista kaikki elokuvat",
|
||||
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
|
||||
"delete_all_series_button": "Poista kaikki TV-sarjat",
|
||||
"delete_all_button": "Poista kaikki",
|
||||
"delete_all_other_media_button": "Poista muu media",
|
||||
"active_download": "Aktiivinen lataus",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
|
||||
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
|
||||
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
|
||||
"deleted_all_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
||||
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
||||
"download_deleted": "Lataus Poistettu",
|
||||
"download_cancelled": "Lataus peruutettu",
|
||||
"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_failed": "Lataus epäonnistui",
|
||||
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
||||
"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}}",
|
||||
"go_to_downloads": "Siirry latauksiin",
|
||||
"file_deleted": "{{item}} poistettu"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "Ei mitään",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Haku...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
||||
"message_from_server": "Viesti palvelimelta: {{message}}",
|
||||
"next_episode": "Seuraava Jakso",
|
||||
"refresh_tracks": "Päivitä Kappaleet",
|
||||
"audio_tracks": "Ääni Kappaleet:",
|
||||
"playback_state": "Toiston Tila:",
|
||||
"index": "Indeksi:",
|
||||
"continue_watching": "Jatka katsomista",
|
||||
"go_back": "Siirry Takaisin",
|
||||
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Näytä Lisää",
|
||||
"show_less": "Näytä Vähemmän",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Seuraava",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "Aucun",
|
||||
"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_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "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_title": "Autres",
|
||||
"video_orientation": "Orientation vidéo",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "Inconnu"
|
||||
},
|
||||
"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_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"playback_title": "Lecture",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"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_hint": "Exemple : http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
|
||||
"server_url_placeholder": "URL de Seerr...",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "En savoir plus sur Marlin.",
|
||||
"save_button": "Enregistrer",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
"refreshed": "Paramètres actualisés depuis le serveur"
|
||||
},
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
"saved": "Enregistré"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Activer Streamystats",
|
||||
"disable_streamystats": "Désactiver Streamystats",
|
||||
"enable_search": "Utiliser pour la recherche",
|
||||
"url": "URL",
|
||||
"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.",
|
||||
"read_more_about_streamystats": "En savoir plus sur Streamystats.",
|
||||
"save_button": "Enregistrer",
|
||||
"save": "Enregistrer",
|
||||
"features_title": "Fonctionnalités",
|
||||
"home_sections_title": "Sections de la page d´accueil",
|
||||
"enable_movie_recommendations": "Recommandations de films",
|
||||
"enable_series_recommendations": "Recommandations de séries",
|
||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture",
|
||||
"watchlist_button": "Activer l'intégration de notre liste de lecture"
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
|
||||
"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",
|
||||
"enable_music_cache": "Activer le cache sur la musique",
|
||||
"clear_music_cache": "Vider le cache de la musique",
|
||||
"music_cache_size": "{{size}} mis en cache",
|
||||
"music_cache_cleared": "Cache de musique effacé",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "Système"
|
||||
},
|
||||
"toasts": {
|
||||
"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"
|
||||
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
"tvseries": "Séries",
|
||||
"series": "Séries",
|
||||
"movies": "Films",
|
||||
"queue": "File d'attente",
|
||||
"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é",
|
||||
"delete_all_movies_button": "Supprimer tous les films",
|
||||
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
||||
"delete_all_series_button": "Supprimer toutes les séries",
|
||||
"delete_all_button": "Supprimer tous les médias",
|
||||
"delete_all_other_media_button": "Supprimer un autre média",
|
||||
"active_download": "Téléchargement actif",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
||||
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
||||
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
||||
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
|
||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"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 !",
|
||||
"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é",
|
||||
"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_failed": "Échec du téléchargement",
|
||||
"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",
|
||||
"all_files_deleted": "Tous les téléchargements supprimés avec succès",
|
||||
"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}}",
|
||||
"go_to_downloads": "Aller aux téléchargements",
|
||||
"file_deleted": "{{item}} supprimé"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "Aucun",
|
||||
"track": "Suivre",
|
||||
"cancel": "Annuler",
|
||||
"stop": "Stop",
|
||||
"delete": "Supprimer",
|
||||
"ok": "Ok",
|
||||
"remove": "Retirer",
|
||||
"next": "Suivant",
|
||||
"back": "Précédent",
|
||||
"continue": "Continuer",
|
||||
"verifying": "Vérification...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Rechercher...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
||||
"message_from_server": "Message du serveur : {{message}}",
|
||||
"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",
|
||||
"go_back": "Retour",
|
||||
"downloaded_file_title": "Ce fichier est téléchargé",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Suivant",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "morceaux"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Toutes"
|
||||
},
|
||||
"recently_added": "Ajoutés récemment",
|
||||
"recently_played": "Récemment joué",
|
||||
"frequently_played": "Fréquemment joué",
|
||||
"explore": "Explorez",
|
||||
"top_tracks": "Top chansons",
|
||||
"play": "Lecture",
|
||||
"shuffle": "Aléatoire",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
@@ -261,43 +261,6 @@
|
||||
"None": "ללא",
|
||||
"OnlyForced": "רק כפוי"
|
||||
},
|
||||
"text_color": "צבע הטקסט",
|
||||
"background_color": "צבע רקע",
|
||||
"outline_color": "צבע קו מתאר",
|
||||
"outline_thickness": "עובי קו מתאר",
|
||||
"background_opacity": "שקיפות רקע",
|
||||
"outline_opacity": "אטימות קו מתאר",
|
||||
"bold_text": "טקסט בולט",
|
||||
"colors": {
|
||||
"Black": "שחור",
|
||||
"Gray": "אפור",
|
||||
"Silver": "כסף",
|
||||
"White": "לבן",
|
||||
"Maroon": "חום ערמוני",
|
||||
"Red": "אדום",
|
||||
"Fuchsia": "פוקסיה",
|
||||
"Yellow": "צהוב",
|
||||
"Olive": "זית",
|
||||
"Green": "ירוק",
|
||||
"Teal": "תכלת",
|
||||
"Lime": "ירוק ליים",
|
||||
"Purple": "סגול",
|
||||
"Navy": "כחול כהה",
|
||||
"Blue": "כחול",
|
||||
"Aqua": "כחול בהיר"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "ללא",
|
||||
"Thin": "דק",
|
||||
"Normal": "רגיל",
|
||||
"Thick": "עבה"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
@@ -315,25 +278,6 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "נגן וידאו",
|
||||
"video_player": "נגן וידאו",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "אחר",
|
||||
"video_orientation": "כיוון וידיאו",
|
||||
@@ -351,11 +295,6 @@
|
||||
"UNKNOWN": "לא ידוע"
|
||||
},
|
||||
"safe_area_in_controls": "איזור בטוח בפקדים",
|
||||
"video_player": "נגן וידאו",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (ניסיוני + נגן בתוך נגן)"
|
||||
},
|
||||
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
|
||||
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
|
||||
"hide_libraries": "הסתר ספריות",
|
||||
@@ -367,9 +306,6 @@
|
||||
"max_auto_play_episode_count": "כמות פרקים מקסימלית לניגון אוטומטי",
|
||||
"disabled": "כבוי"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "הורדות"
|
||||
},
|
||||
"music": {
|
||||
"title": "מוזיקה",
|
||||
"playback_title": "Playback",
|
||||
@@ -384,7 +320,6 @@
|
||||
"plugins": {
|
||||
"plugins_title": "תוספים",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "חלק זה נמצא עדיין בשלבים מוקדמים. צפו שדברים ישתנו.",
|
||||
"server_url": "כתובת ה-URL של השרת",
|
||||
"server_url_hint": "לדוגמא: http(s)://your-host.url\n(הוסף פורט במידת הצורך)",
|
||||
"server_url_placeholder": "כתובת ה-URL של Seerr",
|
||||
@@ -413,23 +348,18 @@
|
||||
"read_more_about_marlin": "קרא עוד על Marlin.",
|
||||
"save_button": "שמור",
|
||||
"toasts": {
|
||||
"saved": "נשמר",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"saved": "נשמר"
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -445,8 +375,7 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -457,7 +386,6 @@
|
||||
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -467,8 +395,6 @@
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_success": "Cache Cleared",
|
||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
@@ -490,15 +416,12 @@
|
||||
"system": "מערכת"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "שגיאה במחיקת קבצים",
|
||||
"background_downloads_enabled": "הורדה ברקע מופעלת",
|
||||
"background_downloads_disabled": "הורדה ברקע כבויה"
|
||||
"error_deleting_files": "שגיאה במחיקת קבצים"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"description": "Auto logout after inactivity",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
@@ -516,15 +439,12 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "הורדות",
|
||||
"tvseries": "סדרות",
|
||||
"series": "סדרות",
|
||||
"movies": "סרטים",
|
||||
"queue": "תוֹר",
|
||||
"other_media": "תוכן אחר",
|
||||
"queue_hint": "התור וההורדות יאבדו בפתיחה מחדש של האפליקציה",
|
||||
"no_items_in_queue": "אין פרטים בתור",
|
||||
"no_downloaded_items": "אין פריטים שהורדו",
|
||||
"delete_all_movies_button": "מחק את כל הסרטים",
|
||||
"delete_all_tvseries_button": "מחק את כל הסדרות",
|
||||
"delete_all_series_button": "מחק את כל הסדרות",
|
||||
"delete_all_button": "מחק הכל",
|
||||
"delete_all_other_media_button": "מחק שאר תוכן",
|
||||
"active_download": "הורדה פעילה",
|
||||
@@ -542,17 +462,12 @@
|
||||
"you_are_not_allowed_to_download_files": "אתה לא מורשה להוריד קבצים.",
|
||||
"deleted_all_movies_successfully": "כל הסרטים נמחקו בהצלחה!",
|
||||
"failed_to_delete_all_movies": "נכשל במחיקת כל הסרטים",
|
||||
"deleted_all_tvseries_successfully": "כל הסדרות נמחקו בהצלחה!",
|
||||
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
|
||||
"deleted_all_series_successfully": "כל הסדרות נמחקו בהצלחה!",
|
||||
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
|
||||
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
||||
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
||||
"download_deleted": "ההורדה נמחקה",
|
||||
"download_cancelled": "ההורדה בוטלה",
|
||||
"could_not_delete_download": "לא היה ניתן למחוק את ההורדה",
|
||||
"download_paused": "ההורדה נעצרה",
|
||||
"could_not_pause_download": "לא היה ניתן לעצור את ההורדה",
|
||||
"download_resumed": "ההורדה חודשה",
|
||||
"could_not_resume_download": "לא היה ניתן לחדש את ההורדה",
|
||||
"download_completed": "ההורדה הושלמה",
|
||||
"download_failed": "ההורדה נכשלה",
|
||||
"download_failed_for_item": "ההורדה נכשלה עבור {{item}} - {{error}}",
|
||||
@@ -562,10 +477,7 @@
|
||||
"item_already_downloading": "{{item}} כבר נמצא בהורדה",
|
||||
"all_files_deleted": "כל ההורדות נמחקו בהצלחה",
|
||||
"files_deleted_by_type": "{{count}} {{type}} נמחקו",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "כל הקבצים, התיקיות והעבודות נמחקו בהצלחה",
|
||||
"failed_to_clean_cache_directory": "נכשל בניסיון למחוק את תיקיית המטמון",
|
||||
"could_not_get_download_url_for_item": "לא היה ניתן להשיג את קישור ההורדה של {{itemName}}",
|
||||
"go_to_downloads": "עבור להורדות",
|
||||
"file_deleted": "{{item}} נמחק"
|
||||
}
|
||||
}
|
||||
@@ -583,16 +495,17 @@
|
||||
"none": "ללא",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "חפש...",
|
||||
@@ -691,10 +604,6 @@
|
||||
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
||||
"message_from_server": "הודעה מהשרת: {{message}}",
|
||||
"next_episode": "הפרק הבא",
|
||||
"refresh_tracks": "רענן רצועות",
|
||||
"audio_tracks": "רצועות שמע:",
|
||||
"playback_state": "מצב ניגון:",
|
||||
"index": "מיקום:",
|
||||
"continue_watching": "המשך לצפות",
|
||||
"go_back": "חזור",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -723,7 +632,8 @@
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded"
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
@@ -761,7 +671,6 @@
|
||||
"show_more": "הצג עוד",
|
||||
"show_less": "הצג פחות",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -784,7 +693,8 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "הבא",
|
||||
@@ -888,13 +798,9 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -1028,7 +934,6 @@
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user