mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-30 18:48:30 +01:00
Compare commits
3 Commits
chore/mpv-
...
sync-subti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdd7770c9 | ||
|
|
24cb679c0b | ||
|
|
af50b023ef |
95
.github/pull_request_template.md
vendored
95
.github/pull_request_template.md
vendored
@@ -1,54 +1,91 @@
|
||||
<!--
|
||||
Use a conventional commit title for the PR title,
|
||||
for example `feat(auth): add MFA`
|
||||
All sections below are required. Write N/A if a section is not applicable.
|
||||
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
|
||||
<!--
|
||||
Pull Request Template for Streamyfin
|
||||
====================================
|
||||
Use this template to help reviewers understand the purpose of your PR
|
||||
and to ensure all necessary checks are completed before merging.
|
||||
-->
|
||||
|
||||
# 📦 Pull Request
|
||||
|
||||
<!--
|
||||
🤖 AI ASSISTED?
|
||||
Uncomment the line below if AI was used to assist with this PR:
|
||||
-->
|
||||
<!--
|
||||
[](#) -->
|
||||
|
||||
## 📝 Description
|
||||
## 🔖 Summary
|
||||
<!--
|
||||
A short description of the changes and why you're making them.
|
||||
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
||||
A concise description of the changes introduced by this PR.
|
||||
Example:
|
||||
“Add real-time currency conversion widget to dashboard.”
|
||||
-->
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
<!--
|
||||
Link to the related ticket, issue or user story.
|
||||
Example: Fixes #123
|
||||
You can also indicate if this PR supersedes a previous one.
|
||||
Example:
|
||||
- Closes #123
|
||||
- Fixes STREAMYFIN-456
|
||||
- Resolves #789
|
||||
- Supersedes #120
|
||||
- Related: #130
|
||||
-->
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
<!--
|
||||
Include screenshots of relevant UI changes for both Android and iOS.
|
||||
Before/After, responsive states (if relevant).
|
||||
## 🛠️ What’s Changed
|
||||
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
||||
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
||||
Spec: https://www.conventionalcommits.org/ -->
|
||||
|
||||
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||
- Scope (optional): e.g., auth, billing, mobile
|
||||
- Short summary: what changed and why (1–2 lines)
|
||||
-->
|
||||
|
||||
## 📋 Details
|
||||
<!--
|
||||
Provide more context or background. Explain any non-obvious decisions.
|
||||
Include screenshots or GIFs for UI changes if applicable.
|
||||
-->
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||
|
||||
### 🔐 Security & Privacy Impact
|
||||
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
||||
|
||||
### ⚡ Performance Impact
|
||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
<!-- Before/After, dark mode, responsive states. -->
|
||||
|
||||
## ✅ Checklist
|
||||
<!--
|
||||
Review and check off items as you complete them.
|
||||
-->
|
||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||
- [ ] Verified that changes behave as expected for all platforms
|
||||
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||
- [ ] No secrets, hardcoded credentials, or private config files are included
|
||||
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
||||
- [ ] Type checks pass (tsc/biome/etc.)
|
||||
- [ ] Docs updated (README/ADR/usage/API)
|
||||
- [ ] No secrets/credentials included; env vars documented
|
||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
||||
- [ ] Verified locally that changes behave as expected
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
<!--
|
||||
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
||||
Describe how reviewers can test your changes.
|
||||
Example:
|
||||
1. Open the settings page and scroll to the bottom
|
||||
2. Verify that the clear data button is visible and pressable
|
||||
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
||||
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
||||
5. Verification steps:
|
||||
- [ ] Expected UI/endpoint behavior
|
||||
- [ ] Logs show no errors
|
||||
- [ ] Edge cases covered (list)
|
||||
-->
|
||||
|
||||
## ⚙️ Deployment Notes
|
||||
<!--
|
||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
||||
-->
|
||||
|
||||
## 📝 Additional Notes
|
||||
<!--
|
||||
Any other information or references related to this PR.
|
||||
-->
|
||||
75
.github/workflows/artifact-comment.yml
vendored
75
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
// Check if we're running from a fork (more precise detection)
|
||||
@@ -188,17 +188,6 @@ jobs:
|
||||
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({
|
||||
@@ -227,6 +216,13 @@ jobs:
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
// Map job names to our build targets
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
|
||||
};
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
@@ -240,9 +236,7 @@ jobs:
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: job.started_at || latestAppsRun.created_at,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at
|
||||
created_at: job.started_at || latestAppsRun.created_at
|
||||
};
|
||||
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||
} else {
|
||||
@@ -253,30 +247,22 @@ jobs:
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||
// Fallback to workflow-level status for every build target.
|
||||
// Keys must match jobMappings / buildTargets statusKey values.
|
||||
const fallbackStatus = {
|
||||
// Fallback to workflow-level status
|
||||
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
||||
name: latestAppsRun.name,
|
||||
status: latestAppsRun.status,
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
for (const platform of Object.keys(jobMappings)) {
|
||||
buildStatuses[platform] = fallbackStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect artifacts if any job has completed successfully
|
||||
@@ -367,12 +353,10 @@ jobs:
|
||||
|
||||
// Process each expected build target individually
|
||||
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 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 Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
|
||||
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
|
||||
];
|
||||
|
||||
for (const target of buildTargets) {
|
||||
@@ -387,31 +371,16 @@ 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') {
|
||||
// Special case for iOS TV - show as disabled
|
||||
if (target.name === 'iOS TV') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||
downloadLink = '*Disabled for now*';
|
||||
} else if (matchingStatus) {
|
||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||
status = '✅ Complete';
|
||||
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
||||
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
|
||||
|
||||
// Format file size
|
||||
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
|
||||
const sizeInfo = `(${sizeInMB} MB)`;
|
||||
|
||||
// Calculate build duration
|
||||
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`;
|
||||
}
|
||||
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink})`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
downloadLink = '*Build failed*';
|
||||
@@ -439,7 +408,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
|
||||
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
|
||||
}
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
226
.github/workflows/build-apps.yml
vendored
226
.github/workflows/build-apps.yml
vendored
@@ -41,12 +41,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -124,12 +124,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -195,12 +195,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -216,12 +216,12 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
@@ -259,12 +259,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
@@ -293,133 +293,73 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
build-ios-tv:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix 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
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
build-ios-tv-unsigned:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix below.
|
||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
# Disabled for now - uncomment when ready to build iOS TV
|
||||
# build-ios-tv:
|
||||
# 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 (TV)
|
||||
# permissions:
|
||||
# contents: read
|
||||
#
|
||||
# steps:
|
||||
# - name: 📥 Checkout code
|
||||
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# with:
|
||||
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
# fetch-depth: 0
|
||||
# submodules: recursive
|
||||
# show-progress: false
|
||||
#
|
||||
# - name: 🍞 Setup Bun
|
||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
# with:
|
||||
# bun-version: latest
|
||||
#
|
||||
# - name: 💾 Cache Bun dependencies
|
||||
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
# with:
|
||||
# path: ~/.bun/install/cache
|
||||
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-bun-cache
|
||||
#
|
||||
# - name: 📦 Install dependencies and reload submodules
|
||||
# run: |
|
||||
# bun install --frozen-lockfile
|
||||
# bun run submodule-reload
|
||||
#
|
||||
# - name: 🛠️ Generate project files
|
||||
# run: bun run prebuild:tv
|
||||
#
|
||||
# - name: 🔧 Setup Xcode
|
||||
# uses: maxim-lobanov/setup-xcode@v1
|
||||
# with:
|
||||
# xcode-version: '26.0.1'
|
||||
#
|
||||
# - name: 🏗️ Setup EAS
|
||||
# uses: expo/expo-github-action@main
|
||||
# with:
|
||||
# eas-version: latest
|
||||
# token: ${{ secrets.EXPO_TOKEN }}
|
||||
# eas-cache: true
|
||||
#
|
||||
# - name: 🚀 Build iOS app
|
||||
# env:
|
||||
# EXPO_TV: 1
|
||||
# run: eas build -p ios --local --non-interactive
|
||||
#
|
||||
# - name: 📅 Set date tag
|
||||
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
#
|
||||
# - name: 📤 Upload IPA artifact
|
||||
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
# with:
|
||||
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||
# path: build-*.ipa
|
||||
# retention-days: 7
|
||||
|
||||
4
.github/workflows/check-lockfile.yml
vendored
4
.github/workflows/check-lockfile.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -27,13 +27,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
12
.github/workflows/linting.yml
vendored
12
.github/workflows/linting.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
fail-on-severity: high
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
6
.github/workflows/update-issue-form.yml
vendored
6
.github/workflows/update-issue-form.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 🔍 Extract minor version from app.json
|
||||
id: minor
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||
uses: actions/github-script@main
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
dry_run: no-push
|
||||
|
||||
- name: 📬 Commit and create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,6 +72,4 @@ modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
.agents/skills/**
|
||||
skills-lock.json
|
||||
build/
|
||||
4
app.json
4
app.json
@@ -124,8 +124,8 @@
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
"podName": "MPVKit-GPL",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ import useRouter from "@/hooks/useAppRouter";
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -48,7 +47,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -59,7 +66,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -69,7 +84,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -79,7 +102,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -89,7 +120,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -99,7 +138,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -109,7 +156,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -119,7 +174,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -147,7 +210,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -157,7 +228,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -167,7 +246,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -177,7 +264,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -187,7 +282,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -197,7 +300,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -207,7 +318,15 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
@@ -217,7 +336,11 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
|
||||
@@ -61,10 +61,7 @@ export default function Page() {
|
||||
setLoading(true);
|
||||
try {
|
||||
logsFile.write(JSON.stringify(filteredLogs));
|
||||
await Sharing.shareAsync(logsFile.uri, {
|
||||
mimeType: "text/plain",
|
||||
UTI: "public.plain-text",
|
||||
});
|
||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||
} catch (e: any) {
|
||||
writeErrorLog("Something went wrong attempting to export", e);
|
||||
} finally {
|
||||
|
||||
@@ -30,10 +30,8 @@ const Page: React.FC = () => {
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// Lazily preload item with full media sources in background — never cache
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
|
||||
gcTime: 0,
|
||||
});
|
||||
// Lazily preload item with full media sources in background
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function page() {
|
||||
const audioIndexFromUrl = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
: undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
const subtitleIndexFromUrl = subtitleIndexStr
|
||||
? Number.parseInt(subtitleIndexStr, 10)
|
||||
: -1;
|
||||
const bitrateValue = bitrateValueStr
|
||||
@@ -161,6 +161,24 @@ export default function page() {
|
||||
return undefined;
|
||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||
|
||||
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
|
||||
const subtitleIndex = useMemo(() => {
|
||||
if (subtitleIndexFromUrl !== undefined) {
|
||||
return subtitleIndexFromUrl;
|
||||
}
|
||||
if (
|
||||
offline &&
|
||||
downloadedItem?.userData?.subtitleStreamIndex !== undefined
|
||||
) {
|
||||
return downloadedItem.userData.subtitleStreamIndex;
|
||||
}
|
||||
return -1;
|
||||
}, [
|
||||
subtitleIndexFromUrl,
|
||||
offline,
|
||||
downloadedItem?.userData?.subtitleStreamIndex,
|
||||
]);
|
||||
|
||||
// Get the playback speed for this item based on settings
|
||||
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
|
||||
item,
|
||||
@@ -406,8 +424,8 @@ export default function page() {
|
||||
|
||||
return {
|
||||
ItemId: item.Id,
|
||||
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
AudioStreamIndex: audioIndex,
|
||||
SubtitleStreamIndex: subtitleIndex,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: msToTicks(progress.get()),
|
||||
IsPaused: !isPlaying,
|
||||
|
||||
@@ -375,9 +375,8 @@ function Layout() {
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
return (
|
||||
query.state.status === "success" && query.options.gcTime !== 0
|
||||
);
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -196,30 +195,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
}
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
// Ensure the snapshot we store offline carries the Chapters array.
|
||||
// Page-level fetches sometimes use a fields filter that omits it; the
|
||||
// offline player would then render no chapter ticks / list.
|
||||
let itemForDownload = item;
|
||||
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
||||
try {
|
||||
const enriched = await getUserLibraryApi(api).getItem({
|
||||
itemId: itemForDownload.Id,
|
||||
userId: user.Id!,
|
||||
});
|
||||
if (enriched.data) {
|
||||
itemForDownload = enriched.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(itemForDownload, settings!)
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
@@ -228,7 +206,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
api,
|
||||
item: itemForDownload,
|
||||
item,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
@@ -240,7 +218,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item: itemForDownload,
|
||||
item,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -53,6 +54,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const downloadedItem =
|
||||
isOffline && item.Id ? getDownloadedItemById(item.Id) : null;
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
@@ -91,17 +95,29 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
// When offline, use the indices stored in userData (the last-used tracks for this file)
|
||||
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
|
||||
const offlineUserData = downloadedItem?.userData;
|
||||
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex:
|
||||
offlineUserData && !offlineUserData.isTranscoded
|
||||
? offlineUserData.subtitleStreamIndex
|
||||
: (defaultSubtitleIndex ?? -1),
|
||||
audioIndex:
|
||||
offlineUserData && !offlineUserData.isTranscoded
|
||||
? offlineUserData.audioStreamIndex
|
||||
: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
downloadedItem?.userData?.audioStreamIndex,
|
||||
downloadedItem?.userData?.subtitleStreamIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,14 +248,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -28,6 +30,14 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isOffline = useOfflineMode();
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
|
||||
// For transcoded downloads there's only one burned-in track — nothing to pick
|
||||
const isTranscodedDownload = useMemo(() => {
|
||||
if (!isOffline || !item?.Id) return false;
|
||||
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
|
||||
}, [isOffline, item?.Id, getDownloadedItemById]);
|
||||
|
||||
const effectiveColors = colors || {
|
||||
primary: "#7c3aed",
|
||||
@@ -72,34 +82,36 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Bitrate group
|
||||
groups.push({
|
||||
title: t("item_card.quality"),
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate,
|
||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||
onPress: () =>
|
||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||
})),
|
||||
});
|
||||
|
||||
// Media Source group (only if multiple sources)
|
||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||
if (!isOffline) {
|
||||
// Bitrate group
|
||||
groups.push({
|
||||
title: t("item_card.video"),
|
||||
options: item.MediaSources.map((source) => ({
|
||||
title: t("item_card.quality"),
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: getMediaSourceDisplayName(source),
|
||||
value: source,
|
||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||
label: bitrate.key,
|
||||
value: bitrate,
|
||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||
onPress: () =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, mediaSource: source },
|
||||
),
|
||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||
})),
|
||||
});
|
||||
|
||||
// Media Source group (only if multiple sources)
|
||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||
groups.push({
|
||||
title: t("item_card.video"),
|
||||
options: item.MediaSources.map((source) => ({
|
||||
type: "radio" as const,
|
||||
label: getMediaSourceDisplayName(source),
|
||||
value: source,
|
||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||
onPress: () =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, mediaSource: source },
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audio track group
|
||||
@@ -150,6 +162,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
return groups;
|
||||
}, [
|
||||
item,
|
||||
isOffline,
|
||||
selectedOptions,
|
||||
audioStreams,
|
||||
subtitleStreams,
|
||||
@@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isTranscodedDownload) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
@@ -254,39 +254,23 @@ const PlatformDropdownComponent = ({
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use a nested ContextMenu as a submenu for grouped options
|
||||
const selectedOption = radioOptions.find(
|
||||
(opt) => opt.selected,
|
||||
);
|
||||
const displayTitle = selectedOption
|
||||
? `${group.title}: ${selectedOption.label}`
|
||||
: group.title;
|
||||
|
||||
// Use Picker for grouped options
|
||||
items.push(
|
||||
<ContextMenu key={`submenu-${groupIndex}`}>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>{displayTitle}</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{radioOptions.map((option, optionIndex) => (
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.selected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>,
|
||||
<Picker
|
||||
key={`picker-${groupIndex}`}
|
||||
label={group.title}
|
||||
options={radioOptions.map((opt) => opt.label)}
|
||||
variant='menu'
|
||||
selectedIndex={radioOptions.findIndex(
|
||||
(opt) => opt.selected,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
const selectedOption = radioOptions[index];
|
||||
selectedOption?.onPress();
|
||||
onOptionSelect?.(selectedOption?.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
|
||||
@@ -96,14 +96,23 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
if (selectedOptions.audioIndex !== undefined) {
|
||||
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
|
||||
}
|
||||
|
||||
if (selectedOptions.subtitleIndex !== undefined) {
|
||||
queryParams.set(
|
||||
"subtitleIndex",
|
||||
selectedOptions.subtitleIndex.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
if (!client) {
|
||||
@@ -292,6 +301,29 @@ export const PlayButton: React.FC<Props> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const buildOfflineQueryParams = useCallback(
|
||||
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
|
||||
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
|
||||
const audioIdx = isTranscoded
|
||||
? downloadedItem.userData?.audioStreamIndex
|
||||
: selectedOptions.audioIndex;
|
||||
const subtitleIdx = isTranscoded
|
||||
? downloadedItem.userData?.subtitleStreamIndex
|
||||
: selectedOptions.subtitleIndex;
|
||||
const params = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
|
||||
if (subtitleIdx !== undefined)
|
||||
params.set("subtitleIndex", subtitleIdx.toString());
|
||||
return params;
|
||||
},
|
||||
[item, selectedOptions],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
@@ -302,13 +334,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,13 +357,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer(
|
||||
buildOfflineQueryParams(downloadedItem).toString(),
|
||||
);
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
@@ -374,13 +396,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
{
|
||||
text: t("player.downloaded_file_yes"),
|
||||
onPress: () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||
},
|
||||
isPreferred: true,
|
||||
},
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* A modal listing an item's chapters. Each row shows the chapter name and its
|
||||
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
||||
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { memo, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
formatChapterTime,
|
||||
sortedChapters,
|
||||
} from "@/utils/chapters";
|
||||
|
||||
interface ChapterListProps {
|
||||
visible: boolean;
|
||||
chapters: ChapterInfo[] | null | undefined;
|
||||
/** Current playback position in milliseconds (to highlight the row). */
|
||||
currentPositionMs: number;
|
||||
/** Seek the player to this millisecond position. */
|
||||
onSeek: (positionMs: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
|
||||
function ChapterListComponent({
|
||||
visible,
|
||||
chapters,
|
||||
currentPositionMs,
|
||||
onSeek,
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
|
||||
// every tick — chapters is the only input that drives the underlying array.
|
||||
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
|
||||
const activeIndex = useMemo(() => {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (currentPositionMs >= starts[i]) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
}, [currentPositionMs, starts]);
|
||||
|
||||
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
|
||||
// children mounted across visible toggles, so subsequent opens never scroll.
|
||||
// Trigger an imperative scroll each time the sheet becomes visible.
|
||||
useEffect(() => {
|
||||
if (!visible || activeIndex < 0 || entries.length === 0) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: activeIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [visible, activeIndex, entries.length]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.close")}
|
||||
>
|
||||
<Ionicons name='close' size={24} color={Colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={entries}
|
||||
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: ROW_HEIGHT,
|
||||
offset: ROW_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Required when getItemLayout is provided and the target index
|
||||
// is outside the currently rendered window. Fallback to an
|
||||
// offset-based scroll, then retry the precise scroll once a
|
||||
// frame has elapsed.
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: info.averageItemLength * info.index,
|
||||
animated: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 50);
|
||||
}}
|
||||
renderItem={({ item, index }) => {
|
||||
const positionMs = item.positionMs;
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSeek(positionMs);
|
||||
onClose();
|
||||
}}
|
||||
style={[
|
||||
styles.row,
|
||||
isActive && { backgroundColor: `${Colors.primary}33` },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.rowText,
|
||||
{ color: isActive ? Colors.primary : Colors.text },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.chapter.Name ||
|
||||
t("chapters.chapter_number", { number: index + 1 })}
|
||||
</Text>
|
||||
<Text style={styles.rowTime}>
|
||||
{formatChapterTime(positionMs)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChapterList = memo(ChapterListComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
color: Colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
height: ROW_HEIGHT,
|
||||
},
|
||||
rowText: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
},
|
||||
rowTime: {
|
||||
color: Colors.icon,
|
||||
fontSize: 13,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
||||
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
||||
* so the slider underneath still receives touches.
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
|
||||
import type { ChapterMarker } from "@/utils/chapters";
|
||||
|
||||
interface ChapterTicksProps {
|
||||
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
|
||||
markers: ChapterMarker[];
|
||||
/** Tick colour. */
|
||||
color?: string;
|
||||
/** Tick height in px — slightly less than the slider track thickness. */
|
||||
height?: number;
|
||||
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function ChapterTicksComponent({
|
||||
markers,
|
||||
// Semi-transparent black contrasts against both the filled progress
|
||||
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
|
||||
// stay visible across the whole bar as playback advances.
|
||||
color = "rgba(0,0,0,0.55)",
|
||||
height = 14,
|
||||
width = 2,
|
||||
}: ChapterTicksProps) {
|
||||
// Hooks must run unconditionally — keep them before any early return.
|
||||
const [sliderWidth, setSliderWidth] = useState(0);
|
||||
|
||||
const handleLayout = (e: LayoutChangeEvent) => {
|
||||
setSliderWidth(e.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
// One chapter (typically a single marker at 0) is not worth marking.
|
||||
if (markers.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
onLayout={handleLayout}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
// Let ticks taller than this container bleed beyond its bounds.
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{sliderWidth > 0 &&
|
||||
markers
|
||||
// Skip the leading 0ms marker — it overlaps the slider start and
|
||||
// adds visual noise at an already-rendered boundary.
|
||||
.filter((marker) => marker.positionMs > 0)
|
||||
.map((marker, index) => {
|
||||
// Align both the position AND the width onto the device's
|
||||
// physical pixel grid. Without this, fractional dp values land
|
||||
// at different sub-pixel fractions per tick — Android samples
|
||||
// each one differently and some ticks render visibly thicker.
|
||||
const centerDp = (marker.percent / 100) * sliderWidth;
|
||||
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
|
||||
const snappedWidth = PixelRatio.roundToNearestPixel(width);
|
||||
return (
|
||||
<View
|
||||
key={`${marker.positionMs}-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top: "50%",
|
||||
marginTop: -height / 2,
|
||||
height,
|
||||
width: snappedWidth,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChapterTicks = memo(ChapterTicksComponent);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
@@ -49,66 +49,32 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
></Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>
|
||||
{`${t("library.filters.sort_by")}: ${t(
|
||||
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
||||
)}`}
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{sortOptions.map((item) => {
|
||||
const label = t(
|
||||
`home.settings.plugins.jellyseerr.order_by.${item}`,
|
||||
);
|
||||
const isSelected =
|
||||
jellyseerrOrderBy ===
|
||||
(item as unknown as JellyseerrSearchSort);
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
systemImage={
|
||||
isSelected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() =>
|
||||
setJellyseerrOrderBy(
|
||||
item as unknown as JellyseerrSearchSort,
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>
|
||||
{`${t("library.filters.sort_order")}: ${t(
|
||||
`library.filters.${jellyseerrSortOrder}`,
|
||||
)}`}
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{orderOptions.map((item) => {
|
||||
const label = t(`library.filters.${item}`);
|
||||
const isSelected = jellyseerrSortOrder === item;
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
systemImage={
|
||||
isSelected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => setJellyseerrSortOrder(item)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
options={sortOptions.map((item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||
)}
|
||||
variant='menu'
|
||||
selectedIndex={sortOptions.indexOf(
|
||||
jellyseerrOrderBy as unknown as string,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrOrderBy(
|
||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Picker
|
||||
label={t("library.filters.sort_order")}
|
||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||
variant='menu'
|
||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrSortOrder(orderOptions[index]);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
ChapterInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
// Chapter tick height in dp — matches the slider track height for a clean,
|
||||
// flush look (no top/bottom overflow).
|
||||
const TICK_HEIGHT = 10;
|
||||
|
||||
interface BottomControlsProps {
|
||||
item: BaseItemDto;
|
||||
/** Item chapters, used for the tick overlay and chapter list. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Total media duration in milliseconds. */
|
||||
durationMs: number;
|
||||
showControls: boolean;
|
||||
isSliding: boolean;
|
||||
showRemoteBubble: boolean;
|
||||
@@ -54,8 +38,6 @@ interface BottomControlsProps {
|
||||
handleSliderChange: (value: number) => void;
|
||||
handleTouchStart: () => void;
|
||||
handleTouchEnd: () => void;
|
||||
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
|
||||
seekTo: (value: number) => void;
|
||||
|
||||
// Trickplay props
|
||||
trickPlayUrl: {
|
||||
@@ -79,8 +61,6 @@ interface BottomControlsProps {
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
item,
|
||||
chapters,
|
||||
durationMs,
|
||||
showControls,
|
||||
isSliding,
|
||||
showRemoteBubble,
|
||||
@@ -104,38 +84,12 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
handleSliderChange,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
seekTo,
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
const chapterMarkerList = useMemo(
|
||||
() => chapterMarkers(chapters, durationMs),
|
||||
[chapters, durationMs],
|
||||
);
|
||||
const hasChapters = chapterMarkerList.length > 1;
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
[hasChapters, currentTime, chapters],
|
||||
);
|
||||
|
||||
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
|
||||
// an {h,m,s} object derived from the slider's dragged value — convert back
|
||||
// to ms for the lookup. Only useful while actively scrubbing.
|
||||
const scrubChapterName = useMemo(() => {
|
||||
if (!hasChapters) return null;
|
||||
const scrubMs =
|
||||
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
|
||||
return chapterNameAt(scrubMs, chapters);
|
||||
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -177,24 +131,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||
)}
|
||||
{currentChapterName ? (
|
||||
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
|
||||
{currentChapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<View className='flex flex-row space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -238,9 +176,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
height: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "stretch",
|
||||
// Allow chapter ticks taller than the 10px track to bleed out
|
||||
// top/bottom (RN defaults to overflow: "hidden" on Android).
|
||||
overflow: "visible",
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -268,7 +203,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
chapterName={scrubChapterName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -278,7 +212,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
|
||||
</View>
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
@@ -286,13 +219,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={currentTime}
|
||||
onSeek={seekTo}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -251,7 +251,6 @@ export const Controls: FC<Props> = ({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
} = useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
@@ -529,8 +528,6 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<BottomControls
|
||||
item={item}
|
||||
chapters={item.Chapters}
|
||||
durationMs={maxMs}
|
||||
showControls={showControls}
|
||||
isSliding={isSliding}
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
@@ -554,7 +551,6 @@ export const Controls: FC<Props> = ({
|
||||
handleSliderChange={handleSliderChange}
|
||||
handleTouchStart={handleTouchStart}
|
||||
handleTouchEnd={handleTouchEnd}
|
||||
seekTo={seekTo}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
|
||||
@@ -22,15 +22,12 @@ interface TrickplayBubbleProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
/** Chapter name at the scrubbed position, if any. */
|
||||
chapterName?: string | null;
|
||||
}
|
||||
|
||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterName,
|
||||
}) => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
@@ -39,30 +36,18 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
||||
|
||||
// Slightly larger preview than before (scale 1.6 vs old 1.4) to give the
|
||||
// overlay text more room and feel closer to the Jellyfin web style.
|
||||
const previewScale = 1.6;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -62,
|
||||
// Sit just above the slider — high enough not to overlap the
|
||||
// progress bar, low enough to feel anchored to the thumb.
|
||||
bottom: 0,
|
||||
paddingTop: 12,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 5,
|
||||
width: tileWidth * 1.5,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
// Bring the bubble in front of the player title / overlays.
|
||||
zIndex: 999,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -70,7 +55,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
alignSelf: "center",
|
||||
transform: [{ scale: previewScale }],
|
||||
transform: [{ scale: 1.4 }],
|
||||
borderRadius: 5,
|
||||
}}
|
||||
className='bg-neutral-800 overflow-hidden'
|
||||
@@ -90,51 +75,17 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
{/*
|
||||
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
|
||||
* faded) above the timestamp (small, bold). Sits on top of the
|
||||
* trickplay frame inside the same overflow:hidden container so it
|
||||
* always stays within the bubble bounds.
|
||||
*/}
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
bottom: 3,
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
maxWidth: tileWidth - 8,
|
||||
}}
|
||||
>
|
||||
{chapterName ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 7,
|
||||
opacity: 0.85,
|
||||
lineHeight: 9,
|
||||
}}
|
||||
>
|
||||
{chapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 8,
|
||||
fontWeight: "600",
|
||||
lineHeight: 10,
|
||||
}}
|
||||
>
|
||||
{timeStr}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 30,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,21 +74,6 @@ export function useVideoSlider({
|
||||
[seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
|
||||
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
|
||||
// during a real slide and would carry stale state on a tap-to-seek.
|
||||
const seekTo = useCallback(
|
||||
(value: number) => {
|
||||
const seekValue = Math.max(0, Math.floor(value));
|
||||
progress.value = seekValue;
|
||||
seek(seekValue);
|
||||
if (isPlaying) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[seek, play, progress, isPlaying],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
// Convert ms to ticks for trickplay
|
||||
@@ -111,6 +96,5 @@ export function useVideoSlider({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
@@ -15,12 +16,27 @@ export const useDownloadedFileOpener = () => {
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const downloadedItem = getDownloadedItemById(item.Id);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
|
||||
if (downloadedItem?.userData?.audioStreamIndex !== undefined) {
|
||||
queryParams.set(
|
||||
"audioIndex",
|
||||
downloadedItem.userData.audioStreamIndex.toString(),
|
||||
);
|
||||
}
|
||||
if (downloadedItem?.userData?.subtitleStreamIndex !== undefined) {
|
||||
queryParams.set(
|
||||
"subtitleIndex",
|
||||
downloadedItem.userData.subtitleStreamIndex.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,17 +12,11 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ExtraQueryOptions = {
|
||||
gcTime?: number;
|
||||
staleTime?: number;
|
||||
};
|
||||
|
||||
export const useItemQuery = (
|
||||
itemId: string | undefined,
|
||||
isOffline?: boolean,
|
||||
fields?: ItemFields[],
|
||||
excludeFields?: ItemFields[],
|
||||
queryOptions?: ExtraQueryOptions,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -59,6 +53,5 @@ export const useItemQuery = (
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
networkMode: "always",
|
||||
...queryOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -186,6 +186,20 @@ export const usePlaybackManager = ({
|
||||
: playedPercentage,
|
||||
},
|
||||
},
|
||||
// Sync selected audio/subtitle tracks so next playback resumes with
|
||||
// the same tracks the user had active — but only for non-transcoded
|
||||
// downloads where the user can freely switch tracks.
|
||||
userData: localItem.userData.isTranscoded
|
||||
? localItem.userData
|
||||
: {
|
||||
...localItem.userData,
|
||||
audioStreamIndex:
|
||||
playbackProgressInfo.AudioStreamIndex ??
|
||||
localItem.userData.audioStreamIndex,
|
||||
subtitleStreamIndex:
|
||||
playbackProgressInfo.SubtitleStreamIndex ??
|
||||
localItem.userData.subtitleStreamIndex,
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
|
||||
@@ -177,9 +177,6 @@ export const useAddToWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
@@ -238,9 +235,6 @@ export const useRemoveFromWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
|
||||
@@ -171,11 +171,7 @@ final class MPVLayerRenderer {
|
||||
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||
// This is better for PiP as subtitles are baked into the video
|
||||
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
|
||||
#if targetEnvironment(simulator)
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
|
||||
#else
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||
#endif
|
||||
|
||||
// Hardware decoding with VideoToolbox
|
||||
// On simulator, use software decoding since VideoToolbox is not available
|
||||
@@ -220,27 +216,20 @@ final class MPVLayerRenderer {
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = nil
|
||||
|
||||
if let handle = self.mpv {
|
||||
self.mpv = nil
|
||||
// Remove the wakeup callback synchronously while `self` is still
|
||||
// alive so it can never fire against a deallocated instance.
|
||||
queue.sync { [weak self] in
|
||||
guard let self, let handle = self.mpv else { return }
|
||||
|
||||
mpv_set_wakeup_callback(handle, nil, nil)
|
||||
// Destroy mpv OFF the main thread. mpv_terminate_destroy() blocks
|
||||
// until all mpv threads (including the vo_avfoundation output thread)
|
||||
// are joined, and that teardown needs the main run loop to finish.
|
||||
// Calling it via queue.sync from deinit (main thread) deadlocks/freezes
|
||||
// the UI. queue.async only references `handle`, never `self`.
|
||||
queue.async {
|
||||
mpv_terminate_destroy(handle)
|
||||
}
|
||||
mpv_terminate_destroy(handle)
|
||||
self.mpv = nil
|
||||
}
|
||||
|
||||
let layer = self.displayLayer
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if #available(iOS 18.0, *) {
|
||||
layer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||
} else {
|
||||
layer.flushAndRemoveImage()
|
||||
self.displayLayer.flushAndRemoveImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPVKit for Expo'
|
||||
s.description = 'MPVKit for Expo'
|
||||
s.author = 'mpvkit'
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit'
|
||||
s.dependency 'MPVKit-GPL'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
'VALID_ARCHS' => 'arm64',
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.user_target_xcconfig = {
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
|
||||
@@ -150,16 +150,6 @@ final class PiPController: NSObject {
|
||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: 0)
|
||||
}
|
||||
sampleBufferDisplayLayer?.controlTimebase = nil
|
||||
timebase = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
15
package.json
15
package.json
@@ -15,7 +15,6 @@
|
||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
@@ -34,7 +33,7 @@
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
@@ -71,14 +70,14 @@
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "2.16.2",
|
||||
"lodash": "4.17.23",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
@@ -118,16 +117,16 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@react-native-community/cli": "20.1.1",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/react": "19.1.17",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.7",
|
||||
"expo-doctor": "1.17.14",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"lint-staged": "16.2.7",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
|
||||
@@ -5,13 +5,10 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean;
|
||||
snapPoints?: (string | number)[];
|
||||
@@ -76,25 +73,6 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "android") return;
|
||||
|
||||
const onBackPress = () => {
|
||||
if (isVisible) {
|
||||
hideModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
onBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [isVisible, hideModal]);
|
||||
|
||||
const value = {
|
||||
showModal,
|
||||
hideModal,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"username_placeholder": "Benutzername",
|
||||
"password_placeholder": "Passwort",
|
||||
"login_button": "Anmelden",
|
||||
"quick_connect": "Quick Connect",
|
||||
"quick_connect": "Schnellverbindung",
|
||||
"enter_code_to_login": "Gib den Code {{code}} ein, um dich anzumelden",
|
||||
"failed_to_initiate_quick_connect": "Fehler beim Initiieren der Schnellverbindung",
|
||||
"got_it": "Verstanden",
|
||||
@@ -30,48 +30,48 @@
|
||||
"connect_button": "Verbinden",
|
||||
"previous_servers": "Vorherige Server",
|
||||
"clear_button": "Löschen",
|
||||
"swipe_to_remove": "Wischen, um zu entfernen",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Nach lokalen Servern suchen",
|
||||
"searching": "Suche...",
|
||||
"servers": "Server",
|
||||
"saved": "Gespeichert",
|
||||
"session_expired": "Sitzung abgelaufen",
|
||||
"please_login_again": "Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.",
|
||||
"remove_saved_login": "Gespeicherte Zugangsdaten entfernen",
|
||||
"remove_saved_login_description": "Hiermit werden ihre gespeicherten Zugangsdaten für diesen Server entfernt. Sie müssen sich dann erneut anmelden.",
|
||||
"accounts_count": "{{count}} Konten",
|
||||
"select_account": "Konto auswählen",
|
||||
"add_account": "Konto hinzufügen",
|
||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt."
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Konto speichern",
|
||||
"save_for_later": "Dieses Konto speichern",
|
||||
"security_option": "Sicherheitseinstellung",
|
||||
"no_protection": "Keine",
|
||||
"no_protection_desc": "Schnellanmeldung ohne Authentifizierung",
|
||||
"pin_code": "PIN",
|
||||
"pin_code_desc": "4-stellige PIN bei Konto-Wechsel erforderlich",
|
||||
"password": "Passwort wiederholen",
|
||||
"password_desc": "Passwort bei Konto-Wechsel erforderlich",
|
||||
"save_button": "Speichern",
|
||||
"cancel_button": "Abbrechen"
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "PIN eingeben",
|
||||
"enter_pin_for": "PIN für {{username}} eingeben",
|
||||
"enter_4_digits": "4 Ziffern eingeben",
|
||||
"invalid_pin": "Ungültige PIN",
|
||||
"setup_pin": "PIN festlegen",
|
||||
"confirm_pin": "PIN bestätigen",
|
||||
"pins_dont_match": "PIN stimmt nicht überein",
|
||||
"forgot_pin": "PIN vergessen?",
|
||||
"forgot_pin_desc": "Ihre gespeicherten Zugangsdaten werden entfernt"
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Passwort eingeben",
|
||||
"enter_password_for": "Passwort für {{username}} eingeben",
|
||||
"invalid_password": "Ungültiges Passwort"
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Überprüfe Serververbindung...",
|
||||
@@ -87,7 +87,7 @@
|
||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"next_up": "Als nächstes",
|
||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
"suggested_movies": "Empfohlene Filme",
|
||||
"suggested_episodes": "Empfohlene Episoden",
|
||||
@@ -120,36 +120,36 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
||||
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Netzwerk",
|
||||
"local_network": "Lokales Netzwerk",
|
||||
"auto_switch_enabled": "Zuhause automatisch wechseln",
|
||||
"auto_switch_description": "Im WLAN Zuhause automatisch zu lokaler URL wechseln",
|
||||
"local_url": "Lokale URL",
|
||||
"local_url_hint": "Lokale Server-URL eingeben (zB. http://192.168.1.100:8096)",
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Private WLAN-Netze",
|
||||
"add_current_network": "{{ssid}} hinzufügen",
|
||||
"not_connected_to_wifi": "Nicht mit WLAN verbunden",
|
||||
"no_networks_configured": "Keine Netzwerke konfiguriert",
|
||||
"add_network_hint": "Füge dein privates WLAN-Netz hinzu um automatischen Wechsel zu aktivieren",
|
||||
"current_wifi": "Aktuelles WLAN-Netz",
|
||||
"using_url": "Verwendet",
|
||||
"local": "Lokale URL",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"current_server": "Aktueller Server",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Aktive URL",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"network_added": "Netzwerk hinzugefügt",
|
||||
"network_already_added": "Netzwerk bereits hinzugefügt",
|
||||
"no_wifi_connected": "Nicht mit WLAN verbunden",
|
||||
"permission_denied": "Standortberechtigung nicht verfügbar",
|
||||
"permission_denied_explanation": "Standortberechtigung ist nötig um WLAN-Netze für den automatischen Wechsel zu erkennen. Bitte in den Einstellungen aktivieren."
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Benutzerinformationen",
|
||||
@@ -159,82 +159,82 @@
|
||||
"app_version": "App-Version"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Quick Connect",
|
||||
"authorize_button": "Quick Connect autorisieren",
|
||||
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
|
||||
"success": "Erfolgreich verbunden",
|
||||
"quick_connect_autorized": "Quick Connect autorisiert",
|
||||
"quick_connect_title": "Schnellverbindung",
|
||||
"authorize_button": "Schnellverbindung autorisieren",
|
||||
"enter_the_quick_connect_code": "Gib den Schnellverbindungscode ein...",
|
||||
"success": "Erfolg",
|
||||
"quick_connect_autorized": "Schnellverbindung autorisiert",
|
||||
"error": "Fehler",
|
||||
"invalid_code": "Ungültiger Code",
|
||||
"authorize": "Autorisieren"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Mediensteuerung",
|
||||
"forward_skip_length": "Vorspullänge",
|
||||
"rewind_length": "Rückspullänge",
|
||||
"forward_skip_length": "Vorspulzeit",
|
||||
"rewind_length": "Rückspulzeit",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gestensteuerung",
|
||||
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
|
||||
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet sind um zu überspringen",
|
||||
"left_side_brightness": "Helligkeitsregler Links",
|
||||
"left_side_brightness_description": "Links nach oben/unten wischen um Helligkeit anzupassen",
|
||||
"right_side_volume": "Lautstärkeregler Rechts",
|
||||
"right_side_volume_description": "Rechts nach oben/unten wischen um Lautstärke anzupassen",
|
||||
"hide_volume_slider": "Lautstärkeregler ausblenden",
|
||||
"hide_volume_slider_description": "Lautstärkeregler im Videoplayer ausblenden",
|
||||
"hide_brightness_slider": "Helligkeitsregler ausblenden",
|
||||
"hide_brightness_slider_description": "Helligkeitsregler im Videoplayer ausblenden"
|
||||
"horizontal_swipe_skip": "Horizontales Wischen zum Überspringen",
|
||||
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet werden um zu überspringen",
|
||||
"left_side_brightness": "Helligkeitskontrolle der linken Seite",
|
||||
"left_side_brightness_description": "Wischen Sie auf der linken Seite nach oben/runter, um die Helligkeit anzupassen",
|
||||
"right_side_volume": "Lautstärkeregelung der rechten Seite",
|
||||
"right_side_volume_description": "Auf der rechten Seite nach oben/unten wischen, um Lautstärke anzupassen",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Audiospur aus dem vorherigen Element übernehmen",
|
||||
"set_audio_track": "Audiospur aus dem vorherigen Element festlegen",
|
||||
"audio_language": "Audio-Sprache",
|
||||
"audio_hint": "Standardsprache für Audio auswählen.",
|
||||
"audio_hint": "Wähl die Standardsprache für Audio aus.",
|
||||
"none": "Keine",
|
||||
"language": "Sprache",
|
||||
"transcode_mode": {
|
||||
"title": "Audio-Transcoding",
|
||||
"description": "Legt fest, wie Surround-Audio (7.1, TrueHD, DTS-HD) behandelt wird",
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Stereo erzwingen",
|
||||
"5_1": "5.1 erlauben",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Untertitel",
|
||||
"subtitle_hint": "Untertitel-Erscheinungsbild und Verhalten konfigurieren.",
|
||||
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
|
||||
"subtitle_language": "Untertitel-Sprache",
|
||||
"subtitle_mode": "Untertitel-Modus",
|
||||
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element übernehmen",
|
||||
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen",
|
||||
"subtitle_size": "Untertitel-Größe",
|
||||
"none": "Keine",
|
||||
"language": "Sprache",
|
||||
"loading": "Lädt",
|
||||
"modes": {
|
||||
"Default": "Standard",
|
||||
"Smart": "Smart",
|
||||
"Smart": "Intelligent",
|
||||
"Always": "Immer",
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungene"
|
||||
"OnlyForced": "Nur erzwungen"
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"outline_thickness": "Umriss Dicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"bold_text": "Fettgedruckter Text",
|
||||
"outline_opacity": "Kontur-Deckkraft",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Rotbraun",
|
||||
"Maroon": "Marotte",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Magenta",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
@@ -251,29 +251,29 @@
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Untertitelfarbe",
|
||||
"subtitle_background_color": "Hintergrundfarbe",
|
||||
"subtitle_font": "Untertitel-Schriftart",
|
||||
"ksplayer_title": "KSPlayer Einstellungen",
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"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"
|
||||
"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": "Videoplayer",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
@@ -282,7 +282,7 @@
|
||||
"video_orientation": "Videoausrichtung",
|
||||
"orientation": "Ausrichtung",
|
||||
"orientations": {
|
||||
"DEFAULT": "Geräteausrichtung folgen",
|
||||
"DEFAULT": "Standard",
|
||||
"ALL": "Alle",
|
||||
"PORTRAIT": "Hochformat",
|
||||
"PORTRAIT_UP": "Hochformat oben",
|
||||
@@ -294,54 +294,54 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player": "Video player",
|
||||
"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)",
|
||||
"show_large_home_carousel": "Zeige Großes Heimkarussell (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität",
|
||||
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
|
||||
"auto_play_next_episode": "Automatisch nächste Episode abspielen",
|
||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max. automatische Wiedergabe Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"playback_title": "Wiedergabe",
|
||||
"playback_description": "Konfigurieren, wie Musik abgespielt wird.",
|
||||
"prefer_downloaded": "Bevorzuge heruntergeladene Titel",
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatisches Caching anstehender Titel für bessere Wiedergabe.",
|
||||
"lookahead_enabled": "Look-Ahead Caching aktivieren",
|
||||
"lookahead_count": "Titel vorher in den Cache laden",
|
||||
"max_cache_size": "Maximale Cache-Größe"
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"plugins_title": "Erweiterungen",
|
||||
"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",
|
||||
"jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.",
|
||||
"server_url": "Server Adresse",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Passwort",
|
||||
"password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben",
|
||||
"login_button": "Anmelden",
|
||||
"total_media_requests": "Gesamtanfragen",
|
||||
"movie_quota_limit": "Film-Anfragelimit",
|
||||
"movie_quota_days": "Film-Anfragetagelimit",
|
||||
"tv_quota_limit": "Serien-Anfragelimit",
|
||||
"tv_quota_days": "Serien-Anfragetagelimit",
|
||||
"reset_jellyseerr_config_button": "Seerr-Konfiguration zurücksetzen",
|
||||
"movie_quota_days": "Film-Anfragetage",
|
||||
"tv_quota_limit": "TV-Anfragelimit",
|
||||
"tv_quota_days": "TV-Anfragetage",
|
||||
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
|
||||
"unlimited": "Unlimitiert",
|
||||
"plus_n_more": "+{{n}} weitere",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Standard",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Stimmenanzahl und Durchschnitt",
|
||||
@@ -352,71 +352,71 @@
|
||||
"enable_marlin_search": "Aktiviere Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "URL für den Marlin Server eingeben. Die URL sollte http oder https enthalten und optional den Port.",
|
||||
"marlin_search_hint": "Gib die URL für den Marlin Server ein. Die URL sollte http oder https enthalten und optional den Port.",
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats aktivieren",
|
||||
"disable_streamystats": "Streamystats deaktivieren",
|
||||
"enable_search": "Zum Suchen verwenden",
|
||||
"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": "URL für den Streamystats-Server eingeben.",
|
||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||
"save_button": "Speichern",
|
||||
"save": "Gespeichert",
|
||||
"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": "Startseitenbereiche",
|
||||
"enable_movie_recommendations": "Filmempfehlungen",
|
||||
"enable_series_recommendations": "Serienempfehlungen",
|
||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||
"hide_watchlists_tab": "Merklisten-Tab ausblenden",
|
||||
"home_sections_hint": "Zeige personalisierte Empfehlungen und empfohlene Merklisten von Streamystats auf der Startseite.",
|
||||
"recommended_movies": "Empfohlene Filme",
|
||||
"recommended_series": "Empfohlene Serien",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert",
|
||||
"disabled": "Streamystats deaktiviert"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||
"watchlist_button": "Merklisten-Integration umschalten"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Speicher",
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Gerät {{availableSpace}}%",
|
||||
"size_used": "{{used}} von {{total}} genutzt",
|
||||
"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",
|
||||
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
|
||||
"downloaded_songs_size": "{{size}} heruntergeladen",
|
||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht"
|
||||
"size_used": "{{used}} von {{total}} benutzt",
|
||||
"delete_all_downloaded_files": "Alle Downloads löschen",
|
||||
"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",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Einführung",
|
||||
"show_intro": "Einführung anzeigen",
|
||||
"reset_intro": "Einführung zurücksetzen"
|
||||
"title": "Intro ",
|
||||
"show_intro": "Show intro",
|
||||
"reset_intro": "Reset intro"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"export_logs": "Logs exportieren",
|
||||
"click_for_more_info": "Für mehr Informationen klicken",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Keine Logs verfügbar",
|
||||
"delete_all_logs": "Alle Logs löschen"
|
||||
@@ -438,21 +438,21 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Serien",
|
||||
"tvseries": "TV-Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart",
|
||||
"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_tvseries_button": "Alle TV-Serien löschen",
|
||||
"delete_all_button": "Alles löschen",
|
||||
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
||||
"delete_all_other_media_button": "Andere Medien löschen",
|
||||
"active_download": "Aktiver Download",
|
||||
"no_active_downloads": "Keine aktiven Downloads",
|
||||
"active_downloads": "Aktive Downloads",
|
||||
"new_app_version_requires_re_download": "Neue App-Version erfordert erneutes Herunterladen",
|
||||
"new_app_version_requires_re_download": "Die neue App-Version erfordert das erneute Herunterladen.",
|
||||
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||
"back": "Zurück",
|
||||
"delete": "Löschen",
|
||||
@@ -463,8 +463,8 @@
|
||||
"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_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
@@ -486,7 +486,7 @@
|
||||
"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",
|
||||
"go_to_downloads": "Gehe zu den Downloads",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -499,18 +499,18 @@
|
||||
"subtitle": "Untertitel",
|
||||
"play": "Abspielen",
|
||||
"none": "Keine",
|
||||
"track": "Spur",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Entfernen",
|
||||
"next": "Weiter",
|
||||
"back": "Zurück",
|
||||
"continue": "Fortsetzen",
|
||||
"verifying": "Verifiziere..."
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Suchen...",
|
||||
"search": "Suche...",
|
||||
"x_items": "{{count}} Elemente",
|
||||
"library": "Bibliothek",
|
||||
"discover": "Entdecken",
|
||||
@@ -521,33 +521,33 @@
|
||||
"episodes": "Episoden",
|
||||
"collections": "Sammlungen",
|
||||
"actors": "Schauspieler",
|
||||
"artists": "Künstler",
|
||||
"albums": "Alben",
|
||||
"songs": "Titel",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film anfragen",
|
||||
"request_series": "Serie anfragen",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recent_requests": "Kürzlich angefragt",
|
||||
"plex_watchlist": "Plex Merkliste",
|
||||
"trending": "Beliebt",
|
||||
"plex_watchlist": "Plex Watchlist",
|
||||
"trending": "In den Trends",
|
||||
"popular_movies": "Beliebte Filme",
|
||||
"movie_genres": "Film-Genres",
|
||||
"upcoming_movies": "Kommende Filme",
|
||||
"studios": "Studios",
|
||||
"popular_tv": "Beliebte Serien",
|
||||
"tv_genres": "Serien-Genres",
|
||||
"upcoming_tv": "Kommende Serien",
|
||||
"networks": "Sender",
|
||||
"popular_tv": "Beliebte TV-Serien",
|
||||
"tv_genres": "TV-Serien-Genres",
|
||||
"upcoming_tv": "Kommende TV-Serien",
|
||||
"networks": "Netzwerke",
|
||||
"tmdb_movie_keyword": "TMDB Film-Schlüsselwort",
|
||||
"tmdb_movie_genre": "TMDB Film-Genre",
|
||||
"tmdb_tv_keyword": "TMDB Serien-Schlüsselwort",
|
||||
"tmdb_tv_genre": "TMDB Serien-Genre",
|
||||
"tmdb_tv_keyword": "TMDB TV-Serien-Schlüsselwort",
|
||||
"tmdb_tv_genre": "TMDB TV-Serien-Genre",
|
||||
"tmdb_search": "TMDB Suche",
|
||||
"tmdb_studio": "TMDB Studio",
|
||||
"tmdb_network": "TMDB Netzwerk",
|
||||
"tmdb_movie_streaming_services": "TMDB Film-Streaming-Dienste",
|
||||
"tmdb_tv_streaming_services": "TMDB Serien-Streaming-Dienste"
|
||||
"tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste"
|
||||
},
|
||||
"library": {
|
||||
"no_results": "Keine Ergebnisse",
|
||||
@@ -572,7 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Jahre",
|
||||
"sort_by": "Sortieren nach",
|
||||
"filter_by": "Filtern nach",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sortierreihenfolge",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -585,7 +585,7 @@
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"noDataTitle": "Noch keine Favoriten",
|
||||
"noData": "Elemente als Favoriten markieren, um sie hier anzuzeigen."
|
||||
"noData": "Markiere Elemente als Favoriten, damit sie hier für einen schnellen Zugriff angezeigt werden."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Keine Links"
|
||||
@@ -593,7 +593,7 @@
|
||||
"player": {
|
||||
"error": "Fehler",
|
||||
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Überprüf die Logs in den Einstellungen.",
|
||||
"client_error": "Client-Fehler",
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
@@ -602,17 +602,17 @@
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsetzen",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
||||
"downloaded_file_title": "Diese Datei wurde heruntergeladen",
|
||||
"downloaded_file_message": "Möchten Sie die heruntergeladene Datei abspielen?",
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nein",
|
||||
"downloaded_file_cancel": "Abbrechen"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
"no_items_to_display": "Keine Elemente",
|
||||
"no_items_to_display": "Keine Elemente zum Anzeigen",
|
||||
"cast_and_crew": "Besetzung und Crew",
|
||||
"series": "Serien",
|
||||
"seasons": "Staffeln",
|
||||
@@ -630,7 +630,7 @@
|
||||
"subtitles": "Untertitel",
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"appeared_in": "Erschien in",
|
||||
"appeared_in": "Erschienen in",
|
||||
"could_not_load_item": "Konnte Element nicht laden",
|
||||
"none": "Keine",
|
||||
"download": {
|
||||
@@ -639,13 +639,13 @@
|
||||
"download_episode": "Episode herunterladen",
|
||||
"download_movie": "Film herunterladen",
|
||||
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||
"download_unwatched_only": "Nur Ungesehene",
|
||||
"download_unwatched_only": "Nur unbeobachtete",
|
||||
"download_button": "Herunterladen"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Nächste",
|
||||
"previous": "Vorherige",
|
||||
"next": "Nächster",
|
||||
"previous": "Vorheriger",
|
||||
"coming_soon": "Demnächst",
|
||||
"on_now": "Jetzt",
|
||||
"shows": "Serien",
|
||||
@@ -658,10 +658,10 @@
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
"whats_wrong": "Was stimmt nicht?",
|
||||
"issue_type": "Art des Problems",
|
||||
"select_an_issue": "Wähle die Art des Problems aus",
|
||||
"types": "Problem-Arten",
|
||||
"whats_wrong": "Hast du Probleme?",
|
||||
"issue_type": "Fehlerart",
|
||||
"select_an_issue": "Wähle einen Fehlerart aus",
|
||||
"types": "Arten",
|
||||
"describe_the_issue": "(optional) Beschreibe das Problem",
|
||||
"submit_button": "Absenden",
|
||||
"report_issue_button": "Fehler melden",
|
||||
@@ -671,7 +671,7 @@
|
||||
"cast": "Besetzung",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
"original_title": "Originaltitel",
|
||||
"original_title": "Original Titel",
|
||||
"series_type": "Serien Typ",
|
||||
"release_dates": "Veröffentlichungsdaten",
|
||||
"first_air_date": "Erstausstrahlungsdatum",
|
||||
@@ -687,10 +687,10 @@
|
||||
"request_as": "Anfragen als",
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Qualitätsprofil",
|
||||
"root_folder": "Stammverzeichnis",
|
||||
"season_all": "Staffeln (alle)",
|
||||
"root_folder": "Root-Ordner",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "Staffel {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Episoden",
|
||||
"number_episodes": "{{episode_number}} Folgen",
|
||||
"born": "Geboren",
|
||||
"appearances": "Auftritte",
|
||||
"approve": "Genehmigen",
|
||||
@@ -698,9 +698,9 @@
|
||||
"requested_by": "Angefragt von {{user}}",
|
||||
"unknown_user": "Unbekannter Nutzer",
|
||||
"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.",
|
||||
"failed_to_test_jellyseerr_server_url": "Fehler beim Test der Seerr-Server-URL",
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"failed_to_test_jellyseerr_server_url": "Fehler beim Testen der Jellyseerr-Server-URL",
|
||||
"issue_submitted": "Problem eingereicht!",
|
||||
"requested_item": "{{item}} angefragt!",
|
||||
"you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen",
|
||||
@@ -715,131 +715,131 @@
|
||||
"home": "Startseite",
|
||||
"search": "Suche",
|
||||
"library": "Bibliothek",
|
||||
"custom_links": "Links",
|
||||
"custom_links": "Benutzerdefinierte Links",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Vorschläge",
|
||||
"albums": "Alben",
|
||||
"artists": "Künstler",
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "Titel"
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
"all": "All"
|
||||
},
|
||||
"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",
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Top-Tracks abspielen",
|
||||
"no_suggestions": "Keine Vorschläge verfügbar",
|
||||
"no_albums": "Keine Alben gefunden",
|
||||
"no_artists": "Keine Künstler gefunden",
|
||||
"no_playlists": "Keine Playlists gefunden",
|
||||
"album_not_found": "Album nicht gefunden",
|
||||
"artist_not_found": "Künstler nicht gefunden",
|
||||
"playlist_not_found": "Playlist nicht gefunden",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Als Nächstes wiedergeben",
|
||||
"add_to_queue": "Zur Warteschlange hinzufügen",
|
||||
"add_to_playlist": "Zur Playlist hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloading": "Wird heruntergeladen...",
|
||||
"cached": "Gecached",
|
||||
"delete_download": "Download löschen",
|
||||
"delete_cache": "Aus dem Cache löschen",
|
||||
"go_to_artist": "Zum Künstler gehen",
|
||||
"go_to_album": "Zum Album gehen",
|
||||
"add_to_favorites": "Zu Favoriten hinzufügen",
|
||||
"remove_from_favorites": "Aus Favoriten entfernen",
|
||||
"remove_from_playlist": "Aus Playlist entfernen"
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Playlist erstellen",
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Playlist Name eingeben",
|
||||
"create": "Erstellen",
|
||||
"search_playlists": "Playlisten durchsuchen...",
|
||||
"added_to": "Zu {{name}} hinzugefügt",
|
||||
"added": "Zur Playlist hinzugefügt",
|
||||
"removed_from": "Aus {{name}} entfernt",
|
||||
"removed": "Aus Playlist entfernt",
|
||||
"created": "Playlist erstellt",
|
||||
"create_new": "Neue Playlist erstellen",
|
||||
"failed_to_add": "Fehler beim Hinzufügen zur Playlist",
|
||||
"failed_to_remove": "Fehler beim Entfernen aus der Playlist",
|
||||
"failed_to_create": "Fehler beim Erstellen der Playlist",
|
||||
"delete_playlist": "Playlist löschen",
|
||||
"delete_confirm": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"deleted": "Playlist gelöscht",
|
||||
"failed_to_delete": "Fehler beim Löschen der Playlist"
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sortieren nach",
|
||||
"alphabetical": "Alphabetisch",
|
||||
"date_created": "Erstellungsdatum"
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Merklisten",
|
||||
"my_watchlists": "Meine Merklisten",
|
||||
"public_watchlists": "Öffentliche Merklisten",
|
||||
"create_title": "Merkliste erstellen",
|
||||
"edit_title": "Merkliste bearbeiten",
|
||||
"create_button": "Merkliste erstellen",
|
||||
"save_button": "Änderungen speichern",
|
||||
"delete_button": "Löschen",
|
||||
"remove_button": "Entfernen",
|
||||
"cancel_button": "Abbrechen",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Merklistenname eingeben",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Beschreibung eingeben (optional)",
|
||||
"is_public_label": "Öffentliche Merkliste",
|
||||
"is_public_description": "Anderen erlauben diese Merkliste anzusehen",
|
||||
"allowed_type_label": "Inhaltstyp",
|
||||
"sort_order_label": "Standard-Sortierreihenfolge",
|
||||
"empty_title": "Keine Merklisten",
|
||||
"empty_description": "Erstelle deine erste Merkliste um deine Medien zu organisieren",
|
||||
"empty_watchlist": "Diese Merkliste ist leer",
|
||||
"empty_watchlist_hint": "Füge Elemente aus deiner Bibliothek zu dieser Merkliste hinzu",
|
||||
"not_configured_title": "Streamystats nicht konfiguriert",
|
||||
"not_configured_description": "Streamystats in den Einstellungen konfigurieren, um Merklisten zu verwenden",
|
||||
"go_to_settings": "Gehe zu Einstellungen",
|
||||
"add_to_watchlist": "Zur Merkliste hinzufügen",
|
||||
"remove_from_watchlist": "Von Merkliste entfernen",
|
||||
"select_watchlist": "Merkliste auswählen",
|
||||
"create_new": "Neue Merkliste erstellen",
|
||||
"item": "Element",
|
||||
"items": "Elemente",
|
||||
"public": "Öffentlich",
|
||||
"private": "Privat",
|
||||
"you": "Du",
|
||||
"by_owner": "Von einem anderen Benutzer",
|
||||
"not_found": "Merkliste nicht gefunden",
|
||||
"delete_confirm_title": "Merkliste löschen",
|
||||
"delete_confirm_message": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"remove_item_title": "Von Merkliste entfernen",
|
||||
"remove_item_message": "\"{{name}}\" von dieser Merkliste entfernen?",
|
||||
"loading": "Lade Merklisten...",
|
||||
"no_compatible_watchlists": "Keine kompatiblen Merklisten",
|
||||
"create_one_first": "Erstelle eine Merkliste, welche diesen Inhaltstyp akzeptiert"
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Wiedergabegeschwindigkeit",
|
||||
"apply_to": "Anwenden auf",
|
||||
"speed": "Geschwindigkeit",
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Nur hier",
|
||||
"show": "Nur diese Serie",
|
||||
"all": "Alle (Standard)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,12 +610,6 @@
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
"no_items_to_display": "No Items to Display",
|
||||
|
||||
@@ -39,39 +39,39 @@
|
||||
"please_login_again": "Su sesión guardada ha caducado. Por favor, inicie sesión de nuevo.",
|
||||
"remove_saved_login": "Eliminar inicio de sesión guardado",
|
||||
"remove_saved_login_description": "Esto eliminará tus credenciales guardadas para este servidor. Tendrás que volver a introducir tu nombre de usuario y contraseña la próxima vez.",
|
||||
"accounts_count": "{{count}} cuentas",
|
||||
"select_account": "Seleccione una cuenta",
|
||||
"add_account": "Añadir cuenta",
|
||||
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}."
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Guardar Cuenta",
|
||||
"save_for_later": "Guardar esta cuenta",
|
||||
"security_option": "Opciones de seguridad",
|
||||
"no_protection": "Sin Protección",
|
||||
"no_protection_desc": "Inicio de sesión rápido sin autenticación",
|
||||
"pin_code": "Código PIN",
|
||||
"pin_code_desc": "PIN de 4 dígitos requerido al cambiar",
|
||||
"password": "Vuelva a introducir la contraseña",
|
||||
"password_desc": "Contraseña requerida al cambiar",
|
||||
"save_button": "Guardar",
|
||||
"cancel_button": "Cancelar"
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Introduce el PIN",
|
||||
"enter_pin_for": "Introduzca el PIN para {{username}}",
|
||||
"enter_4_digits": "Introduce 4 dígitos",
|
||||
"invalid_pin": "PIN inválido",
|
||||
"setup_pin": "Configurar PIN",
|
||||
"confirm_pin": "Confirmar PIN",
|
||||
"pins_dont_match": "Los códigos PIN no coinciden",
|
||||
"forgot_pin": "¿Olvidó el PIN?",
|
||||
"forgot_pin_desc": "Sus credenciales guardadas serán eliminadas"
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Introduzca la contraseña",
|
||||
"enter_password_for": "Introduzca la contraseña para {{username}}",
|
||||
"invalid_password": "Contraseña inválida"
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Comprobando conexión con el servidor...",
|
||||
@@ -124,32 +124,32 @@
|
||||
"hide_remote_session_button": "Ocultar botón de sesión remota"
|
||||
},
|
||||
"network": {
|
||||
"title": "Cadena",
|
||||
"local_network": "Red local",
|
||||
"auto_switch_enabled": "Cambiar automáticamente en casa",
|
||||
"auto_switch_description": "Cambiar automáticamente a la URL local cuando se conecta a la WiFi de casa",
|
||||
"local_url": "URL local",
|
||||
"local_url_hint": "Introduzca la dirección de su servidor local (por ejemplo, http://192.168.1.100:8096)",
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Redes WiFi domésticas",
|
||||
"add_current_network": "Añadir \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "No está conectado a WiFi",
|
||||
"no_networks_configured": "No hay redes configuradas",
|
||||
"add_network_hint": "Añade tu red WiFi doméstica para activar el cambio automático",
|
||||
"current_wifi": "WiFi actual",
|
||||
"using_url": "Utilizando",
|
||||
"local": "URL local",
|
||||
"remote": "URL Remota",
|
||||
"not_connected": "Sin conexión",
|
||||
"current_server": "Servidor actual",
|
||||
"remote_url": "URL Remota",
|
||||
"active_url": "URL Activa",
|
||||
"not_configured": "Sin configurar",
|
||||
"network_added": "Red añadida",
|
||||
"network_already_added": "Red ya añadida",
|
||||
"no_wifi_connected": "Sin conexión a WiFi",
|
||||
"permission_denied": "Permiso de ubicación denegado",
|
||||
"permission_denied_explanation": "Se necesita el permiso de ubicación para detectar la red WiFi para cambiar automáticamente. Por favor, actívala en Configuración."
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Información de usuario",
|
||||
@@ -195,12 +195,12 @@
|
||||
"none": "Ninguno",
|
||||
"language": "Idioma",
|
||||
"transcode_mode": {
|
||||
"title": "Transcodificación de audio",
|
||||
"description": "Controla cómo el audio envolvente (7.1, TrueHD, DTS-HD) es manejado",
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Forzar salida estéreo",
|
||||
"5_1": "Permitir 5.1",
|
||||
"passthrough": "Directo"
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
@@ -259,16 +259,16 @@
|
||||
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
|
||||
},
|
||||
"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"
|
||||
"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": "Reproductor de vídeo",
|
||||
@@ -300,13 +300,13 @@
|
||||
"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)",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
||||
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||
"default_quality": "Calidad por defecto",
|
||||
"default_playback_speed": "Velocidad de reproducción predeterminada",
|
||||
"auto_play_next_episode": "Reproducir automáticamente el siguiente episodio",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
@@ -317,10 +317,10 @@
|
||||
"title": "Música",
|
||||
"playback_title": "Reproducir",
|
||||
"playback_description": "Configurar cómo se reproduce la música.",
|
||||
"prefer_downloaded": "Preferir las canciones descargadas",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"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",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "",
|
||||
"max_cache_size": "Tamaño máximo del caché"
|
||||
},
|
||||
@@ -399,7 +399,7 @@
|
||||
"size_used": "{{used}} de {{total}} usado",
|
||||
"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",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Activar Caché de Música",
|
||||
"clear_music_cache": "Borrar Caché de Música",
|
||||
"music_cache_size": "Caché {{Tamaño}}",
|
||||
@@ -504,10 +504,10 @@
|
||||
"delete": "Borrar",
|
||||
"ok": "Aceptar",
|
||||
"remove": "Eliminar",
|
||||
"next": "Siguiente",
|
||||
"back": "Atrás",
|
||||
"continue": "Continuar",
|
||||
"verifying": "Verificando..."
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Buscar...",
|
||||
@@ -753,8 +753,8 @@
|
||||
"downloaded": "Descargado",
|
||||
"downloading": "Descargando...",
|
||||
"cached": "En caché",
|
||||
"delete_download": "Eliminar descarga",
|
||||
"delete_cache": "Borrar del caché",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Ir al artista",
|
||||
"go_to_album": "Ir al álbum",
|
||||
"add_to_favorites": "Añadir a Favoritos",
|
||||
|
||||
@@ -305,8 +305,8 @@
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut",
|
||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
@@ -314,15 +314,15 @@
|
||||
"downloads_title": "Téléchargements"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"playback_title": "Lecture",
|
||||
"playback_description": "Configurer le mode de lecture de la musique.",
|
||||
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
|
||||
"caching_title": "Mise en cache",
|
||||
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
||||
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||
"max_cache_size": "Taille max de cache"
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
@@ -357,19 +357,19 @@
|
||||
"save_button": "Enregistrer",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
"refreshed": "Paramètres actualisés depuis le serveur"
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Activer Streamystats",
|
||||
"disable_streamystats": "Désactiver Streamystats",
|
||||
"enable_search": "Utiliser pour la recherche",
|
||||
"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": "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",
|
||||
"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": "Enregistrer",
|
||||
"features_title": "Fonctionnalités",
|
||||
"home_sections_title": "Sections de la page d´accueil",
|
||||
@@ -572,7 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Années",
|
||||
"sort_by": "Trier par",
|
||||
"filter_by": "Filtrer par",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Ordre de tri",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -719,127 +719,127 @@
|
||||
"favorites": "Favoris"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artistes",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "morceaux"
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Toutes"
|
||||
"all": "All"
|
||||
},
|
||||
"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",
|
||||
"play_top_tracks": "Jouer les pistes les plus populaires",
|
||||
"no_suggestions": "Pas de suggestion disponible",
|
||||
"no_albums": "Pas d'albums trouvés",
|
||||
"no_artists": "Pas d'artistes trouvé",
|
||||
"no_playlists": "Pas de playlists trouvées",
|
||||
"album_not_found": "Album introuvable",
|
||||
"artist_not_found": "Artiste introuvable",
|
||||
"playlist_not_found": "Playlist introuvable",
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Lecture suivante",
|
||||
"add_to_queue": "Ajouter à la file d'attente",
|
||||
"add_to_playlist": "Ajouter à la playlist",
|
||||
"download": "Télécharger",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloading": "Téléchargement en cours...",
|
||||
"cached": "En cache",
|
||||
"delete_download": "Supprimer un téléchargement",
|
||||
"delete_cache": "Supprimer du cache",
|
||||
"go_to_artist": "Voir l'artiste",
|
||||
"go_to_album": "Aller à l’album",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Retirer des favoris",
|
||||
"remove_from_playlist": "Retirer de la playlist"
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Créer une Playlist",
|
||||
"playlist_name": "Nom de la Playlist",
|
||||
"enter_name": "Entrer le nom de la playlist",
|
||||
"create": "Créer",
|
||||
"search_playlists": "Rechercher des playlists...",
|
||||
"added_to": "Ajouté à {{name}}",
|
||||
"added": "Ajouté à la playlist",
|
||||
"removed_from": "Retiré de {{name}}",
|
||||
"removed": "Retiré de la playlist",
|
||||
"created": "Playlist créée",
|
||||
"create_new": "Créer une nouvelle playlist",
|
||||
"failed_to_add": "Échec de l'ajout à la playlist",
|
||||
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||
"failed_to_create": "Échec de la suppression de la playlist",
|
||||
"delete_playlist": "Supprimer la playlist",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
||||
"deleted": "Playlist supprimée",
|
||||
"failed_to_delete": "Échec de la suppression de la playlist"
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Trier par",
|
||||
"alphabetical": "Ordre alphabétique",
|
||||
"date_created": "Date de création"
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Watchlist publique",
|
||||
"create_title": "Créer une Watchlist",
|
||||
"edit_title": "Modifier la Watchlist",
|
||||
"create_button": "Créer une Watchlist",
|
||||
"save_button": "Enregistrer les modifications",
|
||||
"delete_button": "Supprimer",
|
||||
"remove_button": "Retirer",
|
||||
"cancel_button": "Annuler",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "Entrer le nom de la playlist",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Entrez la description (facultatif)",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||
"allowed_type_label": "Type de contenu",
|
||||
"sort_order_label": "Ordre de tri par défaut",
|
||||
"empty_title": "Pas de Watchlists",
|
||||
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
||||
"empty_watchlist": "Cette liste de suivi est vide",
|
||||
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
||||
"not_configured_title": "Streamystats non configuré",
|
||||
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
||||
"go_to_settings": "Accédez aux Paramètres",
|
||||
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||
"select_watchlist": "Sélectionner la liste de suivi",
|
||||
"create_new": "Créer une Watchlist",
|
||||
"item": "médias",
|
||||
"items": "élément",
|
||||
"public": "Publique",
|
||||
"private": "Privée",
|
||||
"you": "Vous-même",
|
||||
"by_owner": "Par un autre utilisateur",
|
||||
"not_found": "Playlist introuvable",
|
||||
"delete_confirm_title": "Supprimer la Watchlist",
|
||||
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||
"remove_item_title": "Retirer de la Watchlist",
|
||||
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
||||
"loading": "Chargement des listes de suivi...",
|
||||
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
||||
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Vitesse de lecture",
|
||||
"apply_to": "Appliquer à",
|
||||
"speed": "Vitesse",
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Ce média uniquement",
|
||||
"show": "Cette série",
|
||||
"all": "Tous les médias (par défaut)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"search_for_local_servers": "Ricerca dei server locali",
|
||||
"searching": "Cercando...",
|
||||
"servers": "Server",
|
||||
"saved": "Salvato",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
@@ -137,7 +137,7 @@
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Sta utilizzando",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"connect_button": "Verbinden",
|
||||
"previous_servers": "vorige servers",
|
||||
"clear_button": "Wissen",
|
||||
"swipe_to_remove": "Swipe om te verwijderen.",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Zoek naar lokale servers",
|
||||
"searching": "Zoeken...",
|
||||
"servers": "Servers",
|
||||
@@ -40,38 +40,38 @@
|
||||
"remove_saved_login": "Opgeslagen login verwijderen",
|
||||
"remove_saved_login_description": "Hiermee worden uw opgeslagen gegevens voor deze server verwijderd. U moet uw gebruikersnaam en wachtwoord de volgende keer opnieuw invoeren.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Account selecteren",
|
||||
"add_account": "Account toevoegen",
|
||||
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd."
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Account opslaan",
|
||||
"save_for_later": "Dit account opslaan",
|
||||
"security_option": "Beveiligingsopties",
|
||||
"no_protection": "Geen beveiliging",
|
||||
"no_protection_desc": "Snelle login zonder authenticatie",
|
||||
"pin_code": "Pincode",
|
||||
"pin_code_desc": "4-cijferige pincode vereist bij wisselen",
|
||||
"password": "Wachtwoord opnieuw invoeren",
|
||||
"password_desc": "Wachtwoord vereist bij wisselen",
|
||||
"save_button": "Opslaan",
|
||||
"cancel_button": "Annuleren"
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Pincode invoeren",
|
||||
"enter_pin_for": "Pincode voor {{username}} invoeren",
|
||||
"enter_4_digits": "Voer 6 cijfers in",
|
||||
"invalid_pin": "Ongeldige pincode",
|
||||
"setup_pin": "Pincode instellen",
|
||||
"confirm_pin": "Pincode bevestigen",
|
||||
"pins_dont_match": "Pincodes komen niet overeen",
|
||||
"forgot_pin": "Pincode vergeten?",
|
||||
"forgot_pin_desc": "Je opgeslagen inloggegevens worden verwijderd"
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Voer wachtwoord in",
|
||||
"enter_password_for": "Voer wachtwoord voor {{username}} in",
|
||||
"invalid_password": "Ongeldig wachtwoord"
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Serververbinding controleren...",
|
||||
@@ -84,7 +84,7 @@
|
||||
"server_unreachable": "Server onbereikbaar",
|
||||
"server_unreachable_message": "Kon de server niet bereiken.\nControleer uw netwerkverbinding.",
|
||||
"oops": "Oeps!",
|
||||
"error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.",
|
||||
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
|
||||
"continue_watching": "Verder Kijken",
|
||||
"next_up": "Volgende",
|
||||
"continue_and_next_up": "Doorgaan & Volgende",
|
||||
@@ -124,32 +124,32 @@
|
||||
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
|
||||
},
|
||||
"network": {
|
||||
"title": "Netwerk",
|
||||
"local_network": "Lokaal netwerk",
|
||||
"auto_switch_enabled": "Automatisch wisselen wanneer thuis",
|
||||
"auto_switch_description": "Automatisch wisselen naar lokale URL wanneer verbonden met thuisnetwerk",
|
||||
"local_url": "Lokale URL",
|
||||
"local_url_hint": "Voer uw lokale serveradres in (bijv. http://192.168.1.100:8096)",
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Wi-Fi netwerken",
|
||||
"add_current_network": "Voeg \"{{ssid}} \" toe",
|
||||
"not_connected_to_wifi": "Niet verbonden met Wi-Fi",
|
||||
"no_networks_configured": "Geen netwerken geconfigureerd",
|
||||
"add_network_hint": "Voeg je thuisnetwerk toe om automatisch wisselen in te schakelen",
|
||||
"current_wifi": "Huidige Wi-Fi",
|
||||
"using_url": "Gebruik makend van",
|
||||
"local": "Lokale URL",
|
||||
"remote": "Externe URL",
|
||||
"not_connected": "Niet verbonden",
|
||||
"current_server": "Huidige Server",
|
||||
"remote_url": "Externe URL",
|
||||
"active_url": "Actieve URL",
|
||||
"not_configured": "Niet geconfigureerd",
|
||||
"network_added": "Netwerk toegevoegd",
|
||||
"network_already_added": "Netwerk reeds toegevoegd",
|
||||
"no_wifi_connected": "Niet verbonden met Wi-Fi",
|
||||
"permission_denied": "Locatie toestemming geweigerd",
|
||||
"permission_denied_explanation": "Locatie permissie is vereist om Wifi-netwerk te kunnen detecteren voor automatisch wisselen. Schakel het in via Instellingen."
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Gebruiker Info",
|
||||
@@ -195,11 +195,11 @@
|
||||
"none": "Geen",
|
||||
"language": "Taal",
|
||||
"transcode_mode": {
|
||||
"title": "Audio-transcoding",
|
||||
"description": "Bepaalt hoe surround audio (7.1, TrueHD, DTS-HD) wordt behandeld",
|
||||
"auto": "Automatisch",
|
||||
"stereo": "Stereo forceren",
|
||||
"5_1": "5.1 toestaan",
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
@@ -231,7 +231,7 @@
|
||||
"Black": "Zwart",
|
||||
"Gray": "Grijs",
|
||||
"Silver": "Zilver",
|
||||
"White": "Wit",
|
||||
"White": "wit",
|
||||
"Maroon": "Kastanjebruin",
|
||||
"Red": "Rood",
|
||||
"Fuchsia": "Fuchsia",
|
||||
@@ -259,14 +259,14 @@
|
||||
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC ondertitel instellingen",
|
||||
"hint": "Aanpassen van ondertiteling voor VLC-speler. Wijzigingen worden toegepast bij het afspelen.",
|
||||
"text_color": "Tekstkleur",
|
||||
"background_color": "Achtergrondkleur",
|
||||
"background_opacity": "Doorzichtigheid achtergrond",
|
||||
"outline_color": "Kleur omlijning",
|
||||
"outline_opacity": "Omtrek opaciteit",
|
||||
"outline_thickness": "Omtrek dikte",
|
||||
"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"
|
||||
},
|
||||
@@ -306,7 +306,7 @@
|
||||
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
||||
"default_quality": "Standaard kwaliteit",
|
||||
"default_playback_speed": "Standaard Afspeelsnelheid",
|
||||
"auto_play_next_episode": "Volgende aflevering automatisch afspelen",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
|
||||
"disabled": "Uitgeschakeld"
|
||||
},
|
||||
@@ -378,12 +378,12 @@
|
||||
"enable_promoted_watchlists": "Gepromote Kijklijst",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Aanbevolen films",
|
||||
"recommended_series": "Aanbevolen serie",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Opgeslagen",
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats uitgeschakeld"
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
@@ -402,24 +402,24 @@
|
||||
"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}} gecached",
|
||||
"music_cache_cleared": "Muziek cache gewist",
|
||||
"delete_all_downloaded_songs": "Verwijder alle gedownloade liedjes",
|
||||
"downloaded_songs_size": "{{size}} gedownload",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
"show_intro": "Toon intro",
|
||||
"reset_intro": "Reset Intro"
|
||||
"reset_intro": "intro opnieuw instellen"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logboek",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Klik voor meer info",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Niveau",
|
||||
"no_logs_available": "Geen logs beschikbaar",
|
||||
"delete_all_logs": "Alle logs verwijderen"
|
||||
"delete_all_logs": "Verwijder alle logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Talen",
|
||||
@@ -500,14 +500,14 @@
|
||||
"play": "Afspelen",
|
||||
"none": "Geen",
|
||||
"track": "Spoor",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"ok": "Oké",
|
||||
"remove": "Verwijderen",
|
||||
"next": "Volgende",
|
||||
"back": "Terug",
|
||||
"continue": "Doorgaan",
|
||||
"verifying": "Verifiëren..."
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Zoek...",
|
||||
@@ -521,10 +521,10 @@
|
||||
"episodes": "Afleveringen",
|
||||
"collections": "Collecties",
|
||||
"actors": "Acteurs",
|
||||
"artists": "Artiesten",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Nummers",
|
||||
"playlists": "Afspeellijsten",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Vraag films aan",
|
||||
"request_series": "Vraag series aan",
|
||||
"recently_added": "Recent Toegevoegd",
|
||||
@@ -572,7 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Jaren",
|
||||
"sort_by": "Sorteren op",
|
||||
"filter_by": "Filteren op",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sorteer volgorde",
|
||||
"tags": "Labels"
|
||||
}
|
||||
@@ -719,127 +719,127 @@
|
||||
"favorites": "Favorieten"
|
||||
},
|
||||
"music": {
|
||||
"title": "Muziek",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggesties",
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artiesten",
|
||||
"playlists": "Afspeellijsten",
|
||||
"tracks": "Nummers"
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recent toegevoegd",
|
||||
"recently_played": "Onlangs afgespeeld",
|
||||
"frequently_played": "Vaak afgespeeld",
|
||||
"explore": "Ontdek",
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Afspelen",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "Geen suggesties beschikbaar",
|
||||
"no_albums": "Geen albums gevonden",
|
||||
"no_artists": "Geen artiesten gevonden",
|
||||
"no_playlists": "Geen afspeellijsten gevonden",
|
||||
"album_not_found": "Album niet gevonden",
|
||||
"artist_not_found": "Artiest niet gevonden",
|
||||
"playlist_not_found": "Afspeellijst niet gevonden",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Speel volgende af",
|
||||
"add_to_queue": "Toevoegen aan wachtrij",
|
||||
"add_to_playlist": "Voeg toe aan afspeellijst",
|
||||
"download": "Downloaden",
|
||||
"downloaded": "Gedownload",
|
||||
"downloading": "Downloaden...",
|
||||
"cached": "Gecached",
|
||||
"delete_download": "Download verwijderen",
|
||||
"delete_cache": "Verwijderen uit cache",
|
||||
"go_to_artist": "Ga naar artiest",
|
||||
"go_to_album": "Ga naar album",
|
||||
"add_to_favorites": "Toevoegen aan favorieten",
|
||||
"remove_from_favorites": "Verwijderen uit favorieten",
|
||||
"remove_from_playlist": "Verwijder uit afspeellijst"
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Afspeellijst aanmaken",
|
||||
"playlist_name": "Afspeellijst naam",
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Aanmaken",
|
||||
"search_playlists": "Playlist zoeken...",
|
||||
"added_to": "Toegevoegd aan {{name}}",
|
||||
"added": "Toegevoegd aan playlist",
|
||||
"removed_from": "Verwijderd uit {{name}}",
|
||||
"removed": "Verwijderd uit playlist",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Verwijderen uit afspeellijst is mislukt",
|
||||
"failed_to_create": "Het maken van de afspeellijst is mislukt",
|
||||
"delete_playlist": "Afspeellijst verwijderen",
|
||||
"delete_confirm": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"deleted": "Afspeellijst verwijderd.",
|
||||
"failed_to_delete": "Verwijderen van afspeellijst mislukt"
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sorteren op",
|
||||
"alphabetical": "Alfabetisch",
|
||||
"date_created": "Aanmaakdatum"
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlist",
|
||||
"my_watchlists": "Mijn watchlists",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Wijzigingen opslaan",
|
||||
"delete_button": "Verwijder",
|
||||
"remove_button": "Verwijderen",
|
||||
"cancel_button": "Annuleren",
|
||||
"name_label": "Naam",
|
||||
"name_placeholder": "Voer naam van kijklijst in",
|
||||
"description_label": "Beschrijving",
|
||||
"description_placeholder": "Voer beschrijving in (optioneel)",
|
||||
"is_public_label": "Openbare Kijklijst",
|
||||
"is_public_description": "Sta anderen toe om deze kijklijst te bekijken",
|
||||
"allowed_type_label": "Inhoudstype",
|
||||
"sort_order_label": "Standaard Sortering",
|
||||
"empty_title": "Geen Kijklijsten",
|
||||
"empty_description": "Maak je eerste kijklijst om je media te organiseren",
|
||||
"empty_watchlist": "Deze watchlist is leeg",
|
||||
"empty_watchlist_hint": "Voeg items uit je bibliotheek toe aan deze kijklijst",
|
||||
"not_configured_title": "Streamystats niet geconfigureerd",
|
||||
"not_configured_description": "Configureer Streamystats in instellingen om kijklijsten te gebruiken",
|
||||
"go_to_settings": "Ga naar Instellingen",
|
||||
"add_to_watchlist": "Voeg toe aan kijklijst",
|
||||
"remove_from_watchlist": "Verwijder van kijklijst",
|
||||
"select_watchlist": "Selecteer kijklijst",
|
||||
"create_new": "Nieuwe kijklijst aanmaken",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Publiek",
|
||||
"private": "Privé",
|
||||
"you": "Jij",
|
||||
"by_owner": "Door een andere gebruiker",
|
||||
"not_found": "Kijklijst niet gevonden",
|
||||
"delete_confirm_title": "Verwijder kijklijst",
|
||||
"delete_confirm_message": "Weet u zeker dat u \"{{name}}\"wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"remove_item_title": "Verwijder van watchlist",
|
||||
"remove_item_message": "Verwijder \"{{name}}\" uit deze watchlist?",
|
||||
"loading": "Laden van watchlists...",
|
||||
"no_compatible_watchlists": "Geen compatibele watchlist",
|
||||
"create_one_first": "Maak een watchlist aan die dit inhoudstype accepteert"
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Afspeelsnelheid",
|
||||
"apply_to": "Toepassen op",
|
||||
"speed": "Snelheid",
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Alleen deze media",
|
||||
"show": "Deze serie",
|
||||
"all": "Alle media (standaard)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,44 +34,44 @@
|
||||
"search_for_local_servers": "Поиск локальных серверов",
|
||||
"searching": "Поиск...",
|
||||
"servers": "Сервера",
|
||||
"saved": "Сохранено",
|
||||
"session_expired": "Сессия истекла",
|
||||
"please_login_again": "Ваша сессия истекла. Пожалуйста, войдите снова.",
|
||||
"remove_saved_login": "Удалить сохраненный аккаунт",
|
||||
"remove_saved_login_description": "Ваши сохранённые данные для входа от этого сервера будут удалены. Вам придётся ввести ваши логин и пароль ещё раз.",
|
||||
"accounts_count": "{{count}} аккаунтов",
|
||||
"select_account": "Выбрать аккаунт",
|
||||
"add_account": "Добавить аккаунт",
|
||||
"remove_account_description": "Данные для входа {{username}} будут удалены."
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Сохранить аккаунт",
|
||||
"save_for_later": "Сохранить этот аккаунт",
|
||||
"security_option": "Опции безопасности",
|
||||
"no_protection": "Без защиты",
|
||||
"no_protection_desc": "Быстрый вход без ввода данных",
|
||||
"pin_code": "PIN-код",
|
||||
"pin_code_desc": "При переключении будет требоваться 4-значный PIN",
|
||||
"password": "Пароль",
|
||||
"password_desc": "При переключении будет требоваться пароль",
|
||||
"save_button": "Сохранить",
|
||||
"cancel_button": "Отмена"
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Введите PIN",
|
||||
"enter_pin_for": "Введите PIN для {{username}}",
|
||||
"enter_4_digits": "Введите 4 цифры",
|
||||
"invalid_pin": "Некорректный PIN",
|
||||
"setup_pin": "Установить PIN",
|
||||
"confirm_pin": "Подтвердите PIN",
|
||||
"pins_dont_match": "PIN-коды не совпадают",
|
||||
"forgot_pin": "Забыли PIN?",
|
||||
"forgot_pin_desc": "Ваши данные для входа будут удалены"
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Введите пароль",
|
||||
"enter_password_for": "Введите пароль для {{username}}",
|
||||
"invalid_password": "Неверный пароль"
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Проверка соединения с сервером...",
|
||||
@@ -82,12 +82,12 @@
|
||||
"go_to_downloads": "В загрузки",
|
||||
"retry": "Повторить",
|
||||
"server_unreachable": "Сервер недоступен",
|
||||
"server_unreachable_message": "Не удалось соединиться с сервером.\nПожалуйста, проверьте настройки сети.",
|
||||
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
||||
"oops": "Упс!",
|
||||
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
|
||||
"continue_watching": "Продолжить",
|
||||
"next_up": "Далее",
|
||||
"continue_and_next_up": "Продолжить и Далее",
|
||||
"continue_watching": "Продолжить просмотр",
|
||||
"next_up": "Следующее",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Недавно добавлено в {{libraryName}}",
|
||||
"suggested_movies": "Предложенные фильмы",
|
||||
"suggested_episodes": "Предложенные серии",
|
||||
@@ -110,46 +110,46 @@
|
||||
"settings_title": "Настройки",
|
||||
"log_out_button": "Выйти",
|
||||
"categories": {
|
||||
"title": "Категории"
|
||||
"title": "Categories"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Воспроизведение и управление"
|
||||
"title": "Playback & Controls"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Аудио и субтитры"
|
||||
"title": "Audio & Subtitles"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
"merge_next_up_continue_watching": "Объединить «Продолжить» и «Далее»",
|
||||
"hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»"
|
||||
"title": "Appearance",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Сеть",
|
||||
"local_network": "Локальная сеть",
|
||||
"auto_switch_enabled": "Переключаться дома автоматически",
|
||||
"auto_switch_description": "Автоматически переключаться на локальный URL при присоединении к домашней WiFi сети",
|
||||
"local_url": "Локальный URL",
|
||||
"local_url_hint": "Введите локальный URL вашего сервера (e.g., http://192.168.1.100:8096)",
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Домашние WiFi сети",
|
||||
"add_current_network": "Добавить \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Нет WiFi соединения",
|
||||
"no_networks_configured": "Нет настроенных сетей",
|
||||
"add_network_hint": "Добавьте вашу домашнюю сеть WiFi для включения автоматического переключения",
|
||||
"current_wifi": "Текущая WiFi сеть",
|
||||
"using_url": "Используется",
|
||||
"local": "Локальный",
|
||||
"remote": "Внешний",
|
||||
"not_connected": "Нет соединения",
|
||||
"current_server": "Текущий сервер",
|
||||
"remote_url": "Внешний URL",
|
||||
"active_url": "Активный URL",
|
||||
"not_configured": "Не настроено",
|
||||
"network_added": "Сеть добавлена",
|
||||
"network_already_added": "Сеть уже добавлена",
|
||||
"no_wifi_connected": "Нет WiFi соединения",
|
||||
"permission_denied": "Нет доступа к местоположению",
|
||||
"permission_denied_explanation": "Разрешение на доступ к местоположению обязательно для обнаружения WiFi сети при автоматическом переключении. Пожалуйста, включите его в настройках."
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Информация о пользователе",
|
||||
@@ -170,22 +170,22 @@
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Медиа-контроль",
|
||||
"forward_skip_length": "Шаг перемотки вперёд",
|
||||
"rewind_length": "Шаг перемотки назад",
|
||||
"forward_skip_length": "Длина пропуска вперед",
|
||||
"rewind_length": "Длина перемотки",
|
||||
"seconds_unit": "c"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Управление жестами",
|
||||
"horizontal_swipe_skip": "Горизонтальный свайп для перемотки",
|
||||
"horizontal_swipe_skip": "Горизонтальный свайп, чтобы пропустить",
|
||||
"horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы пропустить",
|
||||
"left_side_brightness": "Управление яркостью левой стороны",
|
||||
"left_side_brightness_description": "Смахните вверх/вниз на левой стороне для настройки яркости",
|
||||
"right_side_volume": "Управление громкостью справа",
|
||||
"right_side_volume_description": "Свайп вверх/вниз с правой стороны для настройки громкости",
|
||||
"hide_volume_slider": "Скрыть индикатор громкости",
|
||||
"hide_volume_slider_description": "Скрывает индикатор громкости в плеере",
|
||||
"hide_brightness_slider": "Скрыть индикатор яркости",
|
||||
"hide_brightness_slider_description": "Скрывает индикатор яркости в плеере"
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Аудио",
|
||||
@@ -195,17 +195,17 @@
|
||||
"none": "Отсутствует",
|
||||
"language": "Язык",
|
||||
"transcode_mode": {
|
||||
"title": "Перекодировка аудио",
|
||||
"description": "Управляет обработкой пространственного звука (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Авто",
|
||||
"stereo": "Принудительно в стерео",
|
||||
"5_1": "Разрешить 5.1",
|
||||
"passthrough": "Ничего не изменять"
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Субтитры",
|
||||
"subtitle_hint": "Настройки отображения субтитров",
|
||||
"subtitle_hint": "Настроить субтитры.",
|
||||
"subtitle_language": "Язык субтитров",
|
||||
"subtitle_mode": "Режим субтитров",
|
||||
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
|
||||
@@ -226,24 +226,24 @@
|
||||
"outline_thickness": "Толщина контура",
|
||||
"background_opacity": "Прозрачность фона",
|
||||
"outline_opacity": "Прозрачность контура",
|
||||
"bold_text": "Жирный",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Черный",
|
||||
"Gray": "Серый",
|
||||
"Silver": "Серебристый",
|
||||
"Silver": "Серебряный",
|
||||
"White": "Белый",
|
||||
"Maroon": "Бордовый",
|
||||
"Maroon": "Марун",
|
||||
"Red": "Красный",
|
||||
"Fuchsia": "Пурпурный",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Жёлтый",
|
||||
"Olive": "Оливковый",
|
||||
"Olive": "Олив",
|
||||
"Green": "Зелёный",
|
||||
"Teal": "Бирюзовый",
|
||||
"Lime": "Лаймовый",
|
||||
"Purple": "Фиолетовый",
|
||||
"Navy": "Тёмно-синий",
|
||||
"Blue": "Синий",
|
||||
"Aqua": "Голубой"
|
||||
"Aqua": "Акваа"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Отсутствует",
|
||||
@@ -251,29 +251,29 @@
|
||||
"Normal": "Обычный",
|
||||
"Thick": "Толстый"
|
||||
},
|
||||
"subtitle_color": "Цвет субтитров",
|
||||
"subtitle_background_color": "Цвет фона",
|
||||
"subtitle_font": "Шрифт субтитров",
|
||||
"ksplayer_title": "Настройки KSPlayer",
|
||||
"hardware_decode": "Аппаратное декодирование",
|
||||
"hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением."
|
||||
"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."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "Настройки субтитров в VLC",
|
||||
"hint": "Настройте внешний вид субтитров в VLC плеере. Изменения применятся при следующем воспроизведении.",
|
||||
"text_color": "Цвет текста",
|
||||
"background_color": "Цвет фона",
|
||||
"background_opacity": "Прозрачность фона",
|
||||
"outline_color": "Цвет контура",
|
||||
"outline_opacity": "Прозрачность контура",
|
||||
"outline_thickness": "Толщина контура",
|
||||
"bold": "Жирный",
|
||||
"margin": "Отступ снизу"
|
||||
"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": "Выберите видеоплеер в iOS.",
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
@@ -294,19 +294,19 @@
|
||||
"UNKNOWN": "Неизвестное"
|
||||
},
|
||||
"safe_area_in_controls": "Безопасная зона в элементах управления",
|
||||
"video_player": "Видеоплеер",
|
||||
"video_player": "Видео прейер",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Показать ссылки кастомного меню",
|
||||
"show_large_home_carousel": "Показывать большую карусель (beta)",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Скрыть библиотеки",
|
||||
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
||||
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
||||
"default_quality": "Качество по умолчанию",
|
||||
"default_playback_speed": "Скорость воспроизведения по умолчанию",
|
||||
"auto_play_next_episode": "Автоматически воспроизводить следующий эпизод",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Максимальное количество автовоспроизведения эпизодов",
|
||||
"disabled": "Отключено"
|
||||
},
|
||||
@@ -314,15 +314,15 @@
|
||||
"downloads_title": "Загрузки"
|
||||
},
|
||||
"music": {
|
||||
"title": "Музыка",
|
||||
"playback_title": "Воспроизведение",
|
||||
"playback_description": "Настройте воспроизведение музыки.",
|
||||
"prefer_downloaded": "Предпочитать скачанные песни",
|
||||
"caching_title": "Кеширование",
|
||||
"caching_description": "Автоматически предкешировать следующие треки для стабильного воспроизведения.",
|
||||
"lookahead_enabled": "Включить предкеширование",
|
||||
"lookahead_count": "Сколько предкешировать",
|
||||
"max_cache_size": "Максимальное число предкешированных треков"
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Плагины",
|
||||
@@ -357,39 +357,39 @@
|
||||
"save_button": "Сохранить",
|
||||
"toasts": {
|
||||
"saved": "Сохранено",
|
||||
"refreshed": "Настройки обновлены с сервера"
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Обновить настройки с сервера"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Включить Streamystats",
|
||||
"disable_streamystats": "Выключить Streamystats",
|
||||
"enable_search": "Использовать в поиске",
|
||||
"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": "Введите URL вашего сервера Streamystats. URL должен включать http/https и порт при необходимости.",
|
||||
"read_more_about_streamystats": "Узнать больше про Streamystats.",
|
||||
"save_button": "Сохранить",
|
||||
"save": "Сохранить",
|
||||
"features_title": "Функции",
|
||||
"home_sections_title": "Показывать на главной",
|
||||
"enable_movie_recommendations": "Рекомендации фильмов",
|
||||
"enable_series_recommendations": "Рекомендации сериалов",
|
||||
"enable_promoted_watchlists": "Продвигаемые списки просмотра",
|
||||
"hide_watchlists_tab": "Скрыть вкладку со списками",
|
||||
"home_sections_hint": "Показывать персонализированные рекомендации и подходящие списки просмотров из Streamystats на главной странице.",
|
||||
"recommended_movies": "Рекомендованные фильмы",
|
||||
"recommended_series": "Рекомендованные сериалы",
|
||||
"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",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Сохранено",
|
||||
"refreshed": "Настройки обновлены с сервера",
|
||||
"disabled": "Streamystats отключен"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Обновить настройки с сервера"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Включить интеграцию со списками просмотра",
|
||||
"watchlist_button": "Изменить интеграцию со списками просмотра"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -398,18 +398,18 @@
|
||||
"device_usage": "Устройство {{availableSpace}}%",
|
||||
"size_used": "{{used}} из {{total}} использовано",
|
||||
"delete_all_downloaded_files": "Удалить все загруженные файлы",
|
||||
"music_cache_title": "Кеш музыки",
|
||||
"music_cache_description": "Автоматически прекешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
|
||||
"enable_music_cache": "Кешировать музыку",
|
||||
"clear_music_cache": "Очистить кеш музыки",
|
||||
"music_cache_size": "{{size}} кешировано",
|
||||
"music_cache_cleared": "Кеш музыки очищен",
|
||||
"delete_all_downloaded_songs": "Удалить все скачанные песни",
|
||||
"downloaded_songs_size": "{{size}} скачано",
|
||||
"downloaded_songs_deleted": "Скачанные песни удалены"
|
||||
"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",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Вступление",
|
||||
"title": "Intro",
|
||||
"show_intro": "Показать вступление",
|
||||
"reset_intro": "Сбросить вступление"
|
||||
},
|
||||
@@ -441,24 +441,24 @@
|
||||
"tvseries": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"queue": "Очередь",
|
||||
"other_media": "Прочие файлы",
|
||||
"queue_hint": "Очередь очистится после перезапуска",
|
||||
"other_media": "Другие медиа",
|
||||
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
|
||||
"no_items_in_queue": "Нет элементов в очереди",
|
||||
"no_downloaded_items": "Нет загруженных файлов",
|
||||
"no_downloaded_items": "Нет загруженых предметов",
|
||||
"delete_all_movies_button": "Удалить все фильмы",
|
||||
"delete_all_tvseries_button": "Удалить все сериалы",
|
||||
"delete_all_button": "Удалить все",
|
||||
"delete_all_other_media_button": "Удалить прочие файлы",
|
||||
"active_download": "Загружается",
|
||||
"delete_all_other_media_button": "Удалить другой материал",
|
||||
"active_download": "Активно загружается",
|
||||
"no_active_downloads": "Нет активных загрузок",
|
||||
"active_downloads": "Активные",
|
||||
"active_downloads": "Активные загрузки",
|
||||
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
|
||||
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
|
||||
"back": "Назад",
|
||||
"delete": "Удалить",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
|
||||
"eta": "Осталось {{eta}}",
|
||||
"eta": "ETA {{eta}}",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
|
||||
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
|
||||
@@ -467,64 +467,64 @@
|
||||
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
|
||||
"deleted_media_successfully": "Другие носители успешно удалены!",
|
||||
"failed_to_delete_media": "Не удалось удалить другой файл",
|
||||
"download_deleted": "Удалено",
|
||||
"download_deleted": "Загрузка удалена",
|
||||
"download_cancelled": "Загрузка отменена",
|
||||
"could_not_delete_download": "Не удалось удалить загрузку",
|
||||
"download_paused": "На паузе",
|
||||
"download_paused": "Загрузка приостановлена",
|
||||
"could_not_pause_download": "Не удалось приостановить загрузку",
|
||||
"download_resumed": "Продолжено",
|
||||
"download_resumed": "Загрузка возобновлена",
|
||||
"could_not_resume_download": "Не удалось продолжить загрузку",
|
||||
"download_completed": "Завершено",
|
||||
"download_failed": "Не удалось загрузить",
|
||||
"download_completed": "Загрузка завершена",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
|
||||
"download_completed_for_item": "{{item}} успешно загружен",
|
||||
"download_started_for_item": "Загрузка началась для {{item}}",
|
||||
"failed_to_start_download": "Не удалось начать загрузку",
|
||||
"item_already_downloading": "{{item}} уже загружается",
|
||||
"all_files_deleted": "Все загрузки удалены",
|
||||
"files_deleted_by_type": "{{count}} {{type}} удалён(о)",
|
||||
"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}} удалён"
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Выбрать",
|
||||
"no_trailer_available": "Трейлер недоступен",
|
||||
"no_trailer_available": "Прицеп недоступен",
|
||||
"video": "Видео",
|
||||
"audio": "Звук",
|
||||
"subtitle": "Субтитры",
|
||||
"play": "Воспроизвести",
|
||||
"none": "Отсутствует",
|
||||
"track": "Трек",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"ok": "ОК",
|
||||
"remove": "Удалить",
|
||||
"next": "Вперед",
|
||||
"back": "Назад",
|
||||
"continue": "Продолжить",
|
||||
"verifying": "Проверка..."
|
||||
"play": "Играть",
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Поиск...",
|
||||
"x_items": "{{count}} элементов",
|
||||
"x_items": "{{count}} предметов",
|
||||
"library": "Библиотека",
|
||||
"discover": "Найти новое",
|
||||
"no_results": "Ничего не найдено",
|
||||
"no_results_found_for": "Ничего не найдено по запросу",
|
||||
"no_results": "Нет результатов",
|
||||
"no_results_found_for": "Не было результатов при поиске",
|
||||
"movies": "Фильмы",
|
||||
"series": "Сериалы",
|
||||
"episodes": "Серии",
|
||||
"collections": "Коллекции",
|
||||
"actors": "Актеры",
|
||||
"artists": "Артисты",
|
||||
"albums": "Альбомы",
|
||||
"songs": "Песни",
|
||||
"playlists": "Плейлисты",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Запросить фильмы",
|
||||
"request_series": "Запросить сериалы",
|
||||
"recently_added": "Недавно добавлено",
|
||||
@@ -553,7 +553,7 @@
|
||||
"no_results": "Нет результатов",
|
||||
"no_libraries_found": "Библиотеки не найдены",
|
||||
"item_types": {
|
||||
"movies": "Фильмы",
|
||||
"movies": "фильмы",
|
||||
"series": "Сериалы",
|
||||
"boxsets": "Коллекции",
|
||||
"items": "элементы"
|
||||
@@ -571,9 +571,9 @@
|
||||
"filters": {
|
||||
"genres": "Жанры",
|
||||
"years": "Года",
|
||||
"sort_by": "Сортировка",
|
||||
"filter_by": "Фильтр",
|
||||
"sort_order": "Порядок",
|
||||
"sort_by": "Сортировать по",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Порядок сортировки",
|
||||
"tags": "Тэги"
|
||||
}
|
||||
},
|
||||
@@ -604,14 +604,14 @@
|
||||
"index": "Индекс:",
|
||||
"continue_watching": "Продолжить просмотр",
|
||||
"go_back": "Назад",
|
||||
"downloaded_file_title": "Этот файл уже скачан",
|
||||
"downloaded_file_message": "Хотите воспроизвести скачанный файл?",
|
||||
"downloaded_file_yes": "Да",
|
||||
"downloaded_file_no": "Нет",
|
||||
"downloaded_file_cancel": "Отмена"
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Далее",
|
||||
"next_up": "Следующее",
|
||||
"no_items_to_display": "Нет элементов для отображения",
|
||||
"cast_and_crew": "Актеры и съемочная группа",
|
||||
"series": "Серии",
|
||||
@@ -644,7 +644,7 @@
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Далее",
|
||||
"next": "Следующая",
|
||||
"previous": "Предыдущая",
|
||||
"coming_soon": "Скоро",
|
||||
"on_now": "Сейчас в эфире",
|
||||
@@ -675,7 +675,7 @@
|
||||
"series_type": "Тип сериала",
|
||||
"release_dates": "Дата релиза",
|
||||
"first_air_date": "Первая дата выхода в эфир",
|
||||
"next_air_date": "Ближайшая дата выхода в эфир",
|
||||
"next_air_date": "Следующая дата выхода в эфир",
|
||||
"revenue": "Прибыль",
|
||||
"budget": "Бюджет",
|
||||
"original_language": "Оригинальный язык",
|
||||
@@ -693,10 +693,10 @@
|
||||
"number_episodes": "{{episode_number}} серий",
|
||||
"born": "Рожден",
|
||||
"appearances": "Появления",
|
||||
"approve": "Одобрить",
|
||||
"decline": "Отклонить",
|
||||
"requested_by": "Запрошено {{user}}",
|
||||
"unknown_user": "Неизвестный пользователь",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
|
||||
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
|
||||
@@ -705,141 +705,141 @@
|
||||
"requested_item": "Запрошено {{item}}!",
|
||||
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
|
||||
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!",
|
||||
"request_approved": "Запрос одобрен!",
|
||||
"request_declined": "Запрос отклонён!",
|
||||
"failed_to_approve_request": "Не удалось одобрить запрос",
|
||||
"failed_to_decline_request": "Не удалось отклонить запрос"
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Главная",
|
||||
"home": "Дом",
|
||||
"search": "Поиск",
|
||||
"library": "Библиотека",
|
||||
"custom_links": "Ссылки",
|
||||
"custom_links": "Кастомные ссылки",
|
||||
"favorites": "Избранное"
|
||||
},
|
||||
"music": {
|
||||
"title": "Музыка",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Рекомендации",
|
||||
"albums": "Альбомы",
|
||||
"artists": "Исполнители",
|
||||
"playlists": "Плейлисты",
|
||||
"tracks": "треки"
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все"
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Недавно добавлено",
|
||||
"recently_played": "Недавно воспроизведено",
|
||||
"frequently_played": "Часто играет",
|
||||
"explore": "Найти новое",
|
||||
"top_tracks": "Топ",
|
||||
"play": "Воспроизвести",
|
||||
"shuffle": "Перемешать",
|
||||
"play_top_tracks": "Воспроизвести топ",
|
||||
"no_suggestions": "Нет рекомендаций",
|
||||
"no_albums": "Альбомы не найдены",
|
||||
"no_artists": "Исполнители не найдены",
|
||||
"no_playlists": "Плейлисты не найдены",
|
||||
"album_not_found": "Альбом не найден",
|
||||
"artist_not_found": "Исполнитель не найден",
|
||||
"playlist_not_found": "Плейлист не найден",
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Далее",
|
||||
"add_to_queue": "Добавить в очередь",
|
||||
"add_to_playlist": "Добавить в плейлист",
|
||||
"download": "Скачать",
|
||||
"downloaded": "Скачано",
|
||||
"downloading": "Скачивается...",
|
||||
"cached": "Кешировано",
|
||||
"delete_download": "Удалить загрузку",
|
||||
"delete_cache": "Удалить из кеша",
|
||||
"go_to_artist": "К исполнителю",
|
||||
"go_to_album": "К альбому",
|
||||
"add_to_favorites": "В избранное",
|
||||
"remove_from_favorites": "Удалить из избранного",
|
||||
"remove_from_playlist": "Удалить из плейлиста"
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Создать плейлист",
|
||||
"playlist_name": "Название плейлиста",
|
||||
"enter_name": "Введите название плейлиста",
|
||||
"create": "Создать",
|
||||
"search_playlists": "Поиск плейлистов...",
|
||||
"added_to": "Добавлено в {{name}}",
|
||||
"added": "Добавлено в плейлист",
|
||||
"removed_from": "Удалено из {{name}}",
|
||||
"removed": "Удалено из плейлиста",
|
||||
"created": "Плейлист создан",
|
||||
"create_new": "Добавить новый плейлист",
|
||||
"failed_to_add": "Не удалось добавить в плейлист",
|
||||
"failed_to_remove": "Не удалось удалить из плейлиста",
|
||||
"failed_to_create": "Не удалось создать плейлист",
|
||||
"delete_playlist": "Удалить плейлист",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить \"{{name}}\"? Это действие необратимо.",
|
||||
"deleted": "Плейлист удалён",
|
||||
"failed_to_delete": "Не удалось удалить плейлист"
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Сортировка",
|
||||
"alphabetical": "По алфавиту",
|
||||
"date_created": "По дате создания"
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Списки просмотров",
|
||||
"my_watchlists": "Мои списки",
|
||||
"public_watchlists": "Публичные списки",
|
||||
"create_title": "Создать список",
|
||||
"edit_title": "Редактировать список",
|
||||
"create_button": "Создать список",
|
||||
"save_button": "Сохранить изменения",
|
||||
"delete_button": "Удалить",
|
||||
"remove_button": "Удалить",
|
||||
"cancel_button": "Отмена",
|
||||
"name_label": "Название",
|
||||
"name_placeholder": "Введите название списка",
|
||||
"description_label": "Описание",
|
||||
"description_placeholder": "Введите описание (не обязательно)",
|
||||
"is_public_label": "Публичный",
|
||||
"is_public_description": "Разрешить остальным пользователям видеть этот список",
|
||||
"allowed_type_label": "Тип контента",
|
||||
"sort_order_label": "Сортировка по умолчанию",
|
||||
"empty_title": "Нет списков",
|
||||
"empty_description": "Создайте ваш первый список для управления вашими медиа",
|
||||
"empty_watchlist": "Этот список пуст",
|
||||
"empty_watchlist_hint": "Добавляйте элементы из библиотеки в этот список",
|
||||
"not_configured_title": "Streamystats не настроен",
|
||||
"not_configured_description": "Настройте Streamystats для использования функционала списков",
|
||||
"go_to_settings": "В настройки",
|
||||
"add_to_watchlist": "Добавить в список просмотра",
|
||||
"remove_from_watchlist": "Удалить из списка просмотра",
|
||||
"select_watchlist": "Выбрать список",
|
||||
"create_new": "Создать новый список",
|
||||
"item": "элемент",
|
||||
"items": "элементы",
|
||||
"public": "Публичный",
|
||||
"private": "Личный",
|
||||
"you": "Ваш",
|
||||
"by_owner": "Другим пользователем",
|
||||
"not_found": "Список не найден",
|
||||
"delete_confirm_title": "Удалить список",
|
||||
"delete_confirm_message": "Вы уверены, что хотите удалить список \"{{name}}\"? Это действие необратимо.",
|
||||
"remove_item_title": "Удалить из списка",
|
||||
"remove_item_message": "Удалить \"{{name}}\" из списка?",
|
||||
"loading": "Загрузка списков...",
|
||||
"no_compatible_watchlists": "Нет совместимых списков",
|
||||
"create_one_first": "Создайте список просмотра с подходящим типом контента"
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Скорость воспроизведения",
|
||||
"apply_to": "Применять к",
|
||||
"speed": "Скорость",
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Только в этот раз",
|
||||
"show": "Ко всему сериалу",
|
||||
"all": "Ко всем файлам (по умолчанию)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,88 +7,88 @@
|
||||
"username_placeholder": "Kullanıcı adı",
|
||||
"password_placeholder": "Şifre",
|
||||
"login_button": "Giriş yap",
|
||||
"quick_connect": "Hızlı Bağlan",
|
||||
"quick_connect": "Hızlı Bağlantı",
|
||||
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
|
||||
"failed_to_initiate_quick_connect": "Hızlı Bağlan başlatılamadı",
|
||||
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
|
||||
"got_it": "Anlaşıldı",
|
||||
"connection_failed": "Bağlantı başarısız",
|
||||
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.",
|
||||
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin",
|
||||
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
|
||||
"change_server": "Sunucu değiştir",
|
||||
"change_server": "Sunucuyu değiştir",
|
||||
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
|
||||
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Sunucunun yanıt vermesi çok uzun sürüyor, lütfen daha sonra tekrar deneyin",
|
||||
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen daha sonra tekrar deneyin.",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
|
||||
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
|
||||
"there_is_a_server_error": "Sunucu hatası var",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
|
||||
"too_old_server_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.",
|
||||
"too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin."
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?",
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL adresini girin",
|
||||
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
|
||||
"server_url_placeholder": "http(s)://sunucunuz.com",
|
||||
"connect_button": "Bağlan",
|
||||
"previous_servers": "Önceki sunucular",
|
||||
"clear_button": "Temizle",
|
||||
"swipe_to_remove": "Kaldırmak için kaydırın",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Yerel sunucuları ara",
|
||||
"searching": "Aranıyor...",
|
||||
"servers": "Sunucular",
|
||||
"saved": "Kaydedildi",
|
||||
"session_expired": "Oturum süresi doldu",
|
||||
"please_login_again": "Kaydedilmiş oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.",
|
||||
"remove_saved_login": "Kayıtlı oturumu kaldır",
|
||||
"remove_saved_login_description": "Bu sunucu için kaydedilmiş kimlik bilgileriniz kaldırılacaktır. Bir sonraki sefere kullanıcı adı ve şifrenizi yeniden girmeniz gerekecek.",
|
||||
"accounts_count": "{{count}} hesap",
|
||||
"select_account": "Hesap Seç",
|
||||
"add_account": "Hesap Ekle",
|
||||
"remove_account_description": "{{username}} için kayıtlı bilgiler kaldırılacaktır."
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Hesabı Kaydet",
|
||||
"save_for_later": "Bu hesabı kaydet",
|
||||
"security_option": "Güvenlik Seçeneği",
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Kimlik doğrulamasız hızlı giriş",
|
||||
"pin_code": "PIN kodu",
|
||||
"pin_code_desc": "Geçiş yaparken 4 haneli PIN kodu gereklidir",
|
||||
"password": "Şifrenizi tekrar girin ",
|
||||
"password_desc": "Geçiş yaparken şifre gereklidir",
|
||||
"save_button": "Kaydet",
|
||||
"cancel_button": "Vazgeç"
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "PIN kodunu girin",
|
||||
"enter_pin_for": "{{username}} için PIN kodunu girin",
|
||||
"enter_4_digits": "4 hane girin",
|
||||
"invalid_pin": "Geçersiz PIN kodu",
|
||||
"setup_pin": "PIN kodunu ayarla",
|
||||
"confirm_pin": "PIN kodunu onayla",
|
||||
"pins_dont_match": "PIN kodları eşleşmiyor",
|
||||
"forgot_pin": "PIN kodunu mu unuttunuz?",
|
||||
"forgot_pin_desc": "Kayıtlı bilgileriniz kaldırılacaktır"
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Şifrenizi girin",
|
||||
"enter_password_for": "{{username}} için şifrenizi girin",
|
||||
"invalid_password": "Geçersiz şifre"
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Sunucu bağlantısı kontrol ediliyor...",
|
||||
"checking_server_connection": "Checking server connection...",
|
||||
"no_internet": "İnternet Yok",
|
||||
"no_items": "Öge Yok",
|
||||
"no_internet_message": "Endişelenmeyin, indirilmiş içerikleri izleyebilirsiniz.",
|
||||
"checking_server_connection_message": "Sunucuya bağlantı kontrol ediliyor",
|
||||
"go_to_downloads": "İndirilenlere git",
|
||||
"retry": "Tekrar dene",
|
||||
"server_unreachable": "Sunucuya ulaşılamıyor",
|
||||
"server_unreachable_message": "Sunucuya bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.",
|
||||
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
|
||||
"checking_server_connection_message": "Checking connection to server",
|
||||
"go_to_downloads": "İndirmelere Git",
|
||||
"retry": "Retry",
|
||||
"server_unreachable": "Server Unreachable",
|
||||
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
||||
"oops": "Hups!",
|
||||
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapıp tekrar giriş yapın.",
|
||||
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
|
||||
"continue_watching": "İzlemeye Devam Et",
|
||||
"next_up": "Sonraki",
|
||||
"continue_and_next_up": "İzlemeye Devam Et & Sıradakiler",
|
||||
"recently_added_in": "{{libraryName}} Kütüphanesine Son Eklenenler",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
|
||||
"suggested_movies": "Önerilen Filmler",
|
||||
"suggested_episodes": "Önerilen Bölümler",
|
||||
"intro": {
|
||||
@@ -110,52 +110,52 @@
|
||||
"settings_title": "Ayarlar",
|
||||
"log_out_button": "Çıkış Yap",
|
||||
"categories": {
|
||||
"title": "Kategoriler"
|
||||
"title": "Categories"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Oynatma & Kontroller"
|
||||
"title": "Playback & Controls"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Ses & Altyazılar"
|
||||
"title": "Audio & Subtitles"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Görünüm",
|
||||
"merge_next_up_continue_watching": "İzlemeye Devam Et & Sıradakiler'i birleştir",
|
||||
"hide_remote_session_button": "Uzak Oturum Butonunu Gizle"
|
||||
"title": "Appearance",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Ağ",
|
||||
"local_network": "Yerel Ağ",
|
||||
"auto_switch_enabled": "Evdeyken otomatik geçiş yap",
|
||||
"auto_switch_description": "Ev WiFi'sine bağlanınca otomatik olarak yerek URL adresine geçiş yap",
|
||||
"local_url": "Yerel URL Adresi",
|
||||
"local_url_hint": "Yerel sunucu adresinizi girin (http://192.168.1.100:8096, gibi)",
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Ev WiFi ağları",
|
||||
"add_current_network": "\"{{ssid}}\"'yi ekle",
|
||||
"not_connected_to_wifi": "WiFi'a bağlı değil",
|
||||
"no_networks_configured": "Herhangi bir ağ ayarlanmadı",
|
||||
"add_network_hint": "Otomatik geçişi etkinleştirmek için ev WiFi'nizi ekleyin",
|
||||
"current_wifi": "Şu anki WiFi",
|
||||
"using_url": "Kullanılıyor",
|
||||
"local": "Yerel URL Adresi",
|
||||
"remote": "Uzak URL Adresi",
|
||||
"not_connected": "Bağlı değil",
|
||||
"current_server": "Geçerli Sunucu",
|
||||
"remote_url": "Uzak URL Adresi",
|
||||
"active_url": "Aktif URL Adresi",
|
||||
"not_configured": "Yapılandırılmamış",
|
||||
"network_added": "Ağ eklendi",
|
||||
"network_already_added": "Ağ zaten eklendi",
|
||||
"no_wifi_connected": "WiFi'a bağlı değil",
|
||||
"permission_denied": "Konum izni reddedildi",
|
||||
"permission_denied_explanation": "Otomatik geçiş yapabilmek için WiFi ağını algılayabilmek için konum izni gereklidir. Lütfen Ayarlarda etkinleştirin."
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Kullanıcı Bilgisi",
|
||||
"user": "Kullanıcı",
|
||||
"server": "Sunucu",
|
||||
"token": "Erişim Anahtarı",
|
||||
"token": "Token",
|
||||
"app_version": "Uygulama Sürümü"
|
||||
},
|
||||
"quick_connect": {
|
||||
@@ -172,20 +172,20 @@
|
||||
"media_controls_title": "Medya Kontrolleri",
|
||||
"forward_skip_length": "İleri Sarma Uzunluğu",
|
||||
"rewind_length": "Geri Sarma Uzunluğu",
|
||||
"seconds_unit": "sn"
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Hareketle Kontrol",
|
||||
"horizontal_swipe_skip": "Atlamak için yatay kaydırma",
|
||||
"horizontal_swipe_skip_description": "Kontroller gizliyken sola/sağa kaydırarak atlama",
|
||||
"left_side_brightness": "Sol Taraf Parlaklık Kontrolü",
|
||||
"left_side_brightness_description": "Sol tarafta aşağı/yukarı kaydırarak parlaklık ayarı",
|
||||
"right_side_volume": "Sağ Taraf Ses Kontrolü",
|
||||
"right_side_volume_description": "Sağ tarafta aşağı/yukarı kaydırarak ses ayarı",
|
||||
"hide_volume_slider": "Ses Ayarını Gizle",
|
||||
"hide_volume_slider_description": "Video oynatıcıda ses ayarını gizle",
|
||||
"hide_brightness_slider": "Parlaklık Ayarını Gizle",
|
||||
"hide_brightness_slider_description": "Video oynatıcıda parlaklık ayarını gizle"
|
||||
"gesture_controls_title": "Gesture Controls",
|
||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||
"left_side_brightness": "Left Side Brightness Control",
|
||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Ses",
|
||||
@@ -195,12 +195,12 @@
|
||||
"none": "Yok",
|
||||
"language": "Dil",
|
||||
"transcode_mode": {
|
||||
"title": "Ses Kod Dönüştürmesi",
|
||||
"description": "Surround sesin (7.1, TrueHD, DTS-HD) nasıl işleneceğini kontrol eder.",
|
||||
"auto": "Oto",
|
||||
"stereo": "Stereo'ya zorla",
|
||||
"5_1": "5.1'e izin ver",
|
||||
"passthrough": "Doğrudan geçiş"
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
@@ -220,60 +220,60 @@
|
||||
"None": "Yok",
|
||||
"OnlyForced": "Sadece Zorunlu"
|
||||
},
|
||||
"text_color": "Metin Rengi",
|
||||
"background_color": "Arkaplan Rengi",
|
||||
"outline_color": "Kenarlık Rengi",
|
||||
"outline_thickness": "Kenarlık kalınlığı",
|
||||
"background_opacity": "Arkaplan Opaklığı",
|
||||
"outline_opacity": "Kenarlık Opaklığı",
|
||||
"bold_text": "Kalın Metin",
|
||||
"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": "Siyah",
|
||||
"Gray": "Gri",
|
||||
"Silver": "Gümüş",
|
||||
"White": "Beyaz",
|
||||
"Maroon": "Kestane",
|
||||
"Red": "Kırmızı",
|
||||
"Fuchsia": "Fuşya",
|
||||
"Yellow": "Sarı",
|
||||
"Olive": "Zeytin yeşili",
|
||||
"Green": "Yeşil",
|
||||
"Teal": "Deniz mavisi",
|
||||
"Lime": "Limon",
|
||||
"Purple": "Mor",
|
||||
"Navy": "Lacivert",
|
||||
"Blue": "Mavi",
|
||||
"Aqua": "Açık Mavi"
|
||||
"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": "Hiçbiri",
|
||||
"Thin": "İnce",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Kalın"
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"subtitle_color": "Altyazı Rengi",
|
||||
"subtitle_background_color": "Arkaplan Rengi",
|
||||
"subtitle_font": "Altyazı Yazı Tipi",
|
||||
"ksplayer_title": "KSPlayer Ayarları",
|
||||
"hardware_decode": "Donanımsal Kod Çözme",
|
||||
"hardware_decode_description": "Video kod çözme için donanımsal hızlandırma kullan. Oynatma sorunları yaşıyorsanız devre dışı bırakın."
|
||||
"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."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Altyazı Ayarları",
|
||||
"hint": "VLC oynatıcı için altyazı görünümünü değiştirin. Değişiklikler bir sonraki oynatmada etkili olacak.",
|
||||
"text_color": "Metin Rengi",
|
||||
"background_color": "Arkaplan Rengi",
|
||||
"background_opacity": "Arkaplan Opaklığı",
|
||||
"outline_color": "Kenarlık Rengi",
|
||||
"outline_opacity": "Kenarlık Opaklığı",
|
||||
"outline_thickness": "Kenarlık Kalınlığı",
|
||||
"bold": "Kalın Metin",
|
||||
"margin": "Alt Kenar Boşluğu"
|
||||
"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 oynatıcısı",
|
||||
"video_player": "Video oynatıcısı",
|
||||
"video_player_description": "iOS'da hangi video oynatıcının kullanılacağını seçin.",
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
@@ -297,7 +297,7 @@
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Deneysel + PiP)"
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
@@ -305,24 +305,24 @@
|
||||
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
|
||||
"default_quality": "Varsayılan kalite",
|
||||
"default_playback_speed": "Varsayılan Oynatma Hızı",
|
||||
"auto_play_next_episode": "Otomatik Sonraki Bölümü Oynat",
|
||||
"max_auto_play_episode_count": "En Fazla Otomatik Oynatılacak Bölüm Sayısı",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Devre dışı"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirmeler"
|
||||
},
|
||||
"music": {
|
||||
"title": "Müzik",
|
||||
"playback_title": "Oynatma",
|
||||
"playback_description": "Müziğin nasıl çalınacağını ayarlayın.",
|
||||
"prefer_downloaded": "İndirilmiş Şarkıları Tercih Et",
|
||||
"caching_title": "Önbellekleme",
|
||||
"caching_description": "Akıcı oynatım için gelecek şarkıları otomatik önbelleğe al.",
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Önden Önbelleklenecek Parça Sayısı",
|
||||
"max_cache_size": "Maksimum Önbellek Boyutu"
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Eklentiler",
|
||||
@@ -345,7 +345,7 @@
|
||||
"order_by": {
|
||||
"DEFAULT": "Varsayılan",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
"POPULARITY": "Popülerlik"
|
||||
"POPULARITY": "Popularity"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
@@ -357,35 +357,35 @@
|
||||
"save_button": "Kaydet",
|
||||
"toasts": {
|
||||
"saved": "Kaydedildi",
|
||||
"refreshed": "Ayarlar sunucudan yeniden alındı"
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats'ı Etkinleştir",
|
||||
"disable_streamystats": "Streamystats'ı Devre Dışı Bırak",
|
||||
"enable_search": "Arama için kullan",
|
||||
"url": "URL Adresi",
|
||||
"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": "Streamystats sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||
"read_more_about_streamystats": "Streamystats hakkında daha fazla bilgi.",
|
||||
"save_button": "Kaydet",
|
||||
"save": "Kaydet",
|
||||
"features_title": "Özellikler",
|
||||
"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": "Film Önerileri",
|
||||
"enable_series_recommendations": "Dizi Önerileri",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Önerilen Filmler",
|
||||
"recommended_series": "Önerilen Diziler",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Kaydedildi",
|
||||
"refreshed": "Ayarlar sunucudan yeniden alındı",
|
||||
"disabled": "Streamystats devre dışı"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
@@ -398,18 +398,18 @@
|
||||
"device_usage": "Cihaz {{availableSpace}}%",
|
||||
"size_used": "{{used}} / {{total}} kullanıldı",
|
||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
|
||||
"music_cache_title": "Müzik Ön Belleği",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Müzik Ön Belleğini Etkinleştir",
|
||||
"clear_music_cache": "Müzik Ön Belleğini Temizle",
|
||||
"music_cache_size": "{{size}} ön belleklendi",
|
||||
"music_cache_cleared": "Müzik ön belleği temizlendi",
|
||||
"delete_all_downloaded_songs": "Tüm İndirilen Müzikleri Sil",
|
||||
"downloaded_songs_size": "{{size}} indirildi",
|
||||
"downloaded_songs_deleted": "İndirilen müzikler silindi"
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Giriş",
|
||||
"title": "Intro",
|
||||
"show_intro": "Tanıtımı Göster",
|
||||
"reset_intro": "Tanıtımı Sıfırla"
|
||||
},
|
||||
@@ -417,7 +417,7 @@
|
||||
"logs_title": "Günlükler",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Düzey",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Günlükler mevcut değil",
|
||||
"delete_all_logs": "Tüm günlükleri sil"
|
||||
},
|
||||
@@ -433,22 +433,22 @@
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Oturumlar",
|
||||
"no_active_sessions": "Aktif Oturum Yok"
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No Active Sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirilenler",
|
||||
"tvseries": "Diziler",
|
||||
"movies": "Filmler",
|
||||
"queue": "Sıra",
|
||||
"other_media": "Diğer medya",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
|
||||
"no_items_in_queue": "Sırada öğe yok",
|
||||
"no_downloaded_items": "İndirilen öğe yok",
|
||||
"delete_all_movies_button": "Tüm Filmleri Sil",
|
||||
"delete_all_tvseries_button": "Tüm Dizileri Sil",
|
||||
"delete_all_button": "Tümünü Sil",
|
||||
"delete_all_other_media_button": "Diğer medyayı sil",
|
||||
"delete_all_other_media_button": "Delete other media",
|
||||
"active_download": "Aktif indirme",
|
||||
"no_active_downloads": "Aktif indirme yok",
|
||||
"active_downloads": "Aktif indirmeler",
|
||||
@@ -465,49 +465,49 @@
|
||||
"failed_to_delete_all_movies": "Filmler silinemedi",
|
||||
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
|
||||
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||
"deleted_media_successfully": "Diğer medya başarıyla silindi!",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "İndirme silindi",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "İndirme iptal edildi",
|
||||
"could_not_delete_download": "İndirme Silinemedi",
|
||||
"download_paused": "İndirme Duraklatıldı",
|
||||
"could_not_pause_download": "İndirme Duraklatılamadı",
|
||||
"download_resumed": "İndirme Devam Ediyor",
|
||||
"could_not_resume_download": "İndirme Devam Ettirilemedi",
|
||||
"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": "İndirme tamamlandı",
|
||||
"download_failed": "İndirme başarısız oldu",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
|
||||
"download_completed_for_item": "{{item}} için indirme tamamlandı",
|
||||
"download_started_for_item": "{{item}} için indirme başladı",
|
||||
"failed_to_start_download": "İndirme başlatılamadı",
|
||||
"item_already_downloading": "{{item}} zaten indiriliyor",
|
||||
"all_files_deleted": "Bütün indirilenler başarıyla silindi",
|
||||
"files_deleted_by_type": "{{count}} {{type}} silindi",
|
||||
"download_started_for_item": "Download Started for {{item}}",
|
||||
"failed_to_start_download": "Failed to start download",
|
||||
"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": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
|
||||
"failed_to_clean_cache_directory": "Önbellek dizini temizlenemedi",
|
||||
"could_not_get_download_url_for_item": "{{itemName}} için indirme URL'si alınamadı",
|
||||
"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": "İndirmelere git",
|
||||
"file_deleted": "{{item}} silindi"
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Seç",
|
||||
"no_trailer_available": "Fragman mevcut değil",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
"subtitle": "Altyazı",
|
||||
"play": "Oynat",
|
||||
"none": "Hiçbiri",
|
||||
"track": "Parça",
|
||||
"cancel": "Vazgeç",
|
||||
"delete": "Sil",
|
||||
"ok": "Tamam",
|
||||
"remove": "Kaldır",
|
||||
"next": "Sonraki",
|
||||
"back": "Geri",
|
||||
"continue": "Devam",
|
||||
"verifying": "Doğrulanıyor..."
|
||||
"play": "Play",
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Ara...",
|
||||
@@ -521,10 +521,10 @@
|
||||
"episodes": "Bölümler",
|
||||
"collections": "Koleksiyonlar",
|
||||
"actors": "Oyuncular",
|
||||
"artists": "Sanatçılar",
|
||||
"albums": "Albümler",
|
||||
"songs": "Şarkılar",
|
||||
"playlists": "Çalma listeleri",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film Talep Et",
|
||||
"request_series": "Dizi Talep Et",
|
||||
"recently_added": "Son Eklenenler",
|
||||
@@ -572,7 +572,7 @@
|
||||
"genres": "Türler",
|
||||
"years": "Yıllar",
|
||||
"sort_by": "Sırala",
|
||||
"filter_by": "Filtrele",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sıralama düzeni",
|
||||
"tags": "Etiketler"
|
||||
}
|
||||
@@ -604,11 +604,11 @@
|
||||
"index": "İndeks:",
|
||||
"continue_watching": "İzlemeye devam et",
|
||||
"go_back": "Geri",
|
||||
"downloaded_file_title": "Bu dosya indirilmiş",
|
||||
"downloaded_file_message": "İndirilmiş dosyayı oynatmak ister misiniz?",
|
||||
"downloaded_file_yes": "Evet",
|
||||
"downloaded_file_no": "Hayır",
|
||||
"downloaded_file_cancel": "Vazgeç"
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sıradaki",
|
||||
@@ -624,7 +624,7 @@
|
||||
"no_similar_items_found": "Benzer öge bulunamadı",
|
||||
"video": "Video",
|
||||
"more_details": "Daha fazla detay",
|
||||
"media_options": "Medya Seçenekleri",
|
||||
"media_options": "Media Options",
|
||||
"quality": "Kalite",
|
||||
"audio": "Ses",
|
||||
"subtitles": "Altyazı",
|
||||
@@ -639,7 +639,7 @@
|
||||
"download_episode": "Bölümü indir",
|
||||
"download_movie": "Filmi indir",
|
||||
"download_x_item": "{{item_count}} tane ögeyi indir",
|
||||
"download_unwatched_only": "Yalnızca İzlenmemişler",
|
||||
"download_unwatched_only": "Unwatched Only",
|
||||
"download_button": "İndir"
|
||||
}
|
||||
},
|
||||
@@ -693,10 +693,10 @@
|
||||
"number_episodes": "Bölüm {{episode_number}}",
|
||||
"born": "Doğum",
|
||||
"appearances": "Görünmeler",
|
||||
"approve": "Onayla",
|
||||
"decline": "Reddet",
|
||||
"requested_by": "{{user}} tarafından istendi",
|
||||
"unknown_user": "Bilinmeyen Kullanıcı",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
|
||||
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
|
||||
@@ -705,10 +705,10 @@
|
||||
"requested_item": "{{item}} talep edildi!",
|
||||
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
|
||||
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!",
|
||||
"request_approved": "İstek Onaylandı!",
|
||||
"request_declined": "İstek Reddedildi!",
|
||||
"failed_to_approve_request": "İsteği Onaylama Başarısız Oldu",
|
||||
"failed_to_decline_request": "İsteği Reddetme Başarısız Oldu"
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -719,127 +719,127 @@
|
||||
"favorites": "Favoriler"
|
||||
},
|
||||
"music": {
|
||||
"title": "Müzik",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Öneriler",
|
||||
"albums": "Albümler",
|
||||
"artists": "Sanatçılar",
|
||||
"playlists": "Çalma listeleri",
|
||||
"tracks": "parçalar"
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tümü"
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Son Eklenenler",
|
||||
"recently_played": "Son Oynatılanlar",
|
||||
"frequently_played": "Sık Oynatılanlar",
|
||||
"explore": "Keşfet",
|
||||
"top_tracks": "En Popülar Parçalar",
|
||||
"play": "Oynat",
|
||||
"shuffle": "Karıştır",
|
||||
"play_top_tracks": "En Çok Oynatılan Parçaları Oynat",
|
||||
"no_suggestions": "Öneri mevcut değil",
|
||||
"no_albums": "Hiç albüm bulunamadı",
|
||||
"no_artists": "Hiç sanatçı bulunamadı",
|
||||
"no_playlists": "Hiç çalma listesi bulunamadı",
|
||||
"album_not_found": "Albüm bulunamadı",
|
||||
"artist_not_found": "Sanatçı bulunamadı",
|
||||
"playlist_not_found": "Çalma listesi bulunamadı",
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Sıradakini Çal",
|
||||
"add_to_queue": "Sıraya Ekle",
|
||||
"add_to_playlist": "Çalma listesine ekle",
|
||||
"download": "İndir",
|
||||
"downloaded": "İndirildi",
|
||||
"downloading": "İndiriliyor...",
|
||||
"cached": "Önbellekte",
|
||||
"delete_download": "İndirmeyi Sil",
|
||||
"delete_cache": "Ön bellekten kaldır",
|
||||
"go_to_artist": "Sanatçıya Git",
|
||||
"go_to_album": "Albüme Git",
|
||||
"add_to_favorites": "Favorilere Ekle",
|
||||
"remove_from_favorites": "Favorilerden Kaldır",
|
||||
"remove_from_playlist": "Çalma Listesinden Kaldır"
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Çalma Listesi Oluştur",
|
||||
"playlist_name": "Çalma Listesi Adı",
|
||||
"enter_name": "Çalma listesi adı girin",
|
||||
"create": "Oluştur",
|
||||
"search_playlists": "Çalma listelerini ara...",
|
||||
"added_to": "Şu çalma listesine eklendi: {{name}}",
|
||||
"added": "Çalma listesine eklendi",
|
||||
"removed_from": "Şu çalma listesinden kaldırıldı: {{name}}",
|
||||
"removed": "Çalma listesinden kaldır",
|
||||
"created": "Çalma listesi oluşturuldu",
|
||||
"create_new": "Yeni Çalma Listesi Oluştur",
|
||||
"failed_to_add": "Çalma listesine eklenemedi",
|
||||
"failed_to_remove": "Çalma listesinden kaldırılamadı",
|
||||
"failed_to_create": "Çalma listesi oluşturulamadı",
|
||||
"delete_playlist": "Çalma Listesini Sil",
|
||||
"delete_confirm": "\"{{name}}\" adlı çalma listesini silmek istediğinize emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleted": "Çalma listesi silindi",
|
||||
"failed_to_delete": "Çalma listesi oluşturulamadı"
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sırala",
|
||||
"alphabetical": "Alfabetik",
|
||||
"date_created": "Oluşturulma Tarihi"
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "İzleme listeleri",
|
||||
"my_watchlists": "İzleme listelerim",
|
||||
"public_watchlists": "Herkese açık izleme listeleri",
|
||||
"create_title": "İzleme listesi oluştur",
|
||||
"edit_title": "İzleme listesini düzenle",
|
||||
"create_button": "İzleme listesi oluştur",
|
||||
"save_button": "Değişiklikleri Kaydet",
|
||||
"delete_button": "Sil",
|
||||
"remove_button": "Kaldır",
|
||||
"cancel_button": "Vazgeç",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "İzleme listesi adını girin",
|
||||
"description_label": "Açıklama",
|
||||
"description_placeholder": "Açıklama girin (isteğe bağlı)",
|
||||
"is_public_label": "Herkese açık izleme listesi",
|
||||
"is_public_description": "Başkalarının da bu izleme listesini görmesine izin ver",
|
||||
"allowed_type_label": "İçerik Türü",
|
||||
"sort_order_label": "Varsayılan Sıralama",
|
||||
"empty_title": "İzleme listesi yok",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "Bu izleme listesi boş",
|
||||
"empty_watchlist_hint": "Kütüphanenizdeki nesneleri bu izleme listesine ekleyin",
|
||||
"not_configured_title": "Streamystats ayarlanmamış",
|
||||
"not_configured_description": "İzleme listelerini kullanmak için ayarlardan Streamystats'ı ayarlayın",
|
||||
"go_to_settings": "Ayarlara git",
|
||||
"add_to_watchlist": "İzleme Listesine Ekle",
|
||||
"remove_from_watchlist": "İzleme Listesinden Kaldır",
|
||||
"select_watchlist": "İzleme Listesi Seç",
|
||||
"create_new": "Yeni İzleme Listesi Oluştur",
|
||||
"item": "öğe",
|
||||
"items": "öğeler",
|
||||
"public": "Herkese Açık",
|
||||
"private": "Özel",
|
||||
"you": "Siz",
|
||||
"by_owner": "Başka kullanıcı tarafından",
|
||||
"not_found": "İzleme listesi bulunamadı",
|
||||
"delete_confirm_title": "İzleme listesini sil",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "İzleme Listesinden Kaldır",
|
||||
"remove_item_message": "{{name}} bu izleme listesinden kaldırılsın mı?",
|
||||
"loading": "İzleme listeleri yükleniyor...",
|
||||
"no_compatible_watchlists": "Uyumlu izleme listesi yok",
|
||||
"create_one_first": "Bu içerik türünü kabul eden bir izleme listesi oluşturun"
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Oynatma Hızı",
|
||||
"apply_to": "Şuna Uygula",
|
||||
"speed": "Hız",
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Yalnızca bu medyada",
|
||||
"show": "Bu dizide",
|
||||
"all": "Bütün medyalarda (varsayılan)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,14 +341,6 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||
loadPluginSettings(),
|
||||
);
|
||||
|
||||
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||
value !== undefined && value !== null && value !== "";
|
||||
|
||||
const getEffectiveSettingValue = <K extends keyof Settings>(
|
||||
settings: Partial<Settings> | null | undefined,
|
||||
settingsKey: K,
|
||||
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
|
||||
|
||||
export const useSettings = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||
@@ -389,13 +381,12 @@ export const useSettings = () => {
|
||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||
if (setting && !setting.locked && setting.value !== undefined) {
|
||||
const settingsKey = key as keyof Settings;
|
||||
const effectiveValue = getEffectiveSettingValue(
|
||||
_settings,
|
||||
settingsKey,
|
||||
);
|
||||
// Apply if forceOverride is true, or if neither persisted settings
|
||||
// nor app defaults provide a meaningful value.
|
||||
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||
// Apply if forceOverride is true, or if user hasn't explicitly set this value
|
||||
if (
|
||||
forceOverride ||
|
||||
_settings[settingsKey] === undefined ||
|
||||
_settings[settingsKey] === ""
|
||||
) {
|
||||
(updates as any)[settingsKey] = setting.value;
|
||||
}
|
||||
}
|
||||
@@ -447,22 +438,28 @@ export const useSettings = () => {
|
||||
|
||||
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||
// If admin sets locked to false but provides a value,
|
||||
// use persisted settings first, then app defaults, and only fallback on the
|
||||
// plugin value when neither provides a meaningful value.
|
||||
// use user settings first and fallback on admin setting if required.
|
||||
const settings: Settings = useMemo(() => {
|
||||
const unlockedPluginDefaults: Partial<Settings> = {};
|
||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
if (
|
||||
!locked &&
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||
}
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: hasMeaningfulSettingValue(effectiveValue)
|
||||
? effectiveValue
|
||||
: value;
|
||||
: (_settings?.[settingsKey] ?? value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
chapterMarkers,
|
||||
chapterNameAt,
|
||||
chapterStartsMs,
|
||||
currentChapterIndex,
|
||||
formatChapterTime,
|
||||
sortedChapters,
|
||||
} from "./chapters";
|
||||
|
||||
// Helper: a ChapterInfo with a start in milliseconds.
|
||||
const ch = (ms: number, name?: string) => ({
|
||||
StartPositionTicks: ms * 10000,
|
||||
Name: name,
|
||||
});
|
||||
|
||||
describe("chapterMarkers", () => {
|
||||
test("maps chapters to position + percent", () => {
|
||||
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
{ positionMs: 30_000, percent: 25 },
|
||||
{ positionMs: 60_000, percent: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops chapters past the duration", () => {
|
||||
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns [] when duration is 0 or chapters missing", () => {
|
||||
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
||||
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
||||
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
||||
});
|
||||
|
||||
test("excludes a chapter exactly at the duration", () => {
|
||||
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
||||
{ positionMs: 0, percent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips chapters with no StartPositionTicks", () => {
|
||||
expect(
|
||||
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
||||
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentChapterIndex", () => {
|
||||
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
||||
test("returns the chapter containing the position", () => {
|
||||
expect(currentChapterIndex(0, chapters)).toBe(0);
|
||||
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
||||
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
||||
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
||||
});
|
||||
test("returns -1 before the first chapter and for no chapters", () => {
|
||||
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
||||
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
||||
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortedChapters", () => {
|
||||
test("pairs each chapter with its ms start, sorted ascending", () => {
|
||||
const a = ch(60_000, "C");
|
||||
const b = ch(0, "A");
|
||||
const c = ch(30_000, "B");
|
||||
expect(sortedChapters([a, b, c])).toEqual([
|
||||
{ chapter: b, positionMs: 0 },
|
||||
{ chapter: c, positionMs: 30_000 },
|
||||
{ chapter: a, positionMs: 60_000 },
|
||||
]);
|
||||
});
|
||||
test("returns [] for null/undefined", () => {
|
||||
expect(sortedChapters(null)).toEqual([]);
|
||||
expect(sortedChapters(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterStartsMs", () => {
|
||||
test("returns sorted ms positions", () => {
|
||||
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
|
||||
0, 30_000, 60_000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips entries without StartPositionTicks", () => {
|
||||
expect(
|
||||
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
|
||||
).toEqual([0, 30_000]);
|
||||
});
|
||||
|
||||
test("returns [] for null/undefined/empty", () => {
|
||||
expect(chapterStartsMs(null)).toEqual([]);
|
||||
expect(chapterStartsMs(undefined)).toEqual([]);
|
||||
expect(chapterStartsMs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterNameAt", () => {
|
||||
const named = [
|
||||
{ StartPositionTicks: 0, Name: "Intro" },
|
||||
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
|
||||
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
|
||||
];
|
||||
|
||||
test("returns the chapter name for the active position", () => {
|
||||
expect(chapterNameAt(0, named)).toBe("Intro");
|
||||
expect(chapterNameAt(15_000, named)).toBe("Intro");
|
||||
expect(chapterNameAt(45_000, named)).toBe("Action");
|
||||
expect(chapterNameAt(90_000, named)).toBe("Outro");
|
||||
});
|
||||
|
||||
test("returns null before the first chapter", () => {
|
||||
expect(chapterNameAt(-1, named)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for null/undefined/empty chapters", () => {
|
||||
expect(chapterNameAt(10_000, null)).toBeNull();
|
||||
expect(chapterNameAt(10_000, undefined)).toBeNull();
|
||||
expect(chapterNameAt(10_000, [])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the active chapter has no Name", () => {
|
||||
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChapterTime", () => {
|
||||
test("formats m:ss and h:mm:ss", () => {
|
||||
expect(formatChapterTime(65_000)).toBe("1:05");
|
||||
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
||||
expect(formatChapterTime(-100)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
||||
* unit-testable under `bun test`.
|
||||
*/
|
||||
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
|
||||
export interface ChapterMarker {
|
||||
/** Chapter start, in milliseconds. */
|
||||
positionMs: number;
|
||||
/** Chapter start as a percentage (0-100) of the media duration. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface ChapterEntry {
|
||||
chapter: ChapterInfo;
|
||||
/** Chapter start, in milliseconds. */
|
||||
positionMs: number;
|
||||
}
|
||||
|
||||
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
||||
export const sortedChapters = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): ChapterEntry[] =>
|
||||
(chapters ?? [])
|
||||
.filter((c) => c.StartPositionTicks != null)
|
||||
.map((chapter) => ({
|
||||
chapter,
|
||||
positionMs: ticksToMs(chapter.StartPositionTicks),
|
||||
}))
|
||||
.sort((a, b) => a.positionMs - b.positionMs);
|
||||
|
||||
/** Chapter start positions in milliseconds, ascending. */
|
||||
export const chapterStartsMs = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): number[] =>
|
||||
(chapters ?? [])
|
||||
.filter((c) => c.StartPositionTicks != null)
|
||||
.map((c) => ticksToMs(c.StartPositionTicks))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
||||
export const chapterMarkers = (
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
durationMs: number,
|
||||
): ChapterMarker[] => {
|
||||
if (durationMs <= 0) return [];
|
||||
return chapterStartsMs(chapters)
|
||||
.filter((ms) => ms >= 0 && ms < durationMs)
|
||||
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
||||
};
|
||||
|
||||
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
||||
export const currentChapterIndex = (
|
||||
positionMs: number,
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): number => {
|
||||
const starts = chapterStartsMs(chapters);
|
||||
let index = -1;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (positionMs >= starts[i]) index = i;
|
||||
else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
|
||||
export const chapterNameAt = (
|
||||
positionMs: number,
|
||||
chapters: ChapterInfo[] | null | undefined,
|
||||
): string | null => {
|
||||
// Sort once, derive both the active index and the entry from the same array
|
||||
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
|
||||
// instead of two is worth the duplication of the index loop here.
|
||||
const sorted = sortedChapters(chapters);
|
||||
let idx = -1;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (positionMs >= sorted[i].positionMs) idx = i;
|
||||
else break;
|
||||
}
|
||||
if (idx < 0) return null;
|
||||
const name = sorted[idx]?.chapter.Name;
|
||||
return name && name.length > 0 ? name : null;
|
||||
};
|
||||
|
||||
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
||||
export const formatChapterTime = (positionMs: number): string => {
|
||||
const total = Math.max(0, Math.floor(positionMs / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${minutes}:${pad(seconds)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user