Compare commits
38 Commits
i18n/fr-co
...
refactor/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2bab57bb | ||
|
|
f97852ae98 | ||
|
|
96116e0451 | ||
|
|
938918fa06 | ||
|
|
a4b6f456f2 | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
168bf2e54e | ||
|
|
6f0230c2ca | ||
|
|
d12beee529 | ||
|
|
02ffac167b | ||
|
|
4eb734c99f | ||
|
|
b7bae0072f | ||
|
|
1685571406 | ||
|
|
36ed7539a2 | ||
|
|
8f82ac481a | ||
|
|
a242ff69fd | ||
|
|
b0c9dc114c | ||
|
|
89190b5054 | ||
|
|
394262af5a | ||
|
|
3dbe5bb64c | ||
|
|
801ab275ab | ||
|
|
f7033e7abb | ||
|
|
0d796d01b8 | ||
|
|
46d96d5965 | ||
|
|
7d16e7d5c7 | ||
|
|
ceb9b5a1ae | ||
|
|
1144ff5049 | ||
|
|
4d508a4315 | ||
|
|
915a4febbb | ||
|
|
88163eb531 | ||
|
|
46bd2a784e | ||
|
|
0a36fdfbec | ||
|
|
45d1f752d6 | ||
|
|
54ee507209 | ||
|
|
338fb9713b | ||
|
|
939fd2512d |
29
.gitattributes
vendored
@@ -1 +1,28 @@
|
|||||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
||||||
|
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
||||||
|
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows-only scripts must stay CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Binary assets — never touched / never normalised
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.pdf binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jks binary
|
||||||
|
*.p12 binary
|
||||||
|
|||||||
22
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: "🐛 Bug Report"
|
name: "🐛 Bug Report"
|
||||||
description: Create a report to help us improve
|
description: Create a report to help Streamyfin improve
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels:
|
labels:
|
||||||
- "🐛 bug"
|
- "🐛 bug"
|
||||||
@@ -36,7 +36,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: What happened?
|
||||||
description: A clear and concise description of what the bug is.
|
description: A clear and concise description of what the bug is.
|
||||||
placeholder: Describe what happened in detail.
|
placeholder: Describe what happened in detail, the more precise the better.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Which device and operating system are you using?
|
label: Which device and operating system are you using?
|
||||||
description: Please provide your device model and OS version
|
description: Please provide your device model and OS version
|
||||||
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
|
placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -75,11 +75,11 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 0.47.1
|
- 0.54.1
|
||||||
- 0.30.2
|
- 0.51.0
|
||||||
- older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -90,9 +90,9 @@ body:
|
|||||||
label: Jellyfin Server Information
|
label: Jellyfin Server Information
|
||||||
description: Please provide details about your Jellyfin server
|
description: Please provide details about your Jellyfin server
|
||||||
placeholder: |
|
placeholder: |
|
||||||
- Jellyfin Server Version: e.g. 10.10.7
|
- Jellyfin Server Version: e.g. 10.11.10
|
||||||
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
|
- Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
|
||||||
- Connection: e.g. Local network, Remote via domain, VPN
|
- Connection: e.g. Local network, remote via domain, VPN
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: screenshots
|
||||||
@@ -104,7 +104,7 @@ body:
|
|||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant logs (if available)
|
label: Relevant logs (if available)
|
||||||
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
|
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
|
||||||
render: shell
|
render: shell
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
54
.github/renovate.json
vendored
@@ -44,22 +44,42 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lockFileMaintenance": {
|
"vulnerabilityAlerts": {
|
||||||
"vulnerabilityAlerts": {
|
"enabled": true,
|
||||||
"enabled": true,
|
"addLabels": ["security", "vulnerability"],
|
||||||
"addLabels": ["security", "vulnerability"],
|
"assigneesFromCodeOwners": true,
|
||||||
"assigneesFromCodeOwners": true,
|
"commitMessageSuffix": " [SECURITY]"
|
||||||
"commitMessageSuffix": " [SECURITY]"
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"expo",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-native",
|
||||||
|
"react-native-web",
|
||||||
|
"expo-*",
|
||||||
|
"@expo/*"
|
||||||
|
],
|
||||||
|
"groupName": "Expo SDK",
|
||||||
|
"dependencyDashboardApproval": true
|
||||||
},
|
},
|
||||||
"packageRules": [
|
{
|
||||||
{
|
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
"matchManagers": ["github-actions"],
|
||||||
"matchManagers": ["github-actions"],
|
"groupName": "CI dependencies",
|
||||||
"groupName": "CI dependencies",
|
"groupSlug": "ci-deps",
|
||||||
"groupSlug": "ci-deps",
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
"automerge": true
|
||||||
"automerge": true
|
},
|
||||||
}
|
{
|
||||||
]
|
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
||||||
}
|
"matchDatasources": ["maven"],
|
||||||
|
"registryUrls": [
|
||||||
|
"https://dl.google.com/dl/android/maven2/",
|
||||||
|
"https://repo.maven.apache.org/maven2/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
.github/workflows/build-apps.yml
vendored
@@ -11,6 +11,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
|
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||||
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
|
# run (artifacts + logs) without needing Expo access.
|
||||||
|
env:
|
||||||
|
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||||
|
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
@@ -33,7 +42,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -116,7 +125,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -187,7 +196,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -219,10 +228,10 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -231,7 +240,9 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
run: eas build -p ios --local --non-interactive
|
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
||||||
|
# the production version tier and stops them inflating the store build counter.
|
||||||
|
run: eas build -p ios --local --non-interactive --profile ci
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -252,7 +263,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -284,7 +295,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -312,7 +323,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -344,10 +355,10 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -356,7 +367,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
run: eas build -p ios --local --non-interactive
|
run: eas build -p ios --local --non-interactive --profile ci_tv
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -380,7 +391,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -412,7 +423,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|||||||
2
.github/workflows/conflict.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚩 Apply merge conflict label
|
- name: 🚩 Apply merge conflict label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
102
.github/workflows/crowdin.yml
vendored
@@ -1,51 +1,51 @@
|
|||||||
name: 🌐 Translation Sync
|
name: 🌐 Translation Sync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop]
|
branches: [develop]
|
||||||
paths:
|
paths:
|
||||||
- "translations/**"
|
- "translations/**"
|
||||||
- "crowdin.yml"
|
- "crowdin.yml"
|
||||||
- "i18n.ts"
|
- "i18n.ts"
|
||||||
- ".github/workflows/crowdin.yml"
|
- ".github/workflows/crowdin.yml"
|
||||||
# Run weekly to pull new translations
|
# Run weekly to pull new translations
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-translations:
|
sync-translations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
localization_branch_name: I10n_crowdin_translations
|
localization_branch_name: I10n_crowdin_translations
|
||||||
create_pull_request: true
|
create_pull_request: true
|
||||||
pull_request_title: "feat: New Crowdin Translations"
|
pull_request_title: "feat: New Crowdin Translations"
|
||||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||||
pull_request_base_branch_name: "develop"
|
pull_request_base_branch_name: "develop"
|
||||||
pull_request_labels: "🌐 translation"
|
pull_request_labels: "🌐 translation"
|
||||||
# Quality control options
|
# Quality control options
|
||||||
skip_untranslated_strings: false
|
skip_untranslated_strings: false
|
||||||
skip_untranslated_files: false
|
skip_untranslated_files: false
|
||||||
export_only_approved: false
|
export_only_approved: false
|
||||||
# Commit customization
|
# Commit customization
|
||||||
commit_message: "feat(i18n): update translations from Crowdin"
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|||||||
38
.github/workflows/detect-duplicate.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: 🔁 Detect Duplicate Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: detect-duplicate-${{ github.event.issue.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect:
|
||||||
|
name: 🔍 Find similar issues
|
||||||
|
if: github.actor != 'github-actions[bot]'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 🔍 Detect duplicate issues
|
||||||
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
6
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
60
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: 🛡️ Trivy Security Scan
|
||||||
|
|
||||||
|
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
||||||
|
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
||||||
|
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
||||||
|
# upload needs a write token that fork PRs don't get).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
schedule:
|
||||||
|
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: trivy-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trivy:
|
||||||
|
name: 🔎 Filesystem scan
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
||||||
|
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||||
|
- name: 🗓️ Compute weekly Trivy cache key
|
||||||
|
id: trivy-cache-key
|
||||||
|
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 💾 Cache Trivy vulnerability DB
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/trivy
|
||||||
|
key: ${{ steps.trivy-cache-key.outputs.value }}
|
||||||
|
restore-keys: trivy-db-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: 🔎 Run Trivy filesystem scan
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: vuln,secret,misconfig
|
||||||
|
ignore-unfixed: true
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
|
||||||
|
- name: 📤 Upload results to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
2
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
|||||||
15
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
# Dependencies and Package Managers
|
# Dependencies and Package Managers
|
||||||
node_modules/
|
node_modules/
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
bun.lockb
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -13,18 +12,12 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
|
||||||
/iosmobile
|
|
||||||
/androidmobile
|
|
||||||
/androidtv
|
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|
||||||
# Module-specific Builds
|
# Native module build outputs (any module)
|
||||||
modules/mpv-player/android/build
|
modules/*/android/build/
|
||||||
modules/player/android
|
|
||||||
modules/hls-downloader/android/build
|
|
||||||
|
|
||||||
# Generated Applications
|
# Generated Applications
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
@@ -69,10 +62,6 @@ certs/
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
/modules/sf-player/android/build
|
|
||||||
/modules/music-controls/android/build
|
|
||||||
modules/background-downloader/android/build/*
|
|
||||||
/modules/mpv-player/android/build
|
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js)
|
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||||
- Use functional React components with hooks
|
- Use functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -1,3 +1,47 @@
|
|||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
|
const git = (args) => {
|
||||||
|
try {
|
||||||
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMeta = {
|
||||||
|
commit:
|
||||||
|
(
|
||||||
|
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
||||||
|
process.env.GITHUB_SHA ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
||||||
|
git(["rev-parse", "HEAD"]) ||
|
||||||
|
""
|
||||||
|
).slice(0, 7) || null,
|
||||||
|
branch:
|
||||||
|
process.env.EAS_BUILD_GIT_BRANCH ||
|
||||||
|
process.env.GITHUB_HEAD_REF ||
|
||||||
|
process.env.GITHUB_REF_NAME ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
||||||
|
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
||||||
|
null,
|
||||||
|
profile:
|
||||||
|
process.env.EAS_BUILD_PROFILE ||
|
||||||
|
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||||
|
null,
|
||||||
|
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
||||||
|
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
||||||
|
runNumber:
|
||||||
|
process.env.GITHUB_RUN_NUMBER ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
||||||
|
null,
|
||||||
|
builtAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
|
|||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.extra = { ...config.extra, build: buildMeta };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -161,9 +161,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
/>
|
/>
|
||||||
{!itemType ? (
|
{!itemType ? (
|
||||||
<View className='flex-1 items-center justify-center px-6'>
|
<View className='flex-1 items-center justify-center px-6'>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<View className='justify-center items-center h-full'>
|
<View className='justify-center items-center h-full'>
|
||||||
@@ -194,7 +192,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
{t("home.no_items", { defaultValue: "No items" })}
|
{t("home.no_items")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ export default function DownloadsPage() {
|
|||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
t("home.downloads.toasts.deleted_all_series_successfully"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
|
|||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.tvseries")}
|
{t("home.downloads.series")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
<Text className='text-xs font-bold'>
|
<Text className='text-xs font-bold'>
|
||||||
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
|
|||||||
{t("home.downloads.delete_all_movies_button")}
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='purple' onPress={deleteShows}>
|
<Button color='purple' onPress={deleteShows}>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
{t("home.downloads.delete_all_series_button")}
|
||||||
</Button>
|
</Button>
|
||||||
{otherMedia.length > 0 && (
|
{otherMedia.length > 0 && (
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
<Button color='purple' onPress={deleteOtherMedia}>
|
||||||
|
|||||||
@@ -59,17 +59,19 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<View className='mb-4'>
|
{Platform.OS !== "ios" && (
|
||||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
<View className='mb-4'>
|
||||||
<ListItem
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
onPress={() =>
|
<ListItem
|
||||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
onPress={() =>
|
||||||
}
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
title={t("pairing.pair_with_phone")}
|
}
|
||||||
textColor='blue'
|
title={t("pairing.pair_with_phone")}
|
||||||
/>
|
textColor='blue'
|
||||||
</ListGroup>
|
/>
|
||||||
</View>
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -179,18 +179,15 @@ export default function SettingsTV() {
|
|||||||
// Handle clearing all cache in the entire app
|
// Handle clearing all cache in the entire app
|
||||||
const handleClearCache = async () => {
|
const handleClearCache = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
|
t("home.settings.storage.clear_all_cache_confirm"),
|
||||||
t(
|
t("home.settings.storage.clear_all_cache_confirm_desc"),
|
||||||
"home.settings.storage.clear_all_cache_confirm_desc",
|
|
||||||
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
|
||||||
),
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: t("common.cancel", "Cancel"),
|
text: t("common.cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t("common.ok", "OK"),
|
text: t("common.ok"),
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
// 1. Clear React Query Cache (memory & MMKV)
|
// 1. Clear React Query Cache (memory & MMKV)
|
||||||
@@ -243,11 +240,8 @@ export default function SettingsTV() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to clear cache:", error);
|
console.error("Failed to clear cache:", error);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.toasts.error_deleting_files", "Error"),
|
t("home.settings.toasts.error_deleting_files"),
|
||||||
t(
|
t("home.settings.storage.clear_all_cache_error_desc"),
|
||||||
"home.settings.storage.clear_all_cache_error_desc",
|
|
||||||
"An error occurred while clearing the cache.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
// Update local state with new values
|
// Update local state with new values
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
setUrl(newUrl);
|
setUrl(newUrl);
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View className='pl-1.5'>
|
<View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const Layout = () => {
|
|||||||
keyboardDismissMode='none'
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
|
tabBarActiveTintColor: "#FFFFFF",
|
||||||
|
tabBarInactiveTintColor: "#9CA3AF",
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export default function TabLayout() {
|
|||||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.star.png")
|
||||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
: (_e) => ({ sfSymbol: "list.star" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -112,7 +112,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
|
||||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -123,8 +123,8 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/link.png")
|
||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: (_e) => ({ sfSymbol: "link" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -134,7 +134,7 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !Platform.isTV,
|
tabBarItemHidden: !Platform.isTV,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
|
? (_e) => require("@/assets/icons/gearshape.fill.png")
|
||||||
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
|
setItem(null);
|
||||||
|
setDownloadedItem(null);
|
||||||
|
// Clear the previous episode's stream so the loader gate stays closed
|
||||||
|
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||||
|
setStream(null);
|
||||||
fetchItemData();
|
fetchItemData();
|
||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure item matches the current itemId to avoid race conditions
|
||||||
|
if (item.Id !== itemId) {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem?.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
|
offline,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -427,21 +439,15 @@ export default function DirectPlayerPage() {
|
|||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api).onPlaybackStopped({
|
await getPlaystateApi(api).reportPlaybackStopped({
|
||||||
itemId: item.Id,
|
playbackStopInfo: {
|
||||||
mediaSourceId: mediaSourceId,
|
ItemId: item.Id,
|
||||||
positionTicks: currentTimeInTicks,
|
MediaSourceId: mediaSourceId,
|
||||||
playSessionId: stream.sessionId,
|
PositionTicks: currentTimeInTicks,
|
||||||
|
PlaySessionId: stream.sessionId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, [
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
api,
|
|
||||||
item,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
progress,
|
|
||||||
offline,
|
|
||||||
revalidateProgressCache,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
// Update URL with final playback position before stopping
|
// Update URL with final playback position before stopping
|
||||||
@@ -459,9 +465,10 @@ export default function DirectPlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
return () => {
|
return () => {
|
||||||
|
reportPlaybackStopped();
|
||||||
beforeRemoveListener();
|
beforeRemoveListener();
|
||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop, reportPlaybackStopped]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(():
|
const currentPlayStateInfo = useCallback(():
|
||||||
| PlaybackProgressInfo
|
| PlaybackProgressInfo
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 26 KiB |
BIN
assets/icons/gearshape.fill.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
assets/icons/link.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
assets/icons/list.star.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/rectangle.stack.fill.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
1
assets/icons/seerr-logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,65 +0,0 @@
|
|||||||
<svg
|
|
||||||
type="certified"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
preserveAspectRatio="xMidYMid"
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
>
|
|
||||||
<g transform="translate(2.29, 0)">
|
|
||||||
<path
|
|
||||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
|
||||||
id="Fill-2"
|
|
||||||
fill="#00912D"
|
|
||||||
></path>
|
|
||||||
<mask id="mask-2" fill="white">
|
|
||||||
<polygon
|
|
||||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
|
||||||
></polygon>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
|
||||||
fill="#FFD700"
|
|
||||||
mask="url(#mask-2)"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
|
||||||
fill="#FA6E0F"
|
|
||||||
mask="url(#mask-2)"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
|
||||||
fill="#0AC855"
|
|
||||||
mask="url(#mask-2)"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
|
||||||
fill="#0B4902"
|
|
||||||
></path>
|
|
||||||
<g transform="translate(0, 20.57)">
|
|
||||||
<mask id="mask-4" fill="white">
|
|
||||||
<polygon
|
|
||||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
|
||||||
></polygon>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
|
||||||
fill="#FA3200"
|
|
||||||
mask="url(#mask-4)"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
|
||||||
fill="#0AC855"
|
|
||||||
mask="url(#mask-4)"
|
|
||||||
></path>
|
|
||||||
</g>
|
|
||||||
<path
|
|
||||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
|
||||||
fill="#00912D"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
|
||||||
fill="#0B4902"
|
|
||||||
></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 50 KiB |
1
assets/images/rt_aud_fresh.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
1
assets/images/rt_aud_rotten.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
assets/images/rt_fresh.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
assets/images/rt_rotten.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
assets/images/tmdb_logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
6
bun.lock
@@ -108,7 +108,7 @@
|
|||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.7",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
@@ -969,7 +969,7 @@
|
|||||||
|
|
||||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||||
|
|
||||||
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
|
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||||
|
|
||||||
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
||||||
|
|
||||||
@@ -1599,7 +1599,7 @@
|
|||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
||||||
|
|
||||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/seerr-logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='shrink ml-2'>
|
<View className='shrink ml-2'>
|
||||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
<Text className='font-bold mb-1'>Seerr</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
type LayoutChangeEvent,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
|
||||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
|
||||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
|
||||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
|
||||||
const [triggerSize, setTriggerSize] = useState<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = e.nativeEvent.layout;
|
|
||||||
setTriggerSize((prev) =>
|
|
||||||
prev && prev.width === width && prev.height === height
|
|
||||||
? prev
|
|
||||||
: { width, height },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
|
||||||
// height depends on the parent while the parent depends on the Host — a
|
|
||||||
// circular dependency that collapses to 0 for any selector nested more than
|
|
||||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
|
||||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
|
||||||
// fills a concrete box.
|
|
||||||
return (
|
return (
|
||||||
<View style={triggerSize ?? { opacity: 0 }}>
|
<View>
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
|
||||||
<View
|
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
|
||||||
pointerEvents='none'
|
|
||||||
aria-hidden
|
|
||||||
onLayout={handleMeasureTrigger}
|
|
||||||
>
|
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
<Image
|
<Image
|
||||||
source={
|
source={
|
||||||
item.CriticRating < 60
|
item.CriticRating < 60
|
||||||
? require("@/assets/images/rotten-tomatoes.png")
|
? require("@/assets/images/rt_rotten.svg")
|
||||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
: require("@/assets/images/rt_fresh.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
|
|||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={
|
source={
|
||||||
data?.criticsRating === "Rotten"
|
data?.criticsRating === "Rotten"
|
||||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
? require("@/assets/images/rt_rotten.svg")
|
||||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
: require("@/assets/images/rt_fresh.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
|
|||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={
|
source={
|
||||||
data?.audienceRating === "Spilled"
|
data?.audienceRating === "Spilled"
|
||||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
? require("@/assets/images/rt_aud_rotten.svg")
|
||||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
: require("@/assets/images/rt_aud_fresh.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
|
|||||||
iconLeft={
|
iconLeft={
|
||||||
<Image
|
<Image
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
source={require("@/assets/images/tmdb_logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
|
|||||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else {
|
} else if (isPresentedRef.current) {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index === -1) {
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selected === -1 && streamType === "Subtitle"
|
{selected === -1 && streamType === "Subtitle"
|
||||||
? t("common.none")
|
? t("common.none")
|
||||||
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import {
|
import {
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
chapterStartsMs,
|
chapterStartsMs,
|
||||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterListProps) {
|
}: ChapterListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const safeArea = useControlsSafeAreaInsets();
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
@@ -79,7 +81,17 @@ function ChapterListComponent({
|
|||||||
supportedOrientations={["portrait", "landscape"]}
|
supportedOrientations={["portrait", "landscape"]}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
<Pressable
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
style={[
|
||||||
|
styles.sheet,
|
||||||
|
{
|
||||||
|
marginLeft: safeArea.left,
|
||||||
|
marginRight: safeArea.right,
|
||||||
|
paddingBottom: safeArea.bottom,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
|
|||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.background,
|
backgroundColor: Colors.background,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Pair with Phone */}
|
{/* Pair with Phone */}
|
||||||
{onStartPairing && (
|
{Platform.OS !== "ios" && onStartPairing && (
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={onStartPairing}
|
onPress={onStartPairing}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TVSubmitButton
|
<TVSubmitButton
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
label={t("login.login")}
|
label={t("login.login_button")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const hasMovies = movieResults && movieResults.length > 0;
|
|
||||||
const hasTv = tvResults && tvResults.length > 0;
|
|
||||||
const hasPersons = personResults && personResults.length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||||
|
keeps focus while typing, otherwise the first result would re-grab
|
||||||
|
focus on every keystroke as results re-render. The user navigates
|
||||||
|
down to the grid manually. */}
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={hasMovies}
|
isFirstSection={false}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={!hasMovies && hasTv}
|
isFirstSection={false}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
isFirstSection={false}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
@@ -231,23 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
||||||
module). It renders the native search bar + grid keyboard and
|
TextInput fallback on Android TV (the native module is Apple-only). */}
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
{Platform.OS === "ios" ? (
|
||||||
our own results grid renders below. */}
|
<View
|
||||||
<View
|
style={{
|
||||||
style={{
|
marginBottom: 24,
|
||||||
marginBottom: 24,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
}}
|
||||||
height: SEARCH_AREA_HEIGHT,
|
>
|
||||||
}}
|
{/* No horizontal margin here: the native tvOS search bar centers
|
||||||
>
|
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||||
<TvSearchView
|
<TvSearchView
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
fontSize: 28,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
placeholderTextColor='rgba(255,255,255,0.4)'
|
||||||
|
onChangeText={setSearch}
|
||||||
|
defaultValue=''
|
||||||
|
autoFocus={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -280,13 +305,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section, index) => (
|
{sections.map((section) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
isFirstSection={index === 0}
|
// Never auto-focus a result. The native search field owns focus
|
||||||
|
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||||
|
// every keystroke as results re-render. User navigates down to the
|
||||||
|
// grid manually.
|
||||||
|
isFirstSection={false}
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentInset={{
|
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||||
left: edgePadding,
|
// contentOffset only applies on initial mount; since this FlatList is
|
||||||
right: edgePadding,
|
// reused across searches (stable key), a second search left the inset
|
||||||
}}
|
// without the offset and the grid snapped flush to the left edge.
|
||||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: edgePadding,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const isOffline = useOfflineMode();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
// Read the live (cached) downloads DB inside the query rather than the
|
||||||
|
// provider's downloadedItems snapshot, so refetches after
|
||||||
|
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||||
|
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.setParams({ id: _item.Id });
|
router.setParams({ id: _item.Id });
|
||||||
}}
|
}}
|
||||||
className={`flex flex-col w-44
|
className={`flex flex-col w-44
|
||||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
<ListItem
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
|
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||||
|
>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -229,7 +229,10 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
disabled={
|
||||||
|
!settings.autoPlayNextEpisode ||
|
||||||
|
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getVersionInfo } from "@/utils/version";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const version =
|
// Graduated build identifier — see utils/version.ts:
|
||||||
Application?.nativeApplicationVersion ||
|
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
||||||
Application?.nativeBuildVersion ||
|
const { display: version } = getVersionInfo();
|
||||||
"N/A";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
@@ -75,9 +75,6 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chapter props
|
|
||||||
chapterPositions?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
chapterPositions = [],
|
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
left: insets.left,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
bottom: Math.max(insets.bottom - 17, 0),
|
||||||
bottom:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? Math.max(insets.bottom - 17, 0)
|
|
||||||
: 0,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center ml-4'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
right: insets.right,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
|
|||||||
hasNextChapter,
|
hasNextChapter,
|
||||||
goToPreviousChapter,
|
goToPreviousChapter,
|
||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
chapterPositions,
|
|
||||||
} = useChapterNavigation({
|
} = useChapterNavigation({
|
||||||
chapters: item.Chapters,
|
chapters: item.Chapters,
|
||||||
progress,
|
progress,
|
||||||
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
|
|||||||
{ applyLanguagePreferences: true },
|
{ applyLanguagePreferences: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||||
|
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||||
|
router.setParams({
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue?.toString(),
|
bitrateValue: bitrateValue?.toString(),
|
||||||
playbackPosition:
|
playbackPosition:
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
});
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
},
|
},
|
||||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
[
|
||||||
|
settings,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
offline,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
chapterPositions={chapterPositions}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -17,10 +16,10 @@ import {
|
|||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getDownloadedEpisodesForSeason,
|
getDownloadedEpisodesForSeason,
|
||||||
getDownloadedSeasonNumbers,
|
getDownloadedSeasonNumbers,
|
||||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Read the live (cached) downloads DB inside the query rather than the
|
||||||
|
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||||
|
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||||
|
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||||
|
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop:
|
paddingTop: insets.top,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
paddingLeft: insets.left,
|
||||||
paddingLeft:
|
paddingRight: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
|
||||||
paddingRight:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const { orientation, lockOrientation } = useOrientation();
|
const { orientation, lockOrientation } = useOrientation();
|
||||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
top: insets.top,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
|
|
||||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { settings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
left: Math.max(insets.left, 48) + 20,
|
left: Math.max(insets.left, 48) + 20,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
top:
|
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
|
||||||
left:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
export default {
|
const MediaTypes = {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||||
|
|
||||||
|
export default MediaTypes;
|
||||||
8
eas.json
@@ -97,6 +97,14 @@
|
|||||||
"credentialsSource": "local",
|
"credentialsSource": "local",
|
||||||
"config": "ios-production.yml"
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"extends": "production",
|
||||||
|
"autoIncrement": false
|
||||||
|
},
|
||||||
|
"ci_tv": {
|
||||||
|
"extends": "production_tv",
|
||||||
|
"autoIncrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
type EdgeInsets,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||||
|
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||||
|
* returns zero insets so controls can sit flush against the screen edges.
|
||||||
|
*/
|
||||||
|
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||||
|
* resolves to an empty payload under RN's New Architecture.
|
||||||
|
*/
|
||||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
let blob: Blob;
|
const tmpFile = new File(
|
||||||
|
Paths.cache,
|
||||||
|
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Fetch the data from the URL
|
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||||
const response = await fetch(url);
|
idempotent: true,
|
||||||
blob = await response.blob();
|
});
|
||||||
|
return await downloaded.base64();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Error fetching image:", error);
|
console.warn("Error fetching image:", error);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (tmpFile.exists) tmpFile.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a FileReader instance
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
// Convert blob to base64
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
reader.onloadend = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
// Extract the base64 string (remove the data URL prefix)
|
|
||||||
const base64 = reader.result.split(",")[1];
|
|
||||||
resolve(base64);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to convert image to base64"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveImage = useCallback(
|
const saveImage = useCallback(
|
||||||
|
|||||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive prev/next from the current item's real position in the adjacent
|
||||||
|
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||||
|
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||||
|
* episode it can still return the current item as the first/last entry — so
|
||||||
|
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||||
|
*/
|
||||||
|
const currentIndex = useMemo(
|
||||||
|
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||||
|
[adjacentItems, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A neighbour is only navigable if it has an actual media file (not a
|
||||||
|
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||||
|
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||||
|
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||||
|
|
||||||
const previousItem = useMemo(() => {
|
const previousItem = useMemo(() => {
|
||||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
if (!adjacentItems || currentIndex <= 0) return null;
|
||||||
return null;
|
const candidate = adjacentItems[currentIndex - 1];
|
||||||
}
|
return isNavigable(candidate) ? candidate : null;
|
||||||
|
}, [adjacentItems, currentIndex, item]);
|
||||||
if (adjacentItems.length === 2) {
|
|
||||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacentItems[0];
|
|
||||||
}, [adjacentItems, item]);
|
|
||||||
|
|
||||||
/** The next item in the series */
|
/** The next item in the series */
|
||||||
const nextItem = useMemo(() => {
|
const nextItem = useMemo(() => {
|
||||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
if (!adjacentItems || currentIndex < 0) return null;
|
||||||
return null;
|
const candidate = adjacentItems[currentIndex + 1];
|
||||||
}
|
return isNavigable(candidate) ? candidate : null;
|
||||||
|
}, [adjacentItems, currentIndex, item]);
|
||||||
if (adjacentItems.length === 2) {
|
|
||||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacentItems[2];
|
|
||||||
}, [adjacentItems, item]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports playback progress.
|
* Reports playback progress.
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
let onTracksReady = EventDispatcher()
|
let onTracksReady = EventDispatcher()
|
||||||
|
let onPictureInPictureChange = EventDispatcher()
|
||||||
|
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var cachedPosition: Double = 0
|
private var cachedPosition: Double = 0
|
||||||
@@ -81,7 +82,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
private func setupView() {
|
private func setupView() {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
configureAudioSession()
|
|
||||||
|
|
||||||
videoContainer = UIView()
|
videoContainer = UIView()
|
||||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -141,21 +141,26 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try audioSession.setCategory(
|
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||||
.playback,
|
try session.setActive(true)
|
||||||
mode: .moviePlayback,
|
|
||||||
policy: .longFormAudio,
|
|
||||||
options: []
|
|
||||||
)
|
|
||||||
try audioSession.setActive(true)
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to configure audio session: \(error)")
|
print("Failed to configure audio session: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Audio Session & Notifications
|
|
||||||
|
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||||
|
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||||
|
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||||
|
private func tearDownAudioSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||||
|
}
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
@@ -270,6 +275,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
|
configureAudioSession()
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -440,6 +446,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
|
tearDownAudioSession()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,9 +526,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
|
||||||
nowPlayingManager.activateAudioSession()
|
|
||||||
syncNowPlaying(isPlaying: !isPaused())
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
print("PiP did start: \(didStartPictureInPicture)")
|
print("PiP did start: \(didStartPictureInPicture)")
|
||||||
// Ensure current time is synced when PiP starts
|
// Ensure current time is synced when PiP starts
|
||||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||||
|
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||||
|
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||||
|
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||||
@@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
if _isZoomedToFill {
|
if _isZoomedToFill {
|
||||||
displayLayer.videoGravity = .resizeAspectFill
|
displayLayer.videoGravity = .resizeAspectFill
|
||||||
}
|
}
|
||||||
|
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||||
|
// be re-mounted when the user returns to full screen.
|
||||||
|
onPictureInPictureChange(["isActive": false])
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<receiver
|
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||||
android:name=".TvRecommendationsReceiver"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
|||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
internal object TvRecommendationsPublisher {
|
internal object TvRecommendationsPublisher {
|
||||||
private const val TAG = "TvRecommendations"
|
private const val TAG = "TvRecommendations"
|
||||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||||
private const val KEY_PAYLOAD = "payload"
|
private const val KEY_PAYLOAD = "payload"
|
||||||
private const val KEY_CHANNEL_ID = "channelId"
|
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||||
private const val KEY_PROGRAM_IDS = "programIds"
|
private const val KEY_PROGRAM_IDS = "programIds"
|
||||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||||
|
|
||||||
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
fun clear(context: Context): Boolean {
|
fun clear(context: Context): Boolean {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
|
||||||
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (programIds != null) {
|
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||||
|
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
|
if (allProgramIds != null) {
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val keys = programIds.keys()
|
val channelKeys = allProgramIds.keys()
|
||||||
while (keys.hasNext()) {
|
while (channelKeys.hasNext()) {
|
||||||
val key = keys.next()
|
val channelIdStr = channelKeys.next()
|
||||||
val programId = programIds.optLong(key, -1L)
|
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||||
if (programId > 0L) {
|
if (programIdsJson.isBlank()) continue
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
try {
|
||||||
null,
|
val programIds = JSONObject(programIdsJson)
|
||||||
null
|
val keys = programIds.keys()
|
||||||
)
|
while (keys.hasNext()) {
|
||||||
deletedPrograms += 1
|
val providerId = keys.next()
|
||||||
|
val programId = programIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(contentResolver, programId)
|
||||||
|
deletedPrograms += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify the channel
|
||||||
|
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
||||||
|
if (channelId > 0L) {
|
||||||
|
try {
|
||||||
|
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove per-channel pref
|
||||||
|
prefs.edit().remove("programIds_$channelIdStr").apply()
|
||||||
}
|
}
|
||||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelId > 0L) {
|
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
Log.d(TAG, "clear(): notified channel $channelId")
|
if (legacyProgramIds != null) {
|
||||||
|
val keys = legacyProgramIds.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
val programId = legacyProgramIds.optLong(key, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(contentResolver, programId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single preview program from the TvProvider.
|
||||||
|
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
||||||
|
*/
|
||||||
|
fun deletePreviewProgram(context: Context, programId: Long) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
||||||
|
|
||||||
|
// Also remove from stored programIds prefs
|
||||||
|
removeProgramFromPrefs(context, programId)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
||||||
|
try {
|
||||||
|
contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
||||||
|
val prefs = preferences(context)
|
||||||
|
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||||
|
try {
|
||||||
|
val channelMap = JSONObject(programIdsJson)
|
||||||
|
val channelKeys = channelMap.keys()
|
||||||
|
while (channelKeys.hasNext()) {
|
||||||
|
val channelId = channelKeys.next()
|
||||||
|
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||||
|
val providerKeys = inner.keys()
|
||||||
|
while (providerKeys.hasNext()) {
|
||||||
|
val providerId = providerKeys.next()
|
||||||
|
if (inner.optLong(providerId, -1L) == programId) {
|
||||||
|
inner.remove(providerId)
|
||||||
|
if (inner.length() == 0) {
|
||||||
|
channelMap.remove(channelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||||
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
if (sections.length() == 0) {
|
||||||
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
Log.w(TAG, "synchronize(): no sections in payload")
|
||||||
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
|
||||||
if (channelId <= 0L) {
|
|
||||||
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
val prefs = preferences(context)
|
||||||
|
val allNextProgramIds = JSONObject()
|
||||||
|
var totalActive = 0
|
||||||
|
var totalDeleted = 0
|
||||||
|
|
||||||
val previousProgramIds = preferences(context)
|
for (sectionIndex in 0 until sections.length()) {
|
||||||
.getString(KEY_PROGRAM_IDS, null)
|
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||||
?.let(::JSONObject)
|
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
?: JSONObject()
|
val items = section.optJSONArray("items") ?: JSONArray()
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
Log.d(
|
||||||
val item = items.optJSONObject(index) ?: continue
|
TAG,
|
||||||
val providerId = item.optString("id")
|
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (programId > 0L) {
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
activeProviderIds += providerId
|
if (channelId <= 0L) {
|
||||||
nextProgramIds.put(providerId, programId)
|
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||||
val previousKeys = previousProgramIds.keys()
|
if (!isChannelBrowsable(context, channelId)) {
|
||||||
while (previousKeys.hasNext()) {
|
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||||
val providerId = previousKeys.next()
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
}
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
val prefKey = "programIds_$channelId"
|
||||||
if (programId > 0L) {
|
val previousProgramIds = prefs.getString(prefKey, null)
|
||||||
context.contentResolver.delete(
|
?.let(::JSONObject)
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
?: JSONObject()
|
||||||
null,
|
val nextProgramIds = JSONObject()
|
||||||
null
|
val activeProviderIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.optJSONObject(index) ?: continue
|
||||||
|
val providerId = item.optString("id")
|
||||||
|
if (providerId.isBlank()) continue
|
||||||
|
|
||||||
|
val programId = upsertPreviewProgram(
|
||||||
|
context = context,
|
||||||
|
channelId = channelId,
|
||||||
|
item = item,
|
||||||
|
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||||
|
weight = index
|
||||||
)
|
)
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
if (programId > 0L) {
|
||||||
|
activeProviderIds += providerId
|
||||||
|
nextProgramIds.put(providerId, programId)
|
||||||
|
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var deletedPrograms = 0
|
||||||
|
val previousKeys = previousProgramIds.keys()
|
||||||
|
while (previousKeys.hasNext()) {
|
||||||
|
val providerId = previousKeys.next()
|
||||||
|
if (activeProviderIds.contains(providerId)) continue
|
||||||
|
|
||||||
|
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(context, programId)
|
||||||
|
deletedPrograms += 1
|
||||||
|
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
||||||
|
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
||||||
|
totalActive += activeProviderIds.size
|
||||||
|
totalDeleted += deletedPrograms
|
||||||
|
|
||||||
|
logProviderState(context, channelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
preferences(context)
|
// Store all channel program IDs for clear() to use
|
||||||
.edit()
|
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||||
.putLong(KEY_CHANNEL_ID, channelId)
|
|
||||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query provider to check if a channel is browsable.
|
||||||
|
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
||||||
|
*/
|
||||||
|
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.query(
|
||||||
|
TvContractCompat.buildChannelUri(channelId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||||
|
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} ?: false
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
||||||
|
true // Assume browsable if we can't check, to avoid blocking updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query provider to verify a channel actually exists.
|
||||||
|
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
||||||
|
* we must first check whether the channel was deleted by the system
|
||||||
|
* or if the update simply failed for another reason.
|
||||||
|
*/
|
||||||
|
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.query(
|
||||||
|
TvContractCompat.buildChannelUri(channelId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
} ?: false
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
val channelKey = getChannelKey(displayName)
|
||||||
|
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
val updated = Channel.Builder()
|
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
val exists = channelExistsInProvider(context, existingChannelId)
|
||||||
.setDisplayName(displayName)
|
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val updatedRows = contentResolver.update(
|
if (exists) {
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
// Channel exists — update it in place, never recreate
|
||||||
updated.toContentValues(),
|
val updated = Channel.Builder()
|
||||||
null,
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
null
|
.setDisplayName(displayName)
|
||||||
)
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
try {
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
val updatedRows = contentResolver.update(
|
||||||
storeChannelLogo(context, existingChannelId)
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
updated.toContentValues(),
|
||||||
return existingChannelId
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows > 0) {
|
||||||
|
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||||
|
storeChannelLogo(context, existingChannelId)
|
||||||
|
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||||
|
return existingChannelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
||||||
|
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
||||||
|
return existingChannelId
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
||||||
|
return existingChannelId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
// Channel truly doesn't exist in provider — recreate
|
||||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||||
|
prefs.edit().remove(channelKey).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new channel
|
||||||
val channel = Channel.Builder()
|
val channel = Channel.Builder()
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
.setDisplayName(displayName)
|
.setDisplayName(displayName)
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val channelUri = contentResolver.insert(
|
val channelUri = try {
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
contentResolver.insert(
|
||||||
channel.toContentValues()
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
) ?: return -1L
|
channel.toContentValues()
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
||||||
|
null
|
||||||
|
} ?: return -1L
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
|
prefs.edit().putLong(channelKey, channelId).apply()
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
storeChannelLogo(context, channelId)
|
storeChannelLogo(context, channelId)
|
||||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||||
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getChannelKey(displayName: String): String {
|
||||||
|
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
private fun upsertPreviewProgram(
|
private fun upsertPreviewProgram(
|
||||||
context: Context,
|
context: Context,
|
||||||
channelId: Long,
|
channelId: Long,
|
||||||
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
|
|||||||
builder.setDescription(it)
|
builder.setDescription(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per Android docs: use unique URIs for all images to avoid stale cache
|
||||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||||
val imageUri = Uri.parse(it)
|
val uniqueImageUrl = appendCacheBuster(it)
|
||||||
|
val imageUri = Uri.parse(uniqueImageUrl)
|
||||||
builder.setPosterArtUri(imageUri)
|
builder.setPosterArtUri(imageUri)
|
||||||
builder.setThumbnailUri(imageUri)
|
builder.setThumbnailUri(imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val contentValues = builder.build().toContentValues()
|
val contentValues = builder.build().toContentValues()
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (previousProgramId > 0L) {
|
if (previousProgramId > 0L) {
|
||||||
val updatedRows = contentResolver.update(
|
try {
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
val updatedRows = contentResolver.update(
|
||||||
contentValues,
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
null,
|
contentValues,
|
||||||
null
|
null,
|
||||||
)
|
null
|
||||||
|
)
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
if (updatedRows > 0) {
|
||||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||||
return previousProgramId
|
return previousProgramId
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val insertedUri = contentResolver.insert(
|
val insertedUri = try {
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentResolver.insert(
|
||||||
contentValues
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
) ?: return -1L
|
contentValues
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
||||||
|
null
|
||||||
|
} ?: return -1L
|
||||||
|
|
||||||
val programId = ContentUris.parseId(insertedUri)
|
val programId = ContentUris.parseId(insertedUri)
|
||||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||||
return programId
|
return programId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a stable cache key derived from the image URL.
|
||||||
|
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||||
|
* that changes whenever the image content changes, so a deterministic
|
||||||
|
* hash of the URL is sufficient — the param only changes when the URL
|
||||||
|
* (and therefore the image) actually changes, avoiding unnecessary
|
||||||
|
* re-downloads on every sync.
|
||||||
|
*/
|
||||||
|
private fun appendCacheBuster(imageUrl: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||||
|
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||||
|
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||||
|
return "$imageUrl${separator}_v=$hash"
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(deepLink)
|
data = Uri.parse(deepLink)
|
||||||
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||||
val bitmap = applicationIconBitmap(context) ?: return
|
val bitmap = applicationIconBitmap(context) ?: return
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
try {
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
) ?: return
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
|
) ?: return
|
||||||
|
|
||||||
outputStream.use { stream ->
|
outputStream.use { stream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||||
|
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
||||||
|
}
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logProviderState(context: Context, channelId: Long) {
|
private fun logProviderState(context: Context, channelId: Long) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: Exception) {
|
} catch (error: SecurityException) {
|
||||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||||
}
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ContentUris
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
|
||||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
when (intent.action) {
|
||||||
return
|
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||||
|
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||||
|
TvRecommendationsPublisher.refreshFromCache(context)
|
||||||
|
}
|
||||||
|
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||||
|
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||||
|
if (programId > 0L) {
|
||||||
|
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||||
|
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
|
||||||
TvRecommendationsPublisher.refreshFromCache(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { requireNativeView } from "expo";
|
import { requireNativeView } from "expo";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { View } from "react-native";
|
import type { View } from "react-native";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||||
|
|
||||||
|
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
||||||
|
// On Android the component is never rendered, but we must avoid calling
|
||||||
|
// `requireNativeView` at module-scope because it would crash on import.
|
||||||
const NativeView: React.ComponentType<
|
const NativeView: React.ComponentType<
|
||||||
TvSearchViewProps & React.RefAttributes<View>
|
TvSearchViewProps & React.RefAttributes<View>
|
||||||
> = requireNativeView("TvSearchModule");
|
> =
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? requireNativeView("TvSearchModule")
|
||||||
|
: ((() => null) as any);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards its ref to the underlying native view so it can be used as a
|
* Forwards its ref to the underlying native view so it can be used as a
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "bun scripts/typecheck.ts",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.7",
|
"expo-doctor": "1.19.9",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
|||||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
|||||||
|
|
||||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||||
|
|
||||||
|
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||||
|
let cachedDb: DownloadsDatabase | null = null;
|
||||||
|
let cacheVersion = 0;
|
||||||
|
|
||||||
|
// Performance optimization: Cache the flattened items array
|
||||||
|
let cachedItems: DownloadedItem[] | null = null;
|
||||||
|
let itemsCacheVersion = -1;
|
||||||
|
|
||||||
|
// Performance optimization: Index for O(1) item lookups by ID
|
||||||
|
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||||
|
let indexCacheVersion = -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the downloads database from storage
|
* Get the downloads database from storage
|
||||||
|
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||||
|
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||||
|
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||||
|
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||||
*/
|
*/
|
||||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||||
|
// Return cached database if available
|
||||||
|
if (cachedDb !== null) {
|
||||||
|
return cachedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse from storage and cache the result
|
||||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||||
if (file) {
|
if (file) {
|
||||||
return JSON.parse(file) as DownloadsDatabase;
|
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||||
|
return cachedDb;
|
||||||
}
|
}
|
||||||
return { movies: {}, series: {}, other: {} };
|
|
||||||
|
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||||
|
cachedDb = emptyDb;
|
||||||
|
return emptyDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the downloads database to storage
|
* Save the downloads database to storage
|
||||||
|
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||||
*/
|
*/
|
||||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||||
|
// Update the cache with the new database
|
||||||
|
cachedDb = db;
|
||||||
|
// Invalidate derived caches (items array and index)
|
||||||
|
cachedItems = null;
|
||||||
|
itemIndex = null;
|
||||||
|
cacheVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all downloaded items as a flat array
|
* Get all downloaded items as a flat array
|
||||||
|
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||||
*/
|
*/
|
||||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||||
|
// Return cached items if available and up-to-date
|
||||||
|
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||||
|
return cachedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the items array from the database
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
const items: DownloadedItem[] = [];
|
const items: DownloadedItem[] = [];
|
||||||
|
|
||||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cachedItems = items;
|
||||||
|
itemsCacheVersion = cacheVersion;
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a downloaded item by its ID
|
* Build or refresh the item index for O(1) lookups
|
||||||
*/
|
*/
|
||||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
function ensureItemIndex(): void {
|
||||||
const db = getDownloadsDatabase();
|
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||||
|
return; // Index is up-to-date
|
||||||
if (db.movies[id]) {
|
|
||||||
return db.movies[id];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const series of Object.values(db.series)) {
|
// Build new index from all items
|
||||||
for (const season of Object.values(series.seasons)) {
|
itemIndex = new Map<string, DownloadedItem>();
|
||||||
for (const episode of Object.values(season.episodes)) {
|
const items = getAllDownloadedItems();
|
||||||
if (episode.item.Id === id) {
|
|
||||||
return episode;
|
for (const item of items) {
|
||||||
}
|
if (item.item.Id) {
|
||||||
}
|
itemIndex.set(item.item.Id, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db.other?.[id]) {
|
indexCacheVersion = cacheVersion;
|
||||||
return db.other[id];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
/**
|
||||||
|
* Get a downloaded item by its ID
|
||||||
|
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||||
|
*/
|
||||||
|
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||||
|
ensureItemIndex();
|
||||||
|
return itemIndex!.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
|||||||
*/
|
*/
|
||||||
export function clearAllDownloadedItems(): void {
|
export function clearAllDownloadedItems(): void {
|
||||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||||
|
// saveDownloadsDatabase already invalidates caches
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||||
|
import { APP_VERSION } from "@/utils/version";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -53,7 +54,7 @@ const initialApi = (() => {
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
const jellyfinInstance = new Jellyfin({
|
const jellyfinInstance = new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
return new Jellyfin({
|
return new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -619,44 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
// Dismiss splash screen with cached data immediately,
|
||||||
setUser(response.data);
|
// fetch fresh user data in the background
|
||||||
|
setInitialLoaded(true);
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
try {
|
||||||
if (storedUser?.Id && storedUser?.Name) {
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
const existingCredential = await getAccountCredential(
|
setUser(response.data);
|
||||||
serverUrl,
|
|
||||||
storedUser.Id,
|
// Migrate current session to secure storage if not already saved
|
||||||
);
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
if (!existingCredential) {
|
const existingCredential = await getAccountCredential(
|
||||||
await saveAccountCredential({
|
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverName: "",
|
storedUser.Id,
|
||||||
token,
|
);
|
||||||
userId: storedUser.Id,
|
if (!existingCredential) {
|
||||||
username: storedUser.Name,
|
await saveAccountCredential({
|
||||||
savedAt: Date.now(),
|
serverUrl,
|
||||||
securityType: "none",
|
serverName: "",
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
token,
|
||||||
});
|
userId: storedUser.Id,
|
||||||
} else if (
|
username: storedUser.Name,
|
||||||
response.data.PrimaryImageTag !==
|
savedAt: Date.now(),
|
||||||
existingCredential.primaryImageTag
|
securityType: "none",
|
||||||
) {
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
// Update image tag if it has changed
|
});
|
||||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
} else if (
|
||||||
userId: existingCredential.userId,
|
response.data.PrimaryImageTag !==
|
||||||
username: existingCredential.username,
|
existingCredential.primaryImageTag
|
||||||
securityType: existingCredential.securityType,
|
) {
|
||||||
savedAt: existingCredential.savedAt,
|
// Update image tag if it has changed
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||||
});
|
userId: existingCredential.userId,
|
||||||
|
username: existingCredential.username,
|
||||||
|
securityType: existingCredential.securityType,
|
||||||
|
savedAt: existingCredential.savedAt,
|
||||||
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Background fetch failed — app already rendered with cached data
|
||||||
|
console.warn("Background user fetch failed, using cached data:", e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +34,20 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||||
|
|
||||||
|
interface I18nConfig {
|
||||||
|
localesDir: string;
|
||||||
|
sourceLocale: string;
|
||||||
|
srcDirs: string[];
|
||||||
|
srcExtensions: string[];
|
||||||
|
excludeDirs: string[];
|
||||||
|
ignoreUnused: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const flag = (name, def) => {
|
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
if (!a) return def;
|
if (!a) return def;
|
||||||
const [, v] = a.split("=");
|
const [, v] = a.split("=");
|
||||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
|||||||
|
|
||||||
// ---- config ----
|
// ---- config ----
|
||||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG: I18nConfig = {
|
||||||
localesDir: "translations",
|
localesDir: "translations",
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
|||||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
ignoreUnused: [],
|
ignoreUnused: [],
|
||||||
};
|
};
|
||||||
const config = existsSync(CONFIG_PATH)
|
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
? {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||||
|
}
|
||||||
: DEFAULT_CONFIG;
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
const flatten = (obj, prefix = "", out = {}) => {
|
const flatten = (
|
||||||
|
obj: LocaleTree,
|
||||||
|
prefix = "",
|
||||||
|
out: Record<string, string> = {},
|
||||||
|
): Record<string, string> => {
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
else out[key] = v;
|
else out[key] = v as string;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globMatch = (key, pattern) => {
|
const globMatch = (key: string, pattern: string): boolean => {
|
||||||
if (pattern.endsWith(".*"))
|
if (pattern.endsWith(".*"))
|
||||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
return key === pattern;
|
return key === pattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (dir, files = []) => {
|
const walk = (dir: string, files: string[] = []): string[] => {
|
||||||
let entries;
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
|||||||
}
|
}
|
||||||
for (const name of entries) {
|
for (const name of entries) {
|
||||||
const full = join(dir, name);
|
const full = join(dir, name);
|
||||||
let st;
|
let st: ReturnType<typeof statSync>;
|
||||||
try {
|
try {
|
||||||
st = statSync(full);
|
st = statSync(full);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
|||||||
// ---- load source keys ----
|
// ---- load source keys ----
|
||||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
const sourceKeys = Object.keys(
|
const sourceKeys = Object.keys(
|
||||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||||
);
|
);
|
||||||
const sourceKeySet = new Set(sourceKeys);
|
const sourceKeySet = new Set(sourceKeys);
|
||||||
|
|
||||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
|||||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||||
|
|
||||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||||
const dynamicPrefixes = new Set();
|
const dynamicPrefixes = new Set<string>();
|
||||||
const fullyDynamic = []; // { file, line }
|
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||||
|
|
||||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
// `://` inside string URLs intact.
|
// `://` inside string URLs intact.
|
||||||
const stripComments = (src) =>
|
const stripComments = (src: string): string =>
|
||||||
src
|
src
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
|||||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||||
const literalUsed = (key) =>
|
const literalUsed = (key: string): boolean =>
|
||||||
codeBlob.includes(`"${key}"`) ||
|
codeBlob.includes(`"${key}"`) ||
|
||||||
codeBlob.includes(`'${key}'`) ||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
codeBlob.includes(`\`${key}\``);
|
codeBlob.includes(`\`${key}\``);
|
||||||
const isUsed = (key) =>
|
const isUsed = (key: string): boolean =>
|
||||||
literalUsed(key) ||
|
literalUsed(key) ||
|
||||||
prefixList.some((p) => key.startsWith(p)) ||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||||
@@ -184,27 +202,29 @@ const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
|||||||
const missing = [...usedStatic]
|
const missing = [...usedStatic]
|
||||||
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
||||||
.sort();
|
.sort();
|
||||||
|
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
|
||||||
|
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
|
||||||
|
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
|
||||||
|
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
|
||||||
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||||
const removeKey = (obj, parts) => {
|
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||||
const [head, ...rest] = parts;
|
const [head, ...rest] = parts;
|
||||||
if (!(head in obj)) return;
|
if (!(head in obj)) return;
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
delete obj[head];
|
delete obj[head];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeKey(obj[head], rest);
|
const child = obj[head];
|
||||||
if (
|
if (!child || typeof child !== "object") return;
|
||||||
obj[head] &&
|
removeKey(child, rest);
|
||||||
typeof obj[head] === "object" &&
|
if (Object.keys(child).length === 0) delete obj[head];
|
||||||
Object.keys(obj[head]).length === 0
|
|
||||||
)
|
|
||||||
delete obj[head];
|
|
||||||
};
|
};
|
||||||
if (FIX_UNUSED && unused.length) {
|
if (FIX_UNUSED && unused.length) {
|
||||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||||
// the keys from them automatically on the next sync once they disappear from the source.
|
// the keys from them automatically on the next sync once they disappear from the source.
|
||||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||||
for (const key of unused) removeKey(data, key.split("."));
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -254,7 +274,7 @@ if (JSON_OUT) {
|
|||||||
);
|
);
|
||||||
for (const k of unused) console.log(` - ${k}`);
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
242
scripts/detect-duplicate-issue.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
|
||||||
|
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
|
||||||
|
*
|
||||||
|
* On a match it posts ONE comment listing the closest open issues and adds the
|
||||||
|
* "possible duplicate" label. If nothing is similar enough, it does nothing.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* GITHUB_REPOSITORY owner/repo
|
||||||
|
* ISSUE_NUMBER the new issue number
|
||||||
|
* ISSUE_TITLE the new issue title
|
||||||
|
* ISSUE_BODY the new issue body
|
||||||
|
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
|
||||||
|
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
|
||||||
|
* DUP_MAX max matches to report (default 5)
|
||||||
|
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
|
||||||
|
* DRY_RUN if set, print results instead of commenting/labelling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||||
|
const numEnv = (name: string, def: number): number => {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === "") return def;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isNaN(n) ? def : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
||||||
|
const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN);
|
||||||
|
const TITLE = process.env.ISSUE_TITLE || "";
|
||||||
|
const BODY = process.env.ISSUE_BODY || "";
|
||||||
|
const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3);
|
||||||
|
const MAX = numEnv("DUP_MAX", 5);
|
||||||
|
const DRY = !!process.env.DRY_RUN;
|
||||||
|
const LABEL = "possible duplicate";
|
||||||
|
const MARKER = "<!-- duplicate-detector -->";
|
||||||
|
|
||||||
|
// Generic stop words only — keep domain/feature/platform words (android, downloads,
|
||||||
|
// subtitles…) since those are exactly what makes two reports the same or different.
|
||||||
|
const STOP = new Set(
|
||||||
|
(
|
||||||
|
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
|
||||||
|
"it its this that these those i you we they me my your our their he she him her " +
|
||||||
|
"when while where what which who how why so just then than too very can could would should will " +
|
||||||
|
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
|
||||||
|
"app application streamyfin issue bug"
|
||||||
|
).split(/\s+/),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||||
|
|
||||||
|
const tokens = (s: string | null): string[] =>
|
||||||
|
(s || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
|
||||||
|
.replace(/https?:\/\/\S+/g, " ") // drop urls
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 2 && !STOP.has(w))
|
||||||
|
.map(stem)
|
||||||
|
.filter((w) => w.length > 2);
|
||||||
|
|
||||||
|
const jaccard = (a: string[], b: string[]): number => {
|
||||||
|
const A = new Set(a);
|
||||||
|
const B = new Set(b);
|
||||||
|
if (!A.size || !B.size) return 0;
|
||||||
|
let inter = 0;
|
||||||
|
for (const x of A) if (B.has(x)) inter++;
|
||||||
|
return inter / (A.size + B.size - inter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newTitle = tokens(TITLE);
|
||||||
|
const newBody = tokens(BODY);
|
||||||
|
const score = (o: Issue): number =>
|
||||||
|
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||||
|
0.4 * jaccard(newBody, tokens(o.body));
|
||||||
|
|
||||||
|
// fetch open issues (excluding PRs and the new issue itself)
|
||||||
|
let issues: Issue[];
|
||||||
|
if (process.env.DUP_FIXTURE) {
|
||||||
|
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||||
|
} else {
|
||||||
|
const raw = execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
`repos/${REPO}/issues`,
|
||||||
|
"--paginate",
|
||||||
|
"-X",
|
||||||
|
"GET",
|
||||||
|
"-f",
|
||||||
|
"state=open",
|
||||||
|
"-f",
|
||||||
|
"per_page=100",
|
||||||
|
"--jq",
|
||||||
|
".[] | select(.pull_request | not) | {number, title, body}",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8", maxBuffer: 1e8 },
|
||||||
|
);
|
||||||
|
issues = raw
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => JSON.parse(l) as Issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = issues
|
||||||
|
.filter((o) => o.number !== NUMBER)
|
||||||
|
.map((o) => ({ ...o, s: score(o) }))
|
||||||
|
.filter((o) => o.s >= THRESHOLD)
|
||||||
|
.sort((a, b) => b.s - a.s)
|
||||||
|
.slice(0, MAX);
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
console.log("No likely duplicates found.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||||
|
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||||
|
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||||
|
const safeTitle = (t: string): string =>
|
||||||
|
(t || "")
|
||||||
|
.replace(/@/g, "@")
|
||||||
|
.replace(/[`<>|*_~[\]]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 140);
|
||||||
|
const list = matches
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`- #${m.number} — ${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
const comment = [
|
||||||
|
MARKER,
|
||||||
|
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
|
||||||
|
"",
|
||||||
|
list,
|
||||||
|
"",
|
||||||
|
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
|
||||||
|
|
||||||
|
if (DRY) {
|
||||||
|
console.log("\nDRY_RUN: not commenting/labelling.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/...
|
||||||
|
if (!Number.isInteger(NUMBER) || NUMBER <= 0) {
|
||||||
|
console.error(
|
||||||
|
`Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} — refusing to comment.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers).
|
||||||
|
const priorComments = execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
`repos/${REPO}/issues/${NUMBER}/comments`,
|
||||||
|
"--paginate",
|
||||||
|
"--jq",
|
||||||
|
".[].body",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8", maxBuffer: 1e8 },
|
||||||
|
);
|
||||||
|
if (priorComments.includes(MARKER)) {
|
||||||
|
console.log("Already flagged (marker present); skipping.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
`repos/${REPO}/issues/${NUMBER}/comments`,
|
||||||
|
"-f",
|
||||||
|
`body=${comment}`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
`repos/${REPO}/issues/${NUMBER}/labels`,
|
||||||
|
"-f",
|
||||||
|
`labels[]=${LABEL}`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// label may not exist yet — create then add
|
||||||
|
execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
`repos/${REPO}/labels`,
|
||||||
|
"-f",
|
||||||
|
`name=${LABEL}`,
|
||||||
|
"-f",
|
||||||
|
"color=fbca04",
|
||||||
|
"-f",
|
||||||
|
"description=Automatically flagged as a possible duplicate",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"api",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
`repos/${REPO}/issues/${NUMBER}/labels`,
|
||||||
|
"-f",
|
||||||
|
`labels[]=${LABEL}`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("Commented and labelled.");
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"build",
|
"build",
|
||||||
"translations"
|
"translations"
|
||||||
],
|
],
|
||||||
"_ignoreUnusedNote": "Keys for planned features that are intentionally kept in en.json but not yet wired in code. They are exempt from the unused-key check until implemented. Remove an entry once its feature ships and uses the key.",
|
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
|
||||||
"ignoreUnused": [
|
"ignoreUnused": [
|
||||||
"watchlists.add_to_watchlist",
|
"watchlists.add_to_watchlist",
|
||||||
"watchlists.remove_from_watchlist",
|
"watchlists.remove_from_watchlist",
|
||||||
@@ -38,6 +38,9 @@
|
|||||||
"player.swipe_down_settings",
|
"player.swipe_down_settings",
|
||||||
"player.stopPlayback",
|
"player.stopPlayback",
|
||||||
"player.stopPlayingTitle",
|
"player.stopPlayingTitle",
|
||||||
"player.stopPlayingConfirm"
|
"player.stopPlayingConfirm",
|
||||||
|
"home.settings.other.show_large_home_carousel",
|
||||||
|
"home.settings.logs.delete_all_logs",
|
||||||
|
"home.suggested_episodes"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const _fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const process = require("node:process");
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
// const tvosPath = path.join(root, 'iostv');
|
|
||||||
// const iosPath = path.join(root, 'iosmobile');
|
|
||||||
// const androidPath = path.join(root, 'androidmobile');
|
|
||||||
// const androidTVPath = path.join(root, 'androidtv');
|
|
||||||
// const device = process.argv[2];
|
|
||||||
// const platform = process.argv[2];
|
|
||||||
const isTV = process.env.EXPO_TV || false;
|
|
||||||
|
|
||||||
const paths = new Map([
|
|
||||||
["tvos", path.join(root, "iostv")],
|
|
||||||
["ios", path.join(root, "iosmobile")],
|
|
||||||
["android", path.join(root, "androidmobile")],
|
|
||||||
["androidtv", path.join(root, "androidtv")],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// const platformPath = paths.get(platform);
|
|
||||||
|
|
||||||
if (isTV) {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
|
||||||
"androidtv",
|
|
||||||
)} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
} else {
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
stdout = execSync(
|
|
||||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
|
||||||
);
|
|
||||||
console.log(stdout.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// target = "";
|
|
||||||
// switch (platform) {
|
|
||||||
// case "tvos":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "ios":
|
|
||||||
// target = "ios";
|
|
||||||
// break;
|
|
||||||
// case "android":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// case "androidtv":
|
|
||||||
// target = "android";
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
import { execFileSync } from "node:child_process";
|
||||||
const process = require("node:process");
|
import { createRequire } from "node:module";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
// Enhanced ANSI color codes and styles
|
// Enhanced ANSI color codes and styles
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
|||||||
|
|
||||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||||
|
|
||||||
function log(message, color = "") {
|
function log(message: string, color = "") {
|
||||||
if (useColor && color) {
|
if (useColor && color) {
|
||||||
console.log(`${color}${message}${colors.reset}`);
|
console.log(`${color}${message}${colors.reset}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(errorLine) {
|
function formatError(errorLine: string): string {
|
||||||
if (!useColor) return errorLine;
|
if (!useColor) return errorLine;
|
||||||
|
|
||||||
// Color file paths in cyan
|
// Color file paths in cyan
|
||||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorsAndCreateSummary(errorOutput) {
|
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||||
|
formattedErrors: string[];
|
||||||
|
errorsByFile: Map<string, number>;
|
||||||
|
} {
|
||||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||||
const errorsByFile = new Map();
|
const errorsByFile = new Map<string, number>();
|
||||||
const formattedErrors = [];
|
const formattedErrors: string[] = [];
|
||||||
|
|
||||||
let currentError = [];
|
let currentError: string[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
if (!errorsByFile.has(filePath)) {
|
if (!errorsByFile.has(filePath)) {
|
||||||
errorsByFile.set(filePath, 0);
|
errorsByFile.set(filePath, 0);
|
||||||
}
|
}
|
||||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||||
|
|
||||||
// Start new error
|
// Start new error
|
||||||
currentError.push(formatError(line));
|
currentError.push(formatError(line));
|
||||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
|||||||
return { formattedErrors, errorsByFile };
|
return { formattedErrors, errorsByFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createErrorSummaryTable(errorsByFile) {
|
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||||
if (errorsByFile.size === 0) return "";
|
if (errorsByFile.size === 0) return "";
|
||||||
|
|
||||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||||
@@ -136,12 +142,12 @@ function createErrorSummaryTable(errorsByFile) {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTypeCheck() {
|
function runTypeCheck(): { ok: boolean } {
|
||||||
const extraArgs = process.argv.slice(2);
|
const extraArgs = process.argv.slice(2);
|
||||||
|
|
||||||
// Prefer local TypeScript binary when available
|
// Prefer local TypeScript binary when available
|
||||||
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
||||||
let execArgs = null;
|
let execArgs: { cmd: string; args: string[] };
|
||||||
try {
|
try {
|
||||||
const tscBin = require.resolve("typescript/bin/tsc");
|
const tscBin = require.resolve("typescript/bin/tsc");
|
||||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||||
@@ -174,7 +180,8 @@ function runTypeCheck() {
|
|||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
const execError = error as { stderr?: string; stdout?: string };
|
||||||
|
const errorOutput = execError.stderr || execError.stdout || "";
|
||||||
|
|
||||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||||
// that generates a large volume of known type errors
|
// that generates a large volume of known type errors
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "تابع و التالي",
|
"continue_and_next_up": "تابع و التالي",
|
||||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||||
"suggested_movies": "أفلام مقترحة",
|
"suggested_movies": "أفلام مقترحة",
|
||||||
|
"suggested_episodes": "حلقات مقترحة",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـJellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـJellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "لا شيء",
|
"None": "لا شيء",
|
||||||
"OnlyForced": "فقط الإجبارية"
|
"OnlyForced": "فقط الإجبارية"
|
||||||
},
|
},
|
||||||
|
"text_color": "لون النص",
|
||||||
|
"background_color": "لون الخلفية",
|
||||||
|
"outline_color": "لون إطار الخط",
|
||||||
|
"outline_thickness": "سمك إطار الخط",
|
||||||
|
"background_opacity": "شفافية الخلفية",
|
||||||
|
"outline_opacity": "شفافية إطار الخط",
|
||||||
|
"bold_text": "خط عريض",
|
||||||
|
"colors": {
|
||||||
|
"Black": "أسود",
|
||||||
|
"Gray": "رمادي",
|
||||||
|
"Silver": "فضي",
|
||||||
|
"White": "أبيض",
|
||||||
|
"Maroon": "أحمر داكن",
|
||||||
|
"Red": "أحمر",
|
||||||
|
"Fuchsia": "وردي",
|
||||||
|
"Yellow": "أصفر",
|
||||||
|
"Olive": "أخضر زيتوني",
|
||||||
|
"Green": "أخضر",
|
||||||
|
"Teal": "أزرق مخضر",
|
||||||
|
"Lime": "ليموني",
|
||||||
|
"Purple": "بنفسجي",
|
||||||
|
"Navy": "كحلي",
|
||||||
|
"Blue": "أزرق",
|
||||||
|
"Aqua": "أزرق بحري"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "لا شيء",
|
||||||
|
"Thin": "نحيف",
|
||||||
|
"Normal": "عادي",
|
||||||
|
"Thick": "سميك"
|
||||||
|
},
|
||||||
|
"subtitle_color": "لون الترجمة",
|
||||||
|
"subtitle_background_color": "لون الخلفية",
|
||||||
|
"subtitle_font": "خط الترجمة",
|
||||||
|
"ksplayer_title": "إعدادات KSPlayer",
|
||||||
|
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
||||||
|
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "إعدادات ترجمة VLC",
|
||||||
|
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
||||||
|
"text_color": "لون النص",
|
||||||
|
"background_color": "لون الخلفية",
|
||||||
|
"background_opacity": "شفافية الخلفية",
|
||||||
|
"outline_color": "لون إطار الخط",
|
||||||
|
"outline_opacity": "شفافية إطار الخط",
|
||||||
|
"outline_thickness": "سمك إطار الخط",
|
||||||
|
"bold": "خط عريض",
|
||||||
|
"margin": "الهامش السفلي"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "مشغل الفيديو",
|
||||||
|
"video_player": "مشغل الفيديو",
|
||||||
|
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "أخرى",
|
"other_title": "أخرى",
|
||||||
"video_orientation": "اتجاه الفيديو",
|
"video_orientation": "اتجاه الفيديو",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "غير معروف"
|
"UNKNOWN": "غير معروف"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||||
|
"video_player": "مشغل الفيديو",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||||
|
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||||
"hide_libraries": "إخفاء المكتبات",
|
"hide_libraries": "إخفاء المكتبات",
|
||||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||||
"disabled": "معطل"
|
"disabled": "معطل"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "التنزيلات"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "الموسيقى",
|
||||||
"playback_title": "التشغيل",
|
"playback_title": "التشغيل",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||||
"save_button": "حفظ",
|
"save_button": "حفظ",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ"
|
"saved": "تم الحفظ",
|
||||||
}
|
"refreshed": "تم تحديث الإعدادات من الخادم"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "تفعيل Streamystats",
|
||||||
"disable_streamystats": "تعطيل Streamystats",
|
"disable_streamystats": "تعطيل Streamystats",
|
||||||
"enable_search": "استخدم للبحث",
|
"enable_search": "استخدم للبحث",
|
||||||
"url": "الرابط",
|
"url": "الرابط",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
||||||
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
||||||
|
"save_button": "حفظ",
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"features_title": "المميزات",
|
"features_title": "المميزات",
|
||||||
|
"home_sections_title": "أقسام الرئيسية",
|
||||||
"enable_movie_recommendations": "توصيات الأفلام",
|
"enable_movie_recommendations": "توصيات الأفلام",
|
||||||
"enable_series_recommendations": "توصيات المسلسلات",
|
"enable_series_recommendations": "توصيات المسلسلات",
|
||||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا"
|
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
||||||
|
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||||
|
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
||||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "تصدير السجلات",
|
"export_logs": "تصدير السجلات",
|
||||||
"click_for_more_info": "اضغط للمزيد من المعلومات",
|
"click_for_more_info": "اضغط للمزيد من المعلومات",
|
||||||
"level": "المستوى",
|
"level": "المستوى",
|
||||||
"no_logs_available": "لا توجد سجلات متاحة"
|
"no_logs_available": "لا توجد سجلات متاحة",
|
||||||
|
"delete_all_logs": "حذف جميع السجلات"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "اللغات",
|
"title": "اللغات",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "النظام"
|
"system": "النظام"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "خطأ في حذف الملفات"
|
"error_deleting_files": "خطأ في حذف الملفات",
|
||||||
|
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
||||||
|
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "التنزيلات",
|
"downloads_title": "التنزيلات",
|
||||||
"tvseries": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
|
"queue": "قائمة الانتظار",
|
||||||
"other_media": "وسائط أخرى",
|
"other_media": "وسائط أخرى",
|
||||||
|
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||||
|
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
"delete_all_series_button": "حذف جميع المسلسلات",
|
||||||
"delete_all_button": "حذف الكل",
|
"delete_all_button": "حذف الكل",
|
||||||
"delete_all_other_media_button": "حذف الوسائط الأخرى",
|
"delete_all_other_media_button": "حذف الوسائط الأخرى",
|
||||||
"active_download": "التنزيل الجاري",
|
"active_download": "التنزيل الجاري",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
||||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
||||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
||||||
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
||||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||||
|
"download_deleted": "تم حذف التنزيل",
|
||||||
"download_cancelled": "تم إلغاء التنزيل",
|
"download_cancelled": "تم إلغاء التنزيل",
|
||||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||||
|
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
||||||
|
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
||||||
|
"download_resumed": "تم استئناف التنزيل",
|
||||||
|
"could_not_resume_download": "تعذر استئناف التنزيل",
|
||||||
"download_completed": "اكتمل التنزيل",
|
"download_completed": "اكتمل التنزيل",
|
||||||
"download_failed": "فشل التنزيل",
|
"download_failed": "فشل التنزيل",
|
||||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
||||||
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
||||||
"files_deleted_by_type": "تم حذف {{count}} {{type}}",
|
"files_deleted_by_type": "تم حذف {{count}} {{type}}",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
||||||
|
"failed_to_clean_cache_directory": "فشل تنظيف مجلد ذاكرة التخزين المؤقت",
|
||||||
"could_not_get_download_url_for_item": "تعذر الحصول على عنوان URL للتنزيل لـ{{itemName}}",
|
"could_not_get_download_url_for_item": "تعذر الحصول على عنوان URL للتنزيل لـ{{itemName}}",
|
||||||
|
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||||
"file_deleted": "تم حذف {{item}}"
|
"file_deleted": "تم حذف {{item}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"track": "أغنية",
|
"track": "أغنية",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"ok": "حسناً",
|
"ok": "حسناً",
|
||||||
"remove": "إزالة",
|
"remove": "إزالة",
|
||||||
|
"next": "التالي",
|
||||||
"back": "رجوع",
|
"back": "رجوع",
|
||||||
"continue": "متابعة",
|
"continue": "متابعة",
|
||||||
"verifying": "جارٍ التحقق...",
|
"verifying": "جارٍ التحقق...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "بحث...",
|
"search": "بحث...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||||
"next_episode": "الحلقة التالية",
|
"next_episode": "الحلقة التالية",
|
||||||
|
"refresh_tracks": "تحديث المسارات",
|
||||||
|
"audio_tracks": "مسارات الصوت:",
|
||||||
|
"playback_state": "حالة التشغيل:",
|
||||||
|
"index": "الفِهْرِس:",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"go_back": "رجوع",
|
"go_back": "رجوع",
|
||||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "عرض المزيد",
|
"show_more": "عرض المزيد",
|
||||||
"show_less": "عرض أقل",
|
"show_less": "عرض أقل",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "قوائم التشغيل",
|
"playlists": "قوائم التشغيل",
|
||||||
"tracks": "الأغاني"
|
"tracks": "الأغاني"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "الكل"
|
||||||
|
},
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
"recently_played": "تم تشغيله مؤخرًا",
|
"recently_played": "تم تشغيله مؤخرًا",
|
||||||
"frequently_played": "الأكثر تشغيلاً",
|
"frequently_played": "الأكثر تشغيلاً",
|
||||||
|
"explore": "اكتشف",
|
||||||
"top_tracks": "أفضل الأغاني",
|
"top_tracks": "أفضل الأغاني",
|
||||||
"play": "تشغيل",
|
"play": "تشغيل",
|
||||||
"shuffle": "ترتيب عشوائي",
|
"shuffle": "ترتيب عشوائي",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
||||||
"suggested_movies": "Pel·lícules suggerides",
|
"suggested_movies": "Pel·lícules suggerides",
|
||||||
|
"suggested_episodes": "Episodis suggerits",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Benvingut a Streamyfin",
|
"welcome_to_streamyfin": "Benvingut a Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuït i de codi obert per a Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "Un client gratuït i de codi obert per a Jellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Cap",
|
"None": "Cap",
|
||||||
"OnlyForced": "Només els forçats"
|
"OnlyForced": "Només els forçats"
|
||||||
},
|
},
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"bold_text": "Bold Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Black",
|
||||||
|
"Gray": "Gray",
|
||||||
|
"Silver": "Silver",
|
||||||
|
"White": "White",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Red",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Yellow",
|
||||||
|
"Olive": "Olive",
|
||||||
|
"Green": "Green",
|
||||||
|
"Teal": "Teal",
|
||||||
|
"Lime": "Lime",
|
||||||
|
"Purple": "Purple",
|
||||||
|
"Navy": "Navy",
|
||||||
|
"Blue": "Blue",
|
||||||
|
"Aqua": "Aqua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Cap",
|
||||||
|
"Thin": "Thin",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Thick"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle Color",
|
||||||
|
"subtitle_background_color": "Background Color",
|
||||||
|
"subtitle_font": "Subtitle Font",
|
||||||
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Subtitle Settings",
|
||||||
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"bold": "Bold Text",
|
||||||
|
"margin": "Bottom Margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"video_player": "Video Player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Altres",
|
"other_title": "Altres",
|
||||||
"video_orientation": "Orientació del vídeo",
|
"video_orientation": "Orientació del vídeo",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Desconeguda"
|
"UNKNOWN": "Desconeguda"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Àrea segura als controls",
|
"safe_area_in_controls": "Àrea segura als controls",
|
||||||
|
"video_player": "Reproductor de vídeo",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||||
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Oculta biblioteques",
|
"hide_libraries": "Oculta biblioteques",
|
||||||
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||||
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||||
"disabled": "Desactivat"
|
"disabled": "Desactivat"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descàrregues"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||||
"save_button": "Desa",
|
"save_button": "Desa",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Desat"
|
"saved": "Desat",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration"
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Exporta registres",
|
"export_logs": "Exporta registres",
|
||||||
"click_for_more_info": "Feu clic per obtenir més informació",
|
"click_for_more_info": "Feu clic per obtenir més informació",
|
||||||
"level": "Nivell",
|
"level": "Nivell",
|
||||||
"no_logs_available": "No hi ha registres disponibles"
|
"no_logs_available": "No hi ha registres disponibles",
|
||||||
|
"delete_all_logs": "Suprimeix tots els registres"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Idiomes",
|
"title": "Idiomes",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error en suprimir fitxers"
|
"error_deleting_files": "Error en suprimir fitxers",
|
||||||
|
"background_downloads_enabled": "Descàrregues en segon pla activades",
|
||||||
|
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descàrregues",
|
"downloads_title": "Descàrregues",
|
||||||
"tvseries": "Sèries",
|
"series": "Sèries",
|
||||||
"movies": "Pel·lícules",
|
"movies": "Pel·lícules",
|
||||||
|
"queue": "Cua",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
|
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
|
||||||
|
"no_items_in_queue": "No hi ha elements a la cua",
|
||||||
"no_downloaded_items": "No hi ha elements descarregats",
|
"no_downloaded_items": "No hi ha elements descarregats",
|
||||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||||
"delete_all_tvseries_button": "Suprimeix totes les sèries",
|
"delete_all_series_button": "Suprimeix totes les sèries",
|
||||||
"delete_all_button": "Suprimeix-ho tot",
|
"delete_all_button": "Suprimeix-ho tot",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Descàrrega activa",
|
"active_download": "Descàrrega activa",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
||||||
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
||||||
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
||||||
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
|
"deleted_all_series_successfully": "S'han suprimit totes les sèries correctament!",
|
||||||
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
|
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
|
"download_deleted": "Download Deleted",
|
||||||
"download_cancelled": "Descàrrega cancel·lada",
|
"download_cancelled": "Descàrrega cancel·lada",
|
||||||
"could_not_delete_download": "Could Not Delete Download",
|
"could_not_delete_download": "Could Not Delete Download",
|
||||||
|
"download_paused": "Download Paused",
|
||||||
|
"could_not_pause_download": "Could Not Pause Download",
|
||||||
|
"download_resumed": "Download Resumed",
|
||||||
|
"could_not_resume_download": "Could Not Resume Download",
|
||||||
"download_completed": "Descàrrega completada",
|
"download_completed": "Descàrrega completada",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
|
||||||
|
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
|
"go_to_downloads": "Ves a les descàrregues",
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||||
"message_from_server": "Missatge del servidor: {{message}}",
|
"message_from_server": "Missatge del servidor: {{message}}",
|
||||||
"next_episode": "Episodi següent",
|
"next_episode": "Episodi següent",
|
||||||
|
"refresh_tracks": "Actualitzar pistes",
|
||||||
|
"audio_tracks": "Pistes d'àudio:",
|
||||||
|
"playback_state": "Estat de reproducció:",
|
||||||
|
"index": "Índex:",
|
||||||
"continue_watching": "Continuar veient",
|
"continue_watching": "Continuar veient",
|
||||||
"go_back": "Enrere",
|
"go_back": "Enrere",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Mostra més",
|
"show_more": "Mostra més",
|
||||||
"show_less": "Mostra menys",
|
"show_less": "Mostra menys",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Nedávno přidané v {{libraryName}}",
|
"recently_added_in": "Nedávno přidané v {{libraryName}}",
|
||||||
"suggested_movies": "Navrhované filmy",
|
"suggested_movies": "Navrhované filmy",
|
||||||
|
"suggested_episodes": "Navrhované epizody",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Vítejte v Streamyfin",
|
"welcome_to_streamyfin": "Vítejte v Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Volný a Open-Source klient pro Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "Volný a Open-Source klient pro Jellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Nic",
|
"None": "Nic",
|
||||||
"OnlyForced": "Pouze vynucené"
|
"OnlyForced": "Pouze vynucené"
|
||||||
},
|
},
|
||||||
|
"text_color": "Barva textu",
|
||||||
|
"background_color": "Barva pozadí",
|
||||||
|
"outline_color": "Barva obrysu",
|
||||||
|
"outline_thickness": "Obrys tloušťky",
|
||||||
|
"background_opacity": "Průhlednost pozadí",
|
||||||
|
"outline_opacity": "Průhlednost obrysu",
|
||||||
|
"bold_text": "Bold Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Černý",
|
||||||
|
"Gray": "Šedá",
|
||||||
|
"Silver": "Stříbro",
|
||||||
|
"White": "Bílý",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Červená",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Žlutá",
|
||||||
|
"Olive": "Olivy",
|
||||||
|
"Green": "Zelená",
|
||||||
|
"Teal": "Modrozelený",
|
||||||
|
"Lime": "Světle zelená",
|
||||||
|
"Purple": "Fialová",
|
||||||
|
"Navy": "Námořní loď",
|
||||||
|
"Blue": "Modrá",
|
||||||
|
"Aqua": "Aqua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Nic",
|
||||||
|
"Thin": "Tenké",
|
||||||
|
"Normal": "Normální",
|
||||||
|
"Thick": "Tlustá"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle Color",
|
||||||
|
"subtitle_background_color": "Background Color",
|
||||||
|
"subtitle_font": "Subtitle Font",
|
||||||
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Subtitle Settings",
|
||||||
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"bold": "Bold Text",
|
||||||
|
"margin": "Bottom Margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"video_player": "Video Player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Ostatní",
|
"other_title": "Ostatní",
|
||||||
"video_orientation": "Orientace videa",
|
"video_orientation": "Orientace videa",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Neznámý"
|
"UNKNOWN": "Neznámý"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
||||||
|
"video_player": "Video přehrávač",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (experimentální + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||||
|
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||||
"hide_libraries": "Skrýt knihovny",
|
"hide_libraries": "Skrýt knihovny",
|
||||||
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
||||||
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
|
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
||||||
"disabled": "Zakázáno"
|
"disabled": "Zakázáno"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Stahování"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
||||||
"save_button": "Uložit",
|
"save_button": "Uložit",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Uloženo"
|
"saved": "Uloženo",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration"
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Exportovat protokoly",
|
"export_logs": "Exportovat protokoly",
|
||||||
"click_for_more_info": "Klikněte pro více informací",
|
"click_for_more_info": "Klikněte pro více informací",
|
||||||
"level": "Úrovně",
|
"level": "Úrovně",
|
||||||
"no_logs_available": "Žádné protokoly nejsou k dispozici"
|
"no_logs_available": "Žádné protokoly nejsou k dispozici",
|
||||||
|
"delete_all_logs": "Odstranit všechny logy"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Jazyky",
|
"title": "Jazyky",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "Systém"
|
"system": "Systém"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Chyba při mazání souborů"
|
"error_deleting_files": "Chyba při mazání souborů",
|
||||||
|
"background_downloads_enabled": "Stahování na pozadí povoleno",
|
||||||
|
"background_downloads_disabled": "Stahování na pozadí zakázáno"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Stahování",
|
"downloads_title": "Stahování",
|
||||||
"tvseries": "Televizní série",
|
"series": "Televizní série",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
|
"queue": "Fronta",
|
||||||
"other_media": "Ostatní média",
|
"other_media": "Ostatní média",
|
||||||
|
"queue_hint": "Fronta a stahování budou ztraceny při restartu aplikace",
|
||||||
|
"no_items_in_queue": "Žádné položky ve frontě",
|
||||||
"no_downloaded_items": "Žádné stažené položky",
|
"no_downloaded_items": "Žádné stažené položky",
|
||||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||||
"delete_all_tvseries_button": "Odstranit všechny TV-série",
|
"delete_all_series_button": "Odstranit všechny TV-série",
|
||||||
"delete_all_button": "Smazat vše",
|
"delete_all_button": "Smazat vše",
|
||||||
"delete_all_other_media_button": "Odstranit ostatní média",
|
"delete_all_other_media_button": "Odstranit ostatní média",
|
||||||
"active_download": "Aktivní stahování",
|
"active_download": "Aktivní stahování",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
|
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
|
||||||
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
|
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
|
||||||
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
|
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
|
||||||
"deleted_all_tvseries_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
"deleted_all_series_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
||||||
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
|
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
||||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||||
|
"download_deleted": "Stahování smazáno",
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Stahování nelze odstranit",
|
"could_not_delete_download": "Stahování nelze odstranit",
|
||||||
|
"download_paused": "Stahování pozastaveno",
|
||||||
|
"could_not_pause_download": "Nelze pozastavit stahování",
|
||||||
|
"download_resumed": "Stahování obnoveno",
|
||||||
|
"could_not_resume_download": "Nelze pokračovat v stahování",
|
||||||
"download_completed": "Stahování dokončeno",
|
"download_completed": "Stahování dokončeno",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Všechny soubory, složky a úlohy byly úspěšně odstraněny",
|
||||||
|
"failed_to_clean_cache_directory": "Nepodařilo se vyčistit adresář mezipaměti",
|
||||||
"could_not_get_download_url_for_item": "Nelze získat URL pro stažení {{itemName}}",
|
"could_not_get_download_url_for_item": "Nelze získat URL pro stažení {{itemName}}",
|
||||||
|
"go_to_downloads": "Přejít na stahování",
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Hledat...",
|
"search": "Hledat...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||||
"next_episode": "Další epizoda",
|
"next_episode": "Další epizoda",
|
||||||
|
"refresh_tracks": "Obnovit skladby",
|
||||||
|
"audio_tracks": "Zvukové stopy:",
|
||||||
|
"playback_state": "Stav přehrávání:",
|
||||||
|
"index": "Index:",
|
||||||
"continue_watching": "Pokračovat ve sledování",
|
"continue_watching": "Pokračovat ve sledování",
|
||||||
"go_back": "Zpět",
|
"go_back": "Zpět",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Zobrazit více",
|
"show_more": "Zobrazit více",
|
||||||
"show_less": "Zobrazit méně",
|
"show_less": "Zobrazit méně",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Senest tilføjet i {{libraryName}}",
|
"recently_added_in": "Senest tilføjet i {{libraryName}}",
|
||||||
"suggested_movies": "Foreslåede film",
|
"suggested_movies": "Foreslåede film",
|
||||||
|
"suggested_episodes": "Foreslåede episoder",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "En gratis og open-source klient til Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "En gratis og open-source klient til Jellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Ingen",
|
"None": "Ingen",
|
||||||
"OnlyForced": "Kun tvungne undertekster"
|
"OnlyForced": "Kun tvungne undertekster"
|
||||||
},
|
},
|
||||||
|
"text_color": "Tekst Farve",
|
||||||
|
"background_color": "Baggrunds Farve",
|
||||||
|
"outline_color": "Omrids Farve",
|
||||||
|
"outline_thickness": "Omrids Tykkelse",
|
||||||
|
"background_opacity": "Baggrunds Gennemsigtighed",
|
||||||
|
"outline_opacity": "Omrids Gennemsigtighed",
|
||||||
|
"bold_text": "Bold Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Sort",
|
||||||
|
"Gray": "Grå",
|
||||||
|
"Silver": "Sølv",
|
||||||
|
"White": "Hvid",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Rød",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Gul",
|
||||||
|
"Olive": "Oliven",
|
||||||
|
"Green": "Grøn",
|
||||||
|
"Teal": "Grønblåt",
|
||||||
|
"Lime": "Limegrøn",
|
||||||
|
"Purple": "Lilla",
|
||||||
|
"Navy": "Flåden",
|
||||||
|
"Blue": "Blå",
|
||||||
|
"Aqua": "Aqua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Ingen",
|
||||||
|
"Thin": "Tynd",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Tyk"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle Color",
|
||||||
|
"subtitle_background_color": "Background Color",
|
||||||
|
"subtitle_font": "Subtitle Font",
|
||||||
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Subtitle Settings",
|
||||||
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"bold": "Bold Text",
|
||||||
|
"margin": "Bottom Margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"video_player": "Video Player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Andet",
|
"other_title": "Andet",
|
||||||
"video_orientation": "Videoorientering",
|
"video_orientation": "Videoorientering",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Ukendt"
|
"UNKNOWN": "Ukendt"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
"safe_area_in_controls": "Sikkert område i kontroller",
|
||||||
|
"video_player": "Videospiller",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Eksperimentel + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||||
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||||
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
||||||
"disabled": "Deaktiveret"
|
"disabled": "Deaktiveret"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Downloads"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Læs mere om Marlin.",
|
"read_more_about_marlin": "Læs mere om Marlin.",
|
||||||
"save_button": "Gem",
|
"save_button": "Gem",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Gemt"
|
"saved": "Gemt",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration"
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Eksporter logfiler",
|
"export_logs": "Eksporter logfiler",
|
||||||
"click_for_more_info": "Klik for mere info",
|
"click_for_more_info": "Klik for mere info",
|
||||||
"level": "Niveau",
|
"level": "Niveau",
|
||||||
"no_logs_available": "Ingen logfiler tilgængelige"
|
"no_logs_available": "Ingen logfiler tilgængelige",
|
||||||
|
"delete_all_logs": "Slet alle logfiler"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Sprog",
|
"title": "Sprog",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Fejl ved sletning af filer"
|
"error_deleting_files": "Fejl ved sletning af filer",
|
||||||
|
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
|
||||||
|
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-serier",
|
"series": "TV-serier",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
|
"queue": "Kø",
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
|
"queue_hint": "Kø og downloads vil gå tabt ved genstart af appen",
|
||||||
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen downloadede elementer",
|
"no_downloaded_items": "Ingen downloadede elementer",
|
||||||
"delete_all_movies_button": "Slet alle film",
|
"delete_all_movies_button": "Slet alle film",
|
||||||
"delete_all_tvseries_button": "Slet alle TV-serier",
|
"delete_all_series_button": "Slet alle TV-serier",
|
||||||
"delete_all_button": "Slet alle",
|
"delete_all_button": "Slet alle",
|
||||||
"delete_all_other_media_button": "Slet andre medier",
|
"delete_all_other_media_button": "Slet andre medier",
|
||||||
"active_download": "Aktiv download",
|
"active_download": "Aktiv download",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
|
||||||
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
|
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
|
||||||
"deleted_all_tvseries_successfully": "Alle TV-serier er slettet med succes!",
|
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
|
||||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
|
"download_deleted": "Download Slettet",
|
||||||
"download_cancelled": "Download afbrudt",
|
"download_cancelled": "Download afbrudt",
|
||||||
"could_not_delete_download": "Kunne Ikke Slette Download",
|
"could_not_delete_download": "Kunne Ikke Slette Download",
|
||||||
|
"download_paused": "Download Pauset",
|
||||||
|
"could_not_pause_download": "Kunne Ikke Pause Download",
|
||||||
|
"download_resumed": "Download Genoprettet",
|
||||||
|
"could_not_resume_download": "Kunne Ikke Genoptage Download",
|
||||||
"download_completed": "Download fuldført",
|
"download_completed": "Download fuldført",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobs blev slettet med succes",
|
||||||
|
"failed_to_clean_cache_directory": "Kunne ikke rense cache-mappe",
|
||||||
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
||||||
|
"go_to_downloads": "Gå til downloads",
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Søg...",
|
"search": "Søg...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||||
"message_from_server": "Besked fra server: {{message}}",
|
"message_from_server": "Besked fra server: {{message}}",
|
||||||
"next_episode": "Næste episode",
|
"next_episode": "Næste episode",
|
||||||
|
"refresh_tracks": "Opdater spor",
|
||||||
|
"audio_tracks": "Lydspor:",
|
||||||
|
"playback_state": "Afspilningstilstand:",
|
||||||
|
"index": "Indeks:",
|
||||||
"continue_watching": "Fortsæt med at se",
|
"continue_watching": "Fortsæt med at se",
|
||||||
"go_back": "Gå Tilbage",
|
"go_back": "Gå Tilbage",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Vis mere",
|
"show_more": "Vis mere",
|
||||||
"show_less": "Vis mindre",
|
"show_less": "Vis mindre",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||||
"suggested_movies": "Empfohlene Filme",
|
"suggested_movies": "Empfohlene Filme",
|
||||||
|
"suggested_episodes": "Empfohlene Episoden",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Willkommen bei Streamyfin",
|
"welcome_to_streamyfin": "Willkommen bei Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Ein kostenloser und Open-Source-Client für Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "Ein kostenloser und Open-Source-Client für Jellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Keine",
|
"None": "Keine",
|
||||||
"OnlyForced": "Nur erzwungene"
|
"OnlyForced": "Nur erzwungene"
|
||||||
},
|
},
|
||||||
|
"text_color": "Textfarbe",
|
||||||
|
"background_color": "Hintergrundfarbe",
|
||||||
|
"outline_color": "Konturfarbe",
|
||||||
|
"outline_thickness": "Konturdicke",
|
||||||
|
"background_opacity": "Hintergrundtransparenz",
|
||||||
|
"outline_opacity": "Konturtransparenz",
|
||||||
|
"bold_text": "Fettgedruckter Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Schwarz",
|
||||||
|
"Gray": "Grau",
|
||||||
|
"Silver": "Silber",
|
||||||
|
"White": "Weiß",
|
||||||
|
"Maroon": "Rotbraun",
|
||||||
|
"Red": "Rot",
|
||||||
|
"Fuchsia": "Magenta",
|
||||||
|
"Yellow": "Gelb",
|
||||||
|
"Olive": "Olivgrün",
|
||||||
|
"Green": "Grün",
|
||||||
|
"Teal": "Türkis",
|
||||||
|
"Lime": "Hellgrün",
|
||||||
|
"Purple": "Lila",
|
||||||
|
"Navy": "Marineblau",
|
||||||
|
"Blue": "Blau",
|
||||||
|
"Aqua": "Himmelblau"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Keine",
|
||||||
|
"Thin": "Dünn",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Dick"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Untertitelfarbe",
|
||||||
|
"subtitle_background_color": "Hintergrundfarbe",
|
||||||
|
"subtitle_font": "Untertitel-Schriftart",
|
||||||
|
"ksplayer_title": "KSPlayer Einstellungen",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Untertitel-Einstellungen",
|
||||||
|
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
|
||||||
|
"text_color": "Schriftfarbe",
|
||||||
|
"background_color": "Hintergrundfarbe",
|
||||||
|
"background_opacity": "Hintergrundtransparenz",
|
||||||
|
"outline_color": "Konturfarbe",
|
||||||
|
"outline_opacity": "Konturtransparenz",
|
||||||
|
"outline_thickness": "Konturdicke",
|
||||||
|
"bold": "Fettgedruckter Text",
|
||||||
|
"margin": "Unterer Abstand"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Videoplayer",
|
||||||
|
"video_player": "Videoplayer",
|
||||||
|
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Sonstiges",
|
"other_title": "Sonstiges",
|
||||||
"video_orientation": "Videoausrichtung",
|
"video_orientation": "Videoausrichtung",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Unbekannt"
|
"UNKNOWN": "Unbekannt"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||||
|
"video_player": "Videoplayer",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
|
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||||
"hide_libraries": "Bibliotheken ausblenden",
|
"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": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||||
"disabled": "Deaktiviert"
|
"disabled": "Deaktiviert"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Downloads"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
"playback_title": "Wiedergabe",
|
"playback_title": "Wiedergabe",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||||
"save_button": "Speichern",
|
"save_button": "Speichern",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Gespeichert"
|
"saved": "Gespeichert",
|
||||||
}
|
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Streamystats aktivieren",
|
||||||
"disable_streamystats": "Streamystats deaktivieren",
|
"disable_streamystats": "Streamystats deaktivieren",
|
||||||
"enable_search": "Zum Suchen verwenden",
|
"enable_search": "Zum Suchen verwenden",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||||
|
"save_button": "Speichern",
|
||||||
"save": "Gespeichert",
|
"save": "Gespeichert",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Startseitenbereiche",
|
||||||
"enable_movie_recommendations": "Filmempfehlungen",
|
"enable_movie_recommendations": "Filmempfehlungen",
|
||||||
"enable_series_recommendations": "Serienempfehlungen",
|
"enable_series_recommendations": "Serienempfehlungen",
|
||||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Merklisten-Integration aktivieren"
|
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||||
|
"watchlist_button": "Merklisten-Integration umschalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||||
"music_cache_title": "Musik-Cache",
|
"music_cache_title": "Musik-Cache",
|
||||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
||||||
|
"enable_music_cache": "Musik-Cache aktivieren",
|
||||||
"clear_music_cache": "Musik-Cache leeren",
|
"clear_music_cache": "Musik-Cache leeren",
|
||||||
"music_cache_size": "{{size}} gechached",
|
"music_cache_size": "{{size}} gechached",
|
||||||
"music_cache_cleared": "Musik-Cache geleert",
|
"music_cache_cleared": "Musik-Cache geleert",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Logs exportieren",
|
"export_logs": "Logs exportieren",
|
||||||
"click_for_more_info": "Für mehr Informationen klicken",
|
"click_for_more_info": "Für mehr Informationen klicken",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"no_logs_available": "Keine Logs verfügbar"
|
"no_logs_available": "Keine Logs verfügbar",
|
||||||
|
"delete_all_logs": "Alle Logs löschen"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Sprachen",
|
"title": "Sprachen",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Fehler beim Löschen von Dateien"
|
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||||
|
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||||
|
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "Serien",
|
"series": "Serien",
|
||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
|
"queue": "Warteschlange",
|
||||||
"other_media": "Andere Medien",
|
"other_media": "Andere Medien",
|
||||||
|
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||||
|
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||||
"delete_all_movies_button": "Alle Filme löschen",
|
"delete_all_movies_button": "Alle Filme löschen",
|
||||||
"delete_all_tvseries_button": "Alle Serien löschen",
|
"delete_all_series_button": "Alle Serien löschen",
|
||||||
"delete_all_button": "Alles löschen",
|
"delete_all_button": "Alles löschen",
|
||||||
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
||||||
"active_download": "Aktiver Download",
|
"active_download": "Aktiver Download",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||||
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||||
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||||
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
|
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
||||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||||
|
"download_deleted": "Download gelöscht",
|
||||||
"download_cancelled": "Download abgebrochen",
|
"download_cancelled": "Download abgebrochen",
|
||||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
||||||
|
"download_paused": "Download pausiert",
|
||||||
|
"could_not_pause_download": "Download konnte nicht angehalten werden",
|
||||||
|
"download_resumed": "Download fortgesetzt",
|
||||||
|
"could_not_resume_download": "Download konnte nicht fortgesetzt werden",
|
||||||
"download_completed": "Download abgeschlossen",
|
"download_completed": "Download abgeschlossen",
|
||||||
"download_failed": "Download fehlgeschlagen",
|
"download_failed": "Download fehlgeschlagen",
|
||||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} Lädt",
|
"item_already_downloading": "{{item}} Lädt",
|
||||||
"all_files_deleted": "Alle Downloads gelöscht",
|
"all_files_deleted": "Alle Downloads gelöscht",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||||
|
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||||
|
"go_to_downloads": "Zu Downloads gehen",
|
||||||
"file_deleted": "{{item}} gelöscht"
|
"file_deleted": "{{item}} gelöscht"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"track": "Spur",
|
"track": "Spur",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
|
"next": "Weiter",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"continue": "Fortsetzen",
|
"continue": "Fortsetzen",
|
||||||
"verifying": "Verifiziere...",
|
"verifying": "Verifiziere...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Suchen...",
|
"search": "Suchen...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||||
"next_episode": "Nächste Episode",
|
"next_episode": "Nächste Episode",
|
||||||
|
"refresh_tracks": "Spuren aktualisieren",
|
||||||
|
"audio_tracks": "Audiospuren:",
|
||||||
|
"playback_state": "Wiedergabestatus:",
|
||||||
|
"index": "Index:",
|
||||||
"continue_watching": "Fortsetzen",
|
"continue_watching": "Fortsetzen",
|
||||||
"go_back": "Zurück",
|
"go_back": "Zurück",
|
||||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Mehr anzeigen",
|
"show_more": "Mehr anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "Titel"
|
"tracks": "Titel"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
"recently_added": "Kürzlich hinzugefügt",
|
"recently_added": "Kürzlich hinzugefügt",
|
||||||
"recently_played": "Vor kurzem gehört",
|
"recently_played": "Vor kurzem gehört",
|
||||||
"frequently_played": "Oft gehört",
|
"frequently_played": "Oft gehört",
|
||||||
|
"explore": "Entdecken",
|
||||||
"top_tracks": "Top-Titel",
|
"top_tracks": "Top-Titel",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
|
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
|
||||||
"suggested_movies": "Προτεινόμενες Ταινίες",
|
"suggested_movies": "Προτεινόμενες Ταινίες",
|
||||||
|
"suggested_episodes": "Προτεινόμενα Επεισόδια",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Καλώς ήρθατε στο Streamyfin",
|
"welcome_to_streamyfin": "Καλώς ήρθατε στο Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Ένας ελεύθερος και ανοιχτού κώδικα πελάτης για τη ζελυφίνη.",
|
"a_free_and_open_source_client_for_jellyfin": "Ένας ελεύθερος και ανοιχτού κώδικα πελάτης για τη ζελυφίνη.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Κανένα",
|
"None": "Κανένα",
|
||||||
"OnlyForced": "Μόνο"
|
"OnlyForced": "Μόνο"
|
||||||
},
|
},
|
||||||
|
"text_color": "Χρώμα Κειμένου",
|
||||||
|
"background_color": "Χρώμα Φόντου",
|
||||||
|
"outline_color": "Χρώμα Περιγράμματος",
|
||||||
|
"outline_thickness": "Πάχος Περιγράμματος",
|
||||||
|
"background_opacity": "Αδιαφάνεια Φόντου",
|
||||||
|
"outline_opacity": "Αδιαφάνεια Περιγράμματος",
|
||||||
|
"bold_text": "Bold Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Μαύρο",
|
||||||
|
"Gray": "Γκρι",
|
||||||
|
"Silver": "Ασημένιο",
|
||||||
|
"White": "Λευκό",
|
||||||
|
"Maroon": "Μαρώ",
|
||||||
|
"Red": "Κόκκινο",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Κίτρινο",
|
||||||
|
"Olive": "Ελιές",
|
||||||
|
"Green": "Πράσινο",
|
||||||
|
"Teal": "Τιρκουάζ",
|
||||||
|
"Lime": "Άσβεστος",
|
||||||
|
"Purple": "Μωβ",
|
||||||
|
"Navy": "Ναυτικό",
|
||||||
|
"Blue": "Μπλε",
|
||||||
|
"Aqua": "Νερό"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Κανένα",
|
||||||
|
"Thin": "Λεπτό",
|
||||||
|
"Normal": "Κανονικό",
|
||||||
|
"Thick": "Παχύ"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle Color",
|
||||||
|
"subtitle_background_color": "Background Color",
|
||||||
|
"subtitle_font": "Subtitle Font",
|
||||||
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Subtitle Settings",
|
||||||
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"bold": "Bold Text",
|
||||||
|
"margin": "Bottom Margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"video_player": "Video Player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Άλλο",
|
"other_title": "Άλλο",
|
||||||
"video_orientation": "Προσανατολισμός Βίντεο",
|
"video_orientation": "Προσανατολισμός Βίντεο",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Άγνωστο"
|
"UNKNOWN": "Άγνωστο"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
||||||
|
"video_player": "Αναπαραγωγέας Βίντεο",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Πειραματική + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
||||||
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||||
"select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
"select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
||||||
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
|
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
||||||
"disabled": "Απενεργοποιημένο"
|
"disabled": "Απενεργοποιημένο"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Λήψεις"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
||||||
"save_button": "Αποθήκευση",
|
"save_button": "Αποθήκευση",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Αποθηκεύτηκε"
|
"saved": "Αποθηκεύτηκε",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration"
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Εξαγωγή Αρχείων Καταγραφής",
|
"export_logs": "Εξαγωγή Αρχείων Καταγραφής",
|
||||||
"click_for_more_info": "Κάντε κλικ για περισσότερες πληροφορίες",
|
"click_for_more_info": "Κάντε κλικ για περισσότερες πληροφορίες",
|
||||||
"level": "Επίπεδο",
|
"level": "Επίπεδο",
|
||||||
"no_logs_available": "Δεν Υπάρχουν Διαθέσιμα Αρχεία Καταγραφής"
|
"no_logs_available": "Δεν Υπάρχουν Διαθέσιμα Αρχεία Καταγραφής",
|
||||||
|
"delete_all_logs": "Διαγραφή Όλων Των Καταγραφών"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Γλώσσες",
|
"title": "Γλώσσες",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "Σύστημα"
|
"system": "Σύστημα"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων"
|
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
|
||||||
|
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
|
||||||
|
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Λήψεις",
|
"downloads_title": "Λήψεις",
|
||||||
"tvseries": "Τηλεόραση-Σειρά",
|
"series": "Τηλεόραση-Σειρά",
|
||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
|
"queue": "Ουρά",
|
||||||
"other_media": "Άλλα μέσα",
|
"other_media": "Άλλα μέσα",
|
||||||
|
"queue_hint": "Ουρά και λήψεις θα χαθούν κατά την επανεκκίνηση της εφαρμογής",
|
||||||
|
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
||||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||||
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||||
"delete_all_button": "Διαγραφή Όλων",
|
"delete_all_button": "Διαγραφή Όλων",
|
||||||
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
|
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
|
||||||
"active_download": "Ενεργή Λήψη",
|
"active_download": "Ενεργή Λήψη",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
|
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
|
||||||
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
|
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
|
||||||
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
|
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
|
||||||
"deleted_all_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||||
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
|
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||||
|
"download_deleted": "Η Λήψη Διαγράφηκε",
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_cancelled": "Download Cancelled",
|
||||||
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
||||||
|
"download_paused": "Λήψη Σε Παύση",
|
||||||
|
"could_not_pause_download": "Αδυναμία Παύσης Λήψης",
|
||||||
|
"download_resumed": "Συνέχιση Λήψης",
|
||||||
|
"could_not_resume_download": "Αδυναμία Συνέχισης Λήψης",
|
||||||
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
||||||
"download_failed": "Download Failed",
|
"download_failed": "Download Failed",
|
||||||
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Όλα τα αρχεία, οι φάκελοι και οι εργασίες διαγράφηκαν με επιτυχία",
|
||||||
|
"failed_to_clean_cache_directory": "Αποτυχία καθαρισμού φακέλου προσωρινής μνήμης",
|
||||||
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
||||||
|
"go_to_downloads": "Μετάβαση στις λήψεις",
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Αναζήτηση...",
|
"search": "Αναζήτηση...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
||||||
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
||||||
"next_episode": "Επόμενο Επεισόδιο",
|
"next_episode": "Επόμενο Επεισόδιο",
|
||||||
|
"refresh_tracks": "Ανανέωση Κομματιών",
|
||||||
|
"audio_tracks": "Κομμάτια Ήχου:",
|
||||||
|
"playback_state": "Κατάσταση Αναπαραγωγής:",
|
||||||
|
"index": "Δείκτης:",
|
||||||
"continue_watching": "Συνέχεια Παρακολούθησης",
|
"continue_watching": "Συνέχεια Παρακολούθησης",
|
||||||
"go_back": "Μετάβαση Πίσω",
|
"go_back": "Μετάβαση Πίσω",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Εμφάνιση Περισσότερων",
|
"show_more": "Εμφάνιση Περισσότερων",
|
||||||
"show_less": "Εμφάνιση Λιγότερων",
|
"show_less": "Εμφάνιση Λιγότερων",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"next_up": "Sekva",
|
"next_up": "Sekva",
|
||||||
"recently_added_in": "Ĵus Aldonita en {{libraryName}}",
|
"recently_added_in": "Ĵus Aldonita en {{libraryName}}",
|
||||||
"suggested_movies": "Sugestitaj Filmoj",
|
"suggested_movies": "Sugestitaj Filmoj",
|
||||||
|
"suggested_episodes": "Sugestitaj Epizodoj",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Bonvenon al Streamyfin",
|
"welcome_to_streamyfin": "Bonvenon al Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Senpaga kaj malfermfonta kliento por Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "Senpaga kaj malfermfonta kliento por Jellyfin.",
|
||||||
@@ -127,6 +128,11 @@
|
|||||||
"UNKNOWN": "Nekonata"
|
"UNKNOWN": "Nekonata"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sekura areo en kontroloj",
|
"safe_area_in_controls": "Sekura areo en kontroloj",
|
||||||
|
"video_player": "Video-ludilo",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Eksperimenta + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Montri Proprajn Menuajn Ligilojn",
|
"show_custom_menu_links": "Montri Proprajn Menuajn Ligilojn",
|
||||||
"hide_libraries": "Kaŝi Bibliotekojn",
|
"hide_libraries": "Kaŝi Bibliotekojn",
|
||||||
"select_liraries_you_want_to_hide": "Elektu la bibliotekojn, kiujn vi volas kaŝi de la Biblioteka langeto kaj hejmpaĝaj sekcioj.",
|
"select_liraries_you_want_to_hide": "Elektu la bibliotekojn, kiujn vi volas kaŝi de la Biblioteka langeto kaj hejmpaĝaj sekcioj.",
|
||||||
@@ -134,6 +140,7 @@
|
|||||||
"default_quality": "Defaŭlta kvalito"
|
"default_quality": "Defaŭlta kvalito"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
|
"downloads_title": "Elŝutoj",
|
||||||
"optimized_versions_server": "Optimumigitaj versioj servilo",
|
"optimized_versions_server": "Optimumigitaj versioj servilo",
|
||||||
"save_button": "Konservi",
|
"save_button": "Konservi",
|
||||||
"optimized_server": "Optimumigita Servilo",
|
"optimized_server": "Optimumigita Servilo",
|
||||||
@@ -198,7 +205,8 @@
|
|||||||
"export_logs": "Eksporti protokolojn",
|
"export_logs": "Eksporti protokolojn",
|
||||||
"click_for_more_info": "Klaku por pli da informoj",
|
"click_for_more_info": "Klaku por pli da informoj",
|
||||||
"level": "Nivelo",
|
"level": "Nivelo",
|
||||||
"no_logs_available": "Neniuj protokoloj disponeblaj"
|
"no_logs_available": "Neniuj protokoloj disponeblaj",
|
||||||
|
"delete_all_logs": "Forigi ĉiujn protokolojn"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Lingvoj",
|
"title": "Lingvoj",
|
||||||
@@ -208,6 +216,8 @@
|
|||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Eraro forigante dosierojn",
|
"error_deleting_files": "Eraro forigante dosierojn",
|
||||||
|
"background_downloads_enabled": "Fonaj elŝutoj ebligitaj",
|
||||||
|
"background_downloads_disabled": "Fonaj elŝutoj malŝaltitaj",
|
||||||
"connected": "Konektita",
|
"connected": "Konektita",
|
||||||
"could_not_connect": "Ne povis konekti",
|
"could_not_connect": "Ne povis konekti",
|
||||||
"invalid_url": "Nevalida URL"
|
"invalid_url": "Nevalida URL"
|
||||||
@@ -219,11 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Elŝutoj",
|
"downloads_title": "Elŝutoj",
|
||||||
"tvseries": "Televidaj serioj",
|
"series": "Televidaj serioj",
|
||||||
"movies": "Filmoj",
|
"movies": "Filmoj",
|
||||||
|
"queue": "Vico",
|
||||||
|
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||||
|
"no_items_in_queue": "Neniuj eroj en vico",
|
||||||
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
||||||
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
||||||
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
"delete_all_series_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||||
"delete_all_button": "Forigi ĉion",
|
"delete_all_button": "Forigi ĉion",
|
||||||
"active_download": "Aktiva elŝuto",
|
"active_download": "Aktiva elŝuto",
|
||||||
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
||||||
@@ -240,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
||||||
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
||||||
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
||||||
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||||
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||||
"download_cancelled": "Elŝuto nuligita",
|
"download_cancelled": "Elŝuto nuligita",
|
||||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||||
"download_completed": "Elŝuto finita",
|
"download_completed": "Elŝuto finita",
|
||||||
@@ -256,7 +269,9 @@
|
|||||||
"no_response_received_from_server": "Neniu respondo ricevita de la servilo",
|
"no_response_received_from_server": "Neniu respondo ricevita de la servilo",
|
||||||
"error_setting_up_the_request": "Eraro starigante la peton",
|
"error_setting_up_the_request": "Eraro starigante la peton",
|
||||||
"failed_to_start_download_for_item_unexpected_error": "Malsukcesis komenci elŝutadon por {{item}}: Neatendita eraro",
|
"failed_to_start_download_for_item_unexpected_error": "Malsukcesis komenci elŝutadon por {{item}}: Neatendita eraro",
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj"
|
"all_files_folders_and_jobs_deleted_successfully": "Ĉiuj dosieroj, dosierujoj kaj taskoj sukcese forigitaj",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj",
|
||||||
|
"go_to_downloads": "Iri al elŝutoj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -350,8 +365,12 @@
|
|||||||
"video_has_finished_playing": "Video finis ludi!",
|
"video_has_finished_playing": "Video finis ludi!",
|
||||||
"no_video_source": "Neniu video-fonto...",
|
"no_video_source": "Neniu video-fonto...",
|
||||||
"next_episode": "Sekva Epizodo",
|
"next_episode": "Sekva Epizodo",
|
||||||
|
"refresh_tracks": "Refreŝigi Trakojn",
|
||||||
"subtitle_tracks": "Subtekstaj Trakoj:",
|
"subtitle_tracks": "Subtekstaj Trakoj:",
|
||||||
"no_data_available": "Neniuj datumoj disponeblaj"
|
"audio_tracks": "Aŭdiaj Trakoj:",
|
||||||
|
"playback_state": "Ludada Stato:",
|
||||||
|
"no_data_available": "Neniuj datumoj disponeblaj",
|
||||||
|
"index": "Indekso:"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Sekva",
|
"next_up": "Sekva",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continuar y siguiente",
|
"continue_and_next_up": "Continuar y siguiente",
|
||||||
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
||||||
"suggested_movies": "Películas sugeridas",
|
"suggested_movies": "Películas sugeridas",
|
||||||
|
"suggested_episodes": "Episodios sugeridos",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Bienvenido a Streamyfin",
|
"welcome_to_streamyfin": "Bienvenido a Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Un cliente gratuito y de código abierto para Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "Un cliente gratuito y de código abierto para Jellyfin.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Nada",
|
"None": "Nada",
|
||||||
"OnlyForced": "Solo forzados"
|
"OnlyForced": "Solo forzados"
|
||||||
},
|
},
|
||||||
|
"text_color": "Color del texto",
|
||||||
|
"background_color": "Color de fondo",
|
||||||
|
"outline_color": "Color de salida",
|
||||||
|
"outline_thickness": "Grosor exterior",
|
||||||
|
"background_opacity": "Opacidad de fondo",
|
||||||
|
"outline_opacity": "Opacidad exterior",
|
||||||
|
"bold_text": "Texto en negrita",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Negro",
|
||||||
|
"Gray": "Gris",
|
||||||
|
"Silver": "Plata",
|
||||||
|
"White": "Blanco",
|
||||||
|
"Maroon": "Granate",
|
||||||
|
"Red": "Rojo",
|
||||||
|
"Fuchsia": "Fucsia",
|
||||||
|
"Yellow": "Amarillo",
|
||||||
|
"Olive": "Oliva",
|
||||||
|
"Green": "Verde",
|
||||||
|
"Teal": "Cereal",
|
||||||
|
"Lime": "Lima",
|
||||||
|
"Purple": "Morado",
|
||||||
|
"Navy": "Naval",
|
||||||
|
"Blue": "Azul",
|
||||||
|
"Aqua": "Agua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Ninguno",
|
||||||
|
"Thin": "Ligero",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Grosor"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Color de los Subtítulos",
|
||||||
|
"subtitle_background_color": "Color del fondo",
|
||||||
|
"subtitle_font": "Fuente de los subtítulos",
|
||||||
|
"ksplayer_title": "Ajustes de KSPlayer",
|
||||||
|
"hardware_decode": "Decodificación de hardware",
|
||||||
|
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "Configuración de subtítulos VLC",
|
||||||
|
"hint": "Personalizar la apariencia de los subtítulos para el reproductor VLC. Los cambios tendrán efecto en la próxima reproducción.",
|
||||||
|
"text_color": "Color del texto",
|
||||||
|
"background_color": "Color del fondo",
|
||||||
|
"background_opacity": "Opacidad del fondo",
|
||||||
|
"outline_color": "Color del contorno",
|
||||||
|
"outline_opacity": "Opacidad del contorno",
|
||||||
|
"outline_thickness": "Grosor del contorno",
|
||||||
|
"bold": "Texto en negrita",
|
||||||
|
"margin": "Margen inferior"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Reproductor de vídeo",
|
||||||
|
"video_player": "Reproductor de vídeo",
|
||||||
|
"video_player_description": "Elige qué reproductor de vídeo en iOS",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Otros",
|
"other_title": "Otros",
|
||||||
"video_orientation": "Orientación de vídeo",
|
"video_orientation": "Orientación de vídeo",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Desconocida"
|
"UNKNOWN": "Desconocida"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Área segura en controles",
|
"safe_area_in_controls": "Área segura en controles",
|
||||||
|
"video_player": "Reproductor de vídeo",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||||
|
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
"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",
|
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||||
"disabled": "Deshabilitado"
|
"disabled": "Deshabilitado"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descargas"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Música",
|
"title": "Música",
|
||||||
"playback_title": "Reproducir",
|
"playback_title": "Reproducir",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Leer más sobre Marlin.",
|
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||||
"save_button": "Guardar",
|
"save_button": "Guardar",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Guardado"
|
"saved": "Guardado",
|
||||||
}
|
"refreshed": "Ajustes del servidor actualizados"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Actualizar ajustes del servidor"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Habilitar Streamystats",
|
||||||
"disable_streamystats": "Deshabilitar Streamystats",
|
"disable_streamystats": "Deshabilitar Streamystats",
|
||||||
"enable_search": "Usar para la búsqueda",
|
"enable_search": "Usar para la búsqueda",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
||||||
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||||
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
||||||
|
"save_button": "Guardar",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"features_title": "Características",
|
"features_title": "Características",
|
||||||
|
"home_sections_title": "Secciones de inicio",
|
||||||
"enable_movie_recommendations": "Recomendaciones de películas",
|
"enable_movie_recommendations": "Recomendaciones de películas",
|
||||||
"enable_series_recommendations": "Recomendaciones de series",
|
"enable_series_recommendations": "Recomendaciones de series",
|
||||||
"enable_promoted_watchlists": "Listas promocionadas",
|
"enable_promoted_watchlists": "Listas promocionadas",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento"
|
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento",
|
||||||
|
"watchlist_button": "Activar o desactivar la integración de la lista de seguimiento"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
||||||
"music_cache_title": "Caché de música",
|
"music_cache_title": "Caché de música",
|
||||||
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
|
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
|
||||||
|
"enable_music_cache": "Activar Caché de Música",
|
||||||
"clear_music_cache": "Borrar Caché de Música",
|
"clear_music_cache": "Borrar Caché de Música",
|
||||||
"music_cache_size": "Caché {{Tamaño}}",
|
"music_cache_size": "Caché {{Tamaño}}",
|
||||||
"music_cache_cleared": "Caché de música eliminado",
|
"music_cache_cleared": "Caché de música eliminado",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Export logs",
|
"export_logs": "Export logs",
|
||||||
"click_for_more_info": "Click for more info",
|
"click_for_more_info": "Click for more info",
|
||||||
"level": "Nivel",
|
"level": "Nivel",
|
||||||
"no_logs_available": "No hay registros disponibles"
|
"no_logs_available": "No hay registros disponibles",
|
||||||
|
"delete_all_logs": "Eliminar todos los registros"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Idiomas",
|
"title": "Idiomas",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "Sistema"
|
"system": "Sistema"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error al eliminar archivos"
|
"error_deleting_files": "Error al eliminar archivos",
|
||||||
|
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
||||||
|
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descargas",
|
"downloads_title": "Descargas",
|
||||||
"tvseries": "Series",
|
"series": "Series",
|
||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
|
"queue": "Cola",
|
||||||
"other_media": "Otros medios",
|
"other_media": "Otros medios",
|
||||||
|
"queue_hint": "La cola de series y películas se perderá al reiniciar la app",
|
||||||
|
"no_items_in_queue": "No hay ítems en la cola",
|
||||||
"no_downloaded_items": "No hay ítems descargados",
|
"no_downloaded_items": "No hay ítems descargados",
|
||||||
"delete_all_movies_button": "Eliminar todas las películas",
|
"delete_all_movies_button": "Eliminar todas las películas",
|
||||||
"delete_all_tvseries_button": "Eliminar todas las series",
|
"delete_all_series_button": "Eliminar todas las series",
|
||||||
"delete_all_button": "Eliminar todo",
|
"delete_all_button": "Eliminar todo",
|
||||||
"delete_all_other_media_button": "Eliminar otros medios",
|
"delete_all_other_media_button": "Eliminar otros medios",
|
||||||
"active_download": "Descarga activa",
|
"active_download": "Descarga activa",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
||||||
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
||||||
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
||||||
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
|
||||||
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
"failed_to_delete_all_series": "Error al eliminar todas las series",
|
||||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||||
|
"download_deleted": "Descarga eliminada",
|
||||||
"download_cancelled": "Descarga cancelada",
|
"download_cancelled": "Descarga cancelada",
|
||||||
"could_not_delete_download": "No se pudo eliminar la descarga",
|
"could_not_delete_download": "No se pudo eliminar la descarga",
|
||||||
|
"download_paused": "Descarga pausada",
|
||||||
|
"could_not_pause_download": "No se pudo pausar la descarga",
|
||||||
|
"download_resumed": "Descarga rebatida",
|
||||||
|
"could_not_resume_download": "No se pudo reiniciar la descarga",
|
||||||
"download_completed": "Descarga completada",
|
"download_completed": "Descarga completada",
|
||||||
"download_failed": "Descarga fallida",
|
"download_failed": "Descarga fallida",
|
||||||
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} ya está descargando",
|
"item_already_downloading": "{{item}} ya está descargando",
|
||||||
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Todos los archivos, carpetas y trabajos eliminados con éxito",
|
||||||
|
"failed_to_clean_cache_directory": "Error al limpiar el directorio de caché",
|
||||||
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
||||||
|
"go_to_downloads": "Ir a descargas",
|
||||||
"file_deleted": "{{item}} eliminado"
|
"file_deleted": "{{item}} eliminado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "Nada",
|
"none": "Nada",
|
||||||
"track": "Pista",
|
"track": "Pista",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Borrar",
|
"delete": "Borrar",
|
||||||
"ok": "Aceptar",
|
"ok": "Aceptar",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
|
"next": "Siguiente",
|
||||||
"back": "Atrás",
|
"back": "Atrás",
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"verifying": "Verificando...",
|
"verifying": "Verificando...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Buscar...",
|
"search": "Buscar...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||||
"next_episode": "Siguiente episodio",
|
"next_episode": "Siguiente episodio",
|
||||||
|
"refresh_tracks": "Refrescar pistas",
|
||||||
|
"audio_tracks": "Pistas de audio:",
|
||||||
|
"playback_state": "Estado de la reproducción:",
|
||||||
|
"index": "Índice:",
|
||||||
"continue_watching": "Continuar viendo",
|
"continue_watching": "Continuar viendo",
|
||||||
"go_back": "Volver",
|
"go_back": "Volver",
|
||||||
"downloaded_file_title": "Ya tienes este archivo descargado",
|
"downloaded_file_title": "Ya tienes este archivo descargado",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Mostrar más",
|
"show_more": "Mostrar más",
|
||||||
"show_less": "Mostrar menos",
|
"show_less": "Mostrar menos",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Listas de reproducción",
|
"playlists": "Listas de reproducción",
|
||||||
"tracks": "Canciones"
|
"tracks": "Canciones"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "Todas"
|
||||||
|
},
|
||||||
"recently_added": "Recientemente añadido",
|
"recently_added": "Recientemente añadido",
|
||||||
"recently_played": "Reproducidos Recientemente",
|
"recently_played": "Reproducidos Recientemente",
|
||||||
"frequently_played": "Reproducido con frecuencia",
|
"frequently_played": "Reproducido con frecuencia",
|
||||||
|
"explore": "Explorar",
|
||||||
"top_tracks": "Canciones Populares",
|
"top_tracks": "Canciones Populares",
|
||||||
"play": "Reproducir",
|
"play": "Reproducir",
|
||||||
"shuffle": "Aleatorio",
|
"shuffle": "Aleatorio",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"continue_and_next_up": "Continue & Next Up",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
|
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
|
||||||
"suggested_movies": "Ehdotetut elokuvat",
|
"suggested_movies": "Ehdotetut elokuvat",
|
||||||
|
"suggested_episodes": "Ehdotetut jaksot",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Tervetuloa Streamyfiniin",
|
"welcome_to_streamyfin": "Tervetuloa Streamyfiniin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Ilmainen ja avoimen lähdekoodin asiakas Jellyfinille.",
|
"a_free_and_open_source_client_for_jellyfin": "Ilmainen ja avoimen lähdekoodin asiakas Jellyfinille.",
|
||||||
@@ -260,6 +261,43 @@
|
|||||||
"None": "Ei mitään",
|
"None": "Ei mitään",
|
||||||
"OnlyForced": "Vain pakotettu"
|
"OnlyForced": "Vain pakotettu"
|
||||||
},
|
},
|
||||||
|
"text_color": "Tekstin väri",
|
||||||
|
"background_color": "Taustaväri",
|
||||||
|
"outline_color": "Ääriviivan väri",
|
||||||
|
"outline_thickness": "Ääriviivan paksuus",
|
||||||
|
"background_opacity": "Taustan läpinäkyvyys",
|
||||||
|
"outline_opacity": "Ääriviivan Läpinäkyvyys",
|
||||||
|
"bold_text": "Lihavoi teksti",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Musta",
|
||||||
|
"Gray": "Harmaa",
|
||||||
|
"Silver": "Hopea",
|
||||||
|
"White": "Valkoinen",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Punainen",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Keltainen",
|
||||||
|
"Olive": "Oliivit",
|
||||||
|
"Green": "Vihreä",
|
||||||
|
"Teal": "Sinappi",
|
||||||
|
"Lime": "Limea",
|
||||||
|
"Purple": "Violetti",
|
||||||
|
"Navy": "Laiva",
|
||||||
|
"Blue": "Sininen",
|
||||||
|
"Aqua": "Vesi"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "Ei mitään",
|
||||||
|
"Thin": "Ohut",
|
||||||
|
"Normal": "Normaali",
|
||||||
|
"Thick": "Paksu"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle Color",
|
||||||
|
"subtitle_background_color": "Background Color",
|
||||||
|
"subtitle_font": "Subtitle Font",
|
||||||
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
|
"hardware_decode": "Hardware Decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
@@ -277,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC Subtitle Settings",
|
||||||
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"bold": "Bold Text",
|
||||||
|
"margin": "Bottom Margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video Player",
|
||||||
|
"video_player": "Video Player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Muut",
|
"other_title": "Muut",
|
||||||
"video_orientation": "Videon suunta",
|
"video_orientation": "Videon suunta",
|
||||||
@@ -294,7 +351,13 @@
|
|||||||
"UNKNOWN": "Tuntematon"
|
"UNKNOWN": "Tuntematon"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Turvallinen alue ohjaimissa",
|
"safe_area_in_controls": "Turvallinen alue ohjaimissa",
|
||||||
|
"video_player": "Videosoitin",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Kokeellinen + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
||||||
|
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
||||||
"hide_libraries": "Piilota kirjastot",
|
"hide_libraries": "Piilota kirjastot",
|
||||||
"select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
|
"select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
|
||||||
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
|
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
|
||||||
@@ -304,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
||||||
"disabled": "Pois Käytöstä"
|
"disabled": "Pois Käytöstä"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Lataukset"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -347,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Lue lisää Marlinista.",
|
"read_more_about_marlin": "Lue lisää Marlinista.",
|
||||||
"save_button": "Tallenna",
|
"save_button": "Tallenna",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Tallennettu"
|
"saved": "Tallennettu",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
@@ -374,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable our Watchlist integration"
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -385,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -394,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -406,7 +481,8 @@
|
|||||||
"export_logs": "Vie lokit",
|
"export_logs": "Vie lokit",
|
||||||
"click_for_more_info": "Napsauta lisätietoja varten",
|
"click_for_more_info": "Napsauta lisätietoja varten",
|
||||||
"level": "Taso",
|
"level": "Taso",
|
||||||
"no_logs_available": "Ei lokitietoja saatavilla"
|
"no_logs_available": "Ei lokitietoja saatavilla",
|
||||||
|
"delete_all_logs": "Poista kaikki lokit"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Kielet",
|
"title": "Kielet",
|
||||||
@@ -414,12 +490,15 @@
|
|||||||
"system": "Järjestelmä"
|
"system": "Järjestelmä"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Virhe tiedostojen poistamisessa"
|
"error_deleting_files": "Virhe tiedostojen poistamisessa",
|
||||||
|
"background_downloads_enabled": "Taustalataukset käytössä",
|
||||||
|
"background_downloads_disabled": "Taustalataukset pois käytöstä"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -437,12 +516,15 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Lataukset",
|
"downloads_title": "Lataukset",
|
||||||
"tvseries": "TV-sarjat",
|
"series": "TV-sarjat",
|
||||||
"movies": "Elokuvat",
|
"movies": "Elokuvat",
|
||||||
|
"queue": "Jonot",
|
||||||
"other_media": "Muu media",
|
"other_media": "Muu media",
|
||||||
|
"queue_hint": "Jonot ja lataukset menetetään sovelluksen uudelleenkäynnistyksen yhteydessä",
|
||||||
|
"no_items_in_queue": "Ei kohteita jonossa",
|
||||||
"no_downloaded_items": "Ei ladattuja kohteita",
|
"no_downloaded_items": "Ei ladattuja kohteita",
|
||||||
"delete_all_movies_button": "Poista kaikki elokuvat",
|
"delete_all_movies_button": "Poista kaikki elokuvat",
|
||||||
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
|
"delete_all_series_button": "Poista kaikki TV-sarjat",
|
||||||
"delete_all_button": "Poista kaikki",
|
"delete_all_button": "Poista kaikki",
|
||||||
"delete_all_other_media_button": "Poista muu media",
|
"delete_all_other_media_button": "Poista muu media",
|
||||||
"active_download": "Aktiivinen lataus",
|
"active_download": "Aktiivinen lataus",
|
||||||
@@ -460,12 +542,17 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
|
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
|
||||||
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
|
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
|
||||||
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
|
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
|
||||||
"deleted_all_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||||
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||||
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
||||||
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
||||||
|
"download_deleted": "Lataus Poistettu",
|
||||||
"download_cancelled": "Lataus peruutettu",
|
"download_cancelled": "Lataus peruutettu",
|
||||||
"could_not_delete_download": "Latausta Ei Voitu Poistaa",
|
"could_not_delete_download": "Latausta Ei Voitu Poistaa",
|
||||||
|
"download_paused": "Lataus Keskeytetty",
|
||||||
|
"could_not_pause_download": "Latausta Ei Voitu Keskeyttää",
|
||||||
|
"download_resumed": "Lataus Jatketaan",
|
||||||
|
"could_not_resume_download": "Latausta Ei Voitu Jatkaa.",
|
||||||
"download_completed": "Lataus valmis",
|
"download_completed": "Lataus valmis",
|
||||||
"download_failed": "Lataus epäonnistui",
|
"download_failed": "Lataus epäonnistui",
|
||||||
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
||||||
@@ -475,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} poistettu",
|
"files_deleted_by_type": "{{count}} {{type}} poistettu",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Kaikki tiedostot, kansiot ja tehtävät poistettu onnistuneesti",
|
||||||
|
"failed_to_clean_cache_directory": "Välimuistin hakemiston puhdistus epäonnistui",
|
||||||
"could_not_get_download_url_for_item": "Latauksen URL-osoitetta ei voitu ladata {{itemName}}",
|
"could_not_get_download_url_for_item": "Latauksen URL-osoitetta ei voitu ladata {{itemName}}",
|
||||||
|
"go_to_downloads": "Siirry latauksiin",
|
||||||
"file_deleted": "{{item}} poistettu"
|
"file_deleted": "{{item}} poistettu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +583,16 @@
|
|||||||
"none": "Ei mitään",
|
"none": "Ei mitään",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login"
|
"login": "Login",
|
||||||
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Haku...",
|
"search": "Haku...",
|
||||||
@@ -598,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
||||||
"message_from_server": "Viesti palvelimelta: {{message}}",
|
"message_from_server": "Viesti palvelimelta: {{message}}",
|
||||||
"next_episode": "Seuraava Jakso",
|
"next_episode": "Seuraava Jakso",
|
||||||
|
"refresh_tracks": "Päivitä Kappaleet",
|
||||||
|
"audio_tracks": "Ääni Kappaleet:",
|
||||||
|
"playback_state": "Toiston Tila:",
|
||||||
|
"index": "Indeksi:",
|
||||||
"continue_watching": "Jatka katsomista",
|
"continue_watching": "Jatka katsomista",
|
||||||
"go_back": "Siirry Takaisin",
|
"go_back": "Siirry Takaisin",
|
||||||
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
||||||
@@ -664,6 +761,7 @@
|
|||||||
"show_more": "Näytä Lisää",
|
"show_more": "Näytä Lisää",
|
||||||
"show_less": "Näytä Vähemmän",
|
"show_less": "Näytä Vähemmän",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
@@ -790,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -926,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||