mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-13 09:20:23 +01:00
Compare commits
34 Commits
renovate/s
...
fix/ui-and
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599096f883 | ||
|
|
3247bf709c | ||
|
|
2af252d639 | ||
|
|
96116e0451 | ||
|
|
938918fa06 | ||
|
|
a4b6f456f2 | ||
|
|
1636523d48 | ||
|
|
855957707a | ||
|
|
4bad8ae054 | ||
|
|
16188ac2a3 | ||
|
|
d12d62863e | ||
|
|
7eb65ba430 | ||
|
|
43d469f398 | ||
|
|
d397233991 | ||
|
|
7f020120b3 | ||
|
|
aec3444829 | ||
|
|
24f9c38098 | ||
|
|
1dd1940334 | ||
|
|
1e537bc11e | ||
|
|
b163c2abb4 | ||
|
|
0d47c8d43a | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
168bf2e54e | ||
|
|
6f0230c2ca | ||
|
|
d12beee529 | ||
|
|
02ffac167b | ||
|
|
4eb734c99f | ||
|
|
b7bae0072f | ||
|
|
1685571406 | ||
|
|
36ed7539a2 | ||
|
|
8f82ac481a | ||
|
|
a242ff69fd |
29
.gitattributes
vendored
29
.gitattributes
vendored
@@ -1 +1,28 @@
|
|||||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
||||||
|
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
||||||
|
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows-only scripts must stay CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Binary assets — never touched / never normalised
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.pdf binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jks binary
|
||||||
|
*.p12 binary
|
||||||
|
|||||||
54
.github/renovate.json
vendored
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
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
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
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
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.'
|
||||||
|
|||||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -23,7 +23,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:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
|||||||
7
.github/workflows/linting.yml
vendored
7
.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
|
||||||
@@ -97,10 +97,11 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
|
- "i18n:check"
|
||||||
|
|
||||||
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
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
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
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
|
||||||
|
|||||||
11
.gitignore
vendored
11
.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
|
||||||
|
|
||||||
@@ -21,10 +20,8 @@ web-build/
|
|||||||
# 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 +66,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/
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -651,7 +645,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label='Vertical Margin'
|
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -669,11 +663,11 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Horizontal Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Horizontal Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
options: alignXOptions,
|
options: alignXOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -683,11 +677,11 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Vertical Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,21 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const isStreamystatsEnabled = !!url;
|
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const movieRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
||||||
|
const seriesRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
||||||
|
const promotedWatchlistsLocked =
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
||||||
|
const hideWatchlistsTabLocked =
|
||||||
|
pluginSettings?.hideWatchlistsTab?.locked === true;
|
||||||
|
// The input renders the locked admin URL; enablement must follow the same
|
||||||
|
// effective value or every toggle stays disabled until local state syncs.
|
||||||
|
const effectiveUrl = isUrlLocked
|
||||||
|
? (settings?.streamyStatsServerUrl ?? "")
|
||||||
|
: url;
|
||||||
|
const isStreamystatsEnabled = !!effectiveUrl;
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -146,7 +160,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={url}
|
value={effectiveUrl}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -171,11 +185,18 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
disabledByAdmin={searchLocked}
|
||||||
>
|
>
|
||||||
|
{/* Locked controls show the live admin value and can't be toggled —
|
||||||
|
local form state would let the switch flip while the write guard
|
||||||
|
drops the change. */}
|
||||||
<Switch
|
<Switch
|
||||||
value={useForSearch}
|
value={
|
||||||
disabled={!isStreamystatsEnabled}
|
searchLocked
|
||||||
|
? settings?.searchEngine === "Streamystats"
|
||||||
|
: useForSearch
|
||||||
|
}
|
||||||
|
disabled={!isStreamystatsEnabled || searchLocked}
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -183,52 +204,62 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={movieRecsLocked}
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={movieRecs}
|
value={
|
||||||
|
movieRecsLocked
|
||||||
|
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||||
|
: movieRecs
|
||||||
|
}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={seriesRecsLocked}
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={seriesRecs}
|
value={
|
||||||
|
seriesRecsLocked
|
||||||
|
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||||
|
: seriesRecs
|
||||||
|
}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={promotedWatchlistsLocked}
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={promotedWatchlists}
|
value={
|
||||||
|
promotedWatchlistsLocked
|
||||||
|
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||||
|
: promotedWatchlists
|
||||||
|
}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
disabledByAdmin={hideWatchlistsTabLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={hideWatchlistsTab}
|
value={
|
||||||
|
hideWatchlistsTabLocked
|
||||||
|
? (settings?.hideWatchlistsTab ?? false)
|
||||||
|
: hideWatchlistsTab
|
||||||
|
}
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.no_track_playing")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Now Playing
|
{t("music.now_playing")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
{history.length > 0
|
||||||
|
? t("music.playing_from_queue")
|
||||||
|
: t("music.up_next")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1267,7 +1267,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
t("player.an_error_occurred_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
Hash Match
|
{t("player.hash_match")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as Device from "expo-device";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -84,7 +85,8 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -333,9 +335,12 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
|
// Log only the title — serializing the whole notification touches
|
||||||
|
// the deprecated dataString getter (deprecation warning) and dumps
|
||||||
|
// noisy payloads into the console.
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running",
|
"Notification received while app running:",
|
||||||
notification,
|
notification.request.content.title,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -530,6 +535,7 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -108,7 +109,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",
|
||||||
@@ -955,6 +956,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -969,7 +972,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 +1602,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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => {
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
title={t("item_card.quality")}
|
title={t("item_card.quality")}
|
||||||
data={sorted}
|
data={sorted}
|
||||||
values={selected ? [selected] : []}
|
values={selected ? [selected] : []}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => {
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
title={t("item_card.video")}
|
title={t("item_card.video")}
|
||||||
data={item.MediaSources || []}
|
data={item.MediaSources || []}
|
||||||
values={selected ? [selected] : []}
|
values={selected ? [selected] : []}
|
||||||
|
|||||||
45
components/PendingAccountSaveModal.tsx
Normal file
45
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import {
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
||||||
|
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
||||||
|
* shows here, AFTER the session is authorized — the login screen itself
|
||||||
|
* unmounts as soon as the user is set, so it can't host the modal.
|
||||||
|
*/
|
||||||
|
export const PendingAccountSaveModal: React.FC = () => {
|
||||||
|
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { saveCurrentAccount } = useJellyfin();
|
||||||
|
|
||||||
|
// A logout before answering drops the intent — it must not resurface on
|
||||||
|
// the next (possibly different) login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user && pending) setPending(null);
|
||||||
|
}, [user, pending, setPending]);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={!!pending && !!user}
|
||||||
|
username={user?.Name ?? ""}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onSave={(securityType, pinCode) => {
|
||||||
|
const serverName = pending?.serverName;
|
||||||
|
setPending(null);
|
||||||
|
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
||||||
|
(error) => console.warn("Failed to save account:", error),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +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 } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { 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";
|
||||||
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>Select Session</Text>
|
<Text style={styles.modalTitle}>
|
||||||
|
{t("home.sessions.select_session")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
No active sessions found
|
{t("home.sessions.no_active_sessions")}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
Now playing:{" "}
|
{t("home.sessions.now_playing")}{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -49,6 +50,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
return streams;
|
return streams;
|
||||||
}, [streams, streamType, noneOption]);
|
}, [streams, streamType, noneOption]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
if (isTv || (streams && streams.length === 0)) return null;
|
if (isTv || (streams && streams.length === 0)) return null;
|
||||||
|
|
||||||
@@ -58,18 +60,22 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => {
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
title={title}
|
title={title}
|
||||||
data={addNoneToSubtitles || []}
|
data={addNoneToSubtitles || []}
|
||||||
values={
|
values={
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{t("home.downloads.transcoding")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||||
|
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
const seriesId = items[0]?.SeriesId;
|
||||||
}, []);
|
return seriesId ? storage.getString(seriesId) : undefined;
|
||||||
|
}, [items[0]?.SeriesId]);
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
@@ -34,8 +35,9 @@ export const FilterButton = <T,>({
|
|||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters, isLoading } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
@@ -44,9 +46,15 @@ export const FilterButton = <T,>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* present() must be called here, inside the press handler: calling it
|
||||||
|
from an effect after a state update silently no-ops on the new
|
||||||
|
architecture and the sheet never appears. Opening immediately also
|
||||||
|
replaces the old data-loaded gate that left the button silently
|
||||||
|
dead while options were still loading (the sheet shows a loader). */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
filters?.length && setOpen(true);
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -89,6 +97,8 @@ export const FilterButton = <T,>({
|
|||||||
title={title}
|
title={title}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
|
loading={isLoading}
|
||||||
data={filters}
|
data={filters}
|
||||||
values={values}
|
values={values}
|
||||||
set={set}
|
set={set}
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
interface Props<T> extends ViewProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Modal ref the opener must use to present() the sheet from inside its
|
||||||
|
* press handler. On the new architecture with Reanimated 4, present()
|
||||||
|
* called from an effect after a state update silently no-ops — the sheet
|
||||||
|
* mounts nothing. Presenting straight from the gesture handler works.
|
||||||
|
*/
|
||||||
|
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
|
/** True while the options are loading — shows a loader inside the sheet. */
|
||||||
|
loading?: boolean;
|
||||||
values: T[];
|
values: T[];
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -66,16 +83,18 @@ const LIMIT = 100;
|
|||||||
export const FilterSheet = <T,>({
|
export const FilterSheet = <T,>({
|
||||||
values,
|
values,
|
||||||
data: _data,
|
data: _data,
|
||||||
|
loading = false,
|
||||||
open,
|
open,
|
||||||
set,
|
set,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
modalRef,
|
||||||
title,
|
title,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
disableSearch = false,
|
disableSearch = false,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = modalRef;
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
|
|||||||
const [offset, setOffset] = useState<number>(0);
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
// Filtering and re-rendering the option list on every keystroke blocks the
|
||||||
|
// JS thread on large lists (2000+ tags); the controlled input then snaps the
|
||||||
|
// native text back to a stale value (lost/reappearing letters). Deferring the
|
||||||
|
// value keeps the keystroke render cheap and runs the list update after.
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
|
||||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!search) return _data;
|
if (!deferredSearch) return _data;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||||
if (_data && searchFilter?.(_data[i], search)) {
|
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||||
results.push(_data[i]);
|
results.push(_data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.slice(0, 100);
|
return results.slice(0, 100);
|
||||||
}, [search, _data, searchFilter]);
|
}, [deferredSearch, _data, searchFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
|
|||||||
setData(newData);
|
setData(newData);
|
||||||
}, [offset, _data]);
|
}, [offset, _data]);
|
||||||
|
|
||||||
|
// Opening is imperative (see the modalRef prop); this effect only closes.
|
||||||
|
// It also never calls dismiss() on a modal that was never presented.
|
||||||
|
const wasPresentedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) bottomSheetModalRef.current?.present();
|
if (!open && wasPresentedRef.current) {
|
||||||
else bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index === -1) {
|
if (index >= 0) {
|
||||||
|
wasPresentedRef.current = true;
|
||||||
|
} else if (index === -1) {
|
||||||
|
wasPresentedRef.current = false;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
const renderData = useMemo(() => {
|
||||||
if (search.length > 0 && showSearch) return filteredData;
|
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||||
return data;
|
return data;
|
||||||
}, [search, filteredData, data]);
|
}, [deferredSearch, showSearch, filteredData, data]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoized so typing in the search input (urgent render with an unchanged
|
||||||
|
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
|
||||||
|
const renderedRows = useMemo(
|
||||||
|
() =>
|
||||||
|
renderData?.map((item, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// Match the deep-equality rule used to render the selected
|
||||||
|
// state below — option objects are recreated across renders,
|
||||||
|
// so reference checks would re-add an already selected item.
|
||||||
|
const isSelected = values.some((value) => isEqual(value, item));
|
||||||
|
if (multiple) {
|
||||||
|
if (!isSelected) set(values.concat(item));
|
||||||
|
else set(values.filter((value) => !isEqual(value, item)));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
if (!isSelected) {
|
||||||
|
set([item]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
{values.some((i) => isEqual(i, item)) ? (
|
||||||
|
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='h-1 divide-neutral-700 '
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
[renderData, values, multiple, set, setOpen, renderItemLabel],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
<Text className='font-bold text-2xl'>{title}</Text>
|
||||||
<Text className='mb-2 text-neutral-500'>
|
{loading ? (
|
||||||
{t("search.x_items", { count: _data?.length })}
|
<View className='my-8 flex items-center justify-center'>
|
||||||
</Text>
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className='mb-2 text-neutral-500'>
|
||||||
|
{t("search.x_items", { count: _data?.length })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||||
>
|
>
|
||||||
{renderData?.map((item, index) => (
|
{renderedRows}
|
||||||
<View key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (multiple) {
|
|
||||||
if (!values.includes(item)) set(values.concat(item));
|
|
||||||
else set(values.filter((v) => v !== item));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
} else {
|
|
||||||
if (!values.includes(item)) {
|
|
||||||
set([item]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
|
||||||
{values.some((i) => isEqual(i, item)) ? (
|
|
||||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='h-1 divide-neutral-700 '
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
LIVE
|
{t("player.live")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Live TV
|
{t("live_tv.title")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import type { SavedServer } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -37,14 +39,17 @@ export const Login: React.FC = () => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const {
|
const {
|
||||||
setServer,
|
setServer,
|
||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
|
stopQuickConnectPolling,
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +69,43 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state
|
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Close the code sheet as soon as the session is authorized — the native
|
||||||
|
// Alert used before had no programmatic dismiss and stayed open after login.
|
||||||
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
|
// the protection picker shows globally once the session exists (this screen
|
||||||
|
// unmounts on login, so it can't host the modal).
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
if (quickConnectCode && saveAccount) {
|
||||||
|
setPendingAccountSave({ serverName });
|
||||||
|
}
|
||||||
|
setQuickConnectCode(null);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
};
|
||||||
|
}, [stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Going back to server selection keeps this component mounted (same screen,
|
||||||
|
// different state), so the unmount cleanup above doesn't run. Without this a
|
||||||
|
// code authorized after leaving would silently log the user in later.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api?.basePath) {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
setQuickConnectCode(null);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Save account state — only the intent lives here; the protection picker is
|
||||||
|
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,55 +152,34 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
setPendingAccountSave({ serverName });
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPendingLogin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccountConfirm = async (
|
|
||||||
securityType: AccountSecurityType,
|
|
||||||
pinCode?: string,
|
|
||||||
) => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
if (pendingLogin) {
|
|
||||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
|
||||||
saveAccount: true,
|
|
||||||
securityType,
|
|
||||||
pinCode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,15 +273,7 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(
|
setQuickConnectCode(code);
|
||||||
t("login.quick_connect"),
|
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -402,7 +408,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
aria-label={t("server.server_url")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -444,14 +450,11 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
<SaveAccountModal
|
{/* Dismissing only hides the code — polling continues so the login still
|
||||||
visible={showSaveModal}
|
completes if the code is authorized from another device afterwards. */}
|
||||||
onClose={() => {
|
<QuickConnectCodeModal
|
||||||
setShowSaveModal(false);
|
code={quickConnectCode}
|
||||||
setPendingLogin(null);
|
onClose={() => setQuickConnectCode(null)}
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The Quick Connect code to display, or null when hidden. */
|
||||||
|
code: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the Quick Connect code while the app polls for authorization.
|
||||||
|
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||||
|
* session is authorized — a native alert has no programmatic dismiss and
|
||||||
|
* lingers over the app after login completes.
|
||||||
|
*/
|
||||||
|
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||||
|
// when the parent clears the code to close the sheet.
|
||||||
|
const lastCodeRef = useRef<string | null>(null);
|
||||||
|
if (code) lastCodeRef.current = code;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else if (isPresentedRef.current) {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
const value = code ?? lastCodeRef.current;
|
||||||
|
if (!value) return;
|
||||||
|
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||||
|
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||||
|
// and skip — importing the JS wrapper there would error out.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
toast.success(t("login.code_copied"));
|
||||||
|
}, [code, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||||
|
onPress={copyCode}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className='text-center font-bold text-5xl text-neutral-100'
|
||||||
|
style={{ letterSpacing: 10 }}
|
||||||
|
>
|
||||||
|
{code ?? lastCodeRef.current}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='copy-outline'
|
||||||
|
size={22}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||||
|
{t("login.tap_code_to_copy")}
|
||||||
|
</Text>
|
||||||
|
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||||
|
{t("login.quick_connect_instructions")}
|
||||||
|
</Text>
|
||||||
|
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||||
|
{t("login.got_it")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,26 +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
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
style={{
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
marginBottom: 24,
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
height: SEARCH_AREA_HEIGHT,
|
||||||
the native view span the full width and own its own insets. */}
|
}}
|
||||||
<View
|
>
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers
|
||||||
marginBottom: 24,
|
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||||
height: SEARCH_AREA_HEIGHT,
|
<TvSearchView
|
||||||
}}
|
style={{ width: "100%", height: "100%" }}
|
||||||
>
|
placeholder={t("search.search")}
|
||||||
<TvSearchView
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
style={{ width: "100%", height: "100%" }}
|
/>
|
||||||
placeholder={t("search.search")}
|
</View>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
) : (
|
||||||
/>
|
<View
|
||||||
</View>
|
style={{
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
fontSize: 28,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
placeholderTextColor='rgba(255,255,255,0.4)'
|
||||||
|
onChangeText={setSearch}
|
||||||
|
defaultValue=''
|
||||||
|
autoFocus={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Library'
|
label={t("search.library")}
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Discover'
|
label={t("search.discover")}
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -17,20 +18,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: "Left",
|
left: t("home.settings.subtitles.align.left"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
right: "Right",
|
right: t("home.settings.subtitles.align.right"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: "Top",
|
top: t("home.settings.subtitles.align.top"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
bottom: "Bottom",
|
bottom: t("home.settings.subtitles.align.bottom"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -60,16 +62,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title='MPV Subtitle Settings'
|
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
Advanced subtitle customization for MPV player
|
{t("home.settings.subtitles.mpv_settings_description")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem title='Vertical Margin'>
|
<ListItem
|
||||||
|
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
|
>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -81,7 +85,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Horizontal Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -96,11 +100,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Horizontal Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Vertical Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -115,13 +119,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Vertical Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title='Opaque Background'>
|
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -131,7 +135,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title='Background Opacity'>
|
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title={"Jellyseerr"}
|
title='Jellyseerr'
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
|
||||||
title='Marlin Search'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Streamystats'
|
title='Streamystats'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||||
|
title='Marlin Search'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the bar moving while a download is writing to disk.
|
||||||
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = () => {
|
||||||
try {
|
Alert.alert(
|
||||||
await deleteAllFiles();
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
successHapticFeedback();
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
} catch (_e) {
|
[
|
||||||
errorHapticFeedback();
|
{
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
text: t("common.cancel"),
|
||||||
}
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
successHapticFeedback();
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
} finally {
|
||||||
|
// Reflect the freed space immediately instead of waiting for
|
||||||
|
// the next poll.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -106,6 +107,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
@@ -371,7 +373,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Now Playing
|
{t("music.now_playing")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>Hash Match</Text>
|
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText='Skip Intro'
|
buttonText={t("player.skip_intro")}
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText='Skip Credits'
|
buttonText={t("player.skip_credits")}
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
Are you still watching ?
|
{t("player.still_watching")}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} 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 { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel='Toggle screen orientation'
|
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Video: {formatCodec(info.videoCodec)}
|
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{streamInfo?.audioChannels
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
{t("player.technical_info.subtitle")}{" "}
|
||||||
|
{formatCodec(streamInfo.subtitleCodec)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Bitrate:{" "}
|
{t("player.technical_info.bitrate")}{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -341,21 +344,27 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
{t("player.technical_info.buffer_seconds", {
|
||||||
|
seconds: info.cacheSeconds.toFixed(1),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
VO: {info.voDriver}
|
{t("player.technical_info.vo")} {info.voDriver}
|
||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
{t("player.technical_info.dropped_frames", {
|
||||||
|
count: info.droppedFrames,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
{!info && !playMethod && (
|
||||||
|
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: option.label,
|
label:
|
||||||
|
option.id === "default"
|
||||||
|
? t("player.aspect_ratio_original")
|
||||||
|
: option.label,
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Aspect Ratio'
|
title={t("player.aspect_ratio")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -101,7 +103,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Quality",
|
title: t("player.menu.quality"),
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -116,7 +118,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitles",
|
title: t("player.menu.subtitles"),
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -128,7 +130,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitle Scale",
|
title: t("player.menu.subtitle_scale"),
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -142,7 +144,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Audio",
|
title: t("player.menu.audio"),
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -156,7 +158,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Speed",
|
title: t("player.menu.speed"),
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -174,8 +176,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? "Hide Technical Info"
|
? t("player.menu.hide_technical_info")
|
||||||
: "Show Technical Info",
|
: t("player.menu.show_technical_info"),
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -185,6 +187,7 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
|
t,
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -217,7 +220,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Playback Options'
|
title={t("player.menu.playback_options")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -124,17 +125,23 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Stop Playback",
|
i18n.t("player.stopPlayback"),
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? `Stop playing "${videoTitleRef.current}"?`
|
? i18n.t("player.stopPlayingTitle", {
|
||||||
: "Are you sure you want to stop playback?",
|
title: videoTitleRef.current,
|
||||||
|
})
|
||||||
|
: i18n.t("player.stopPlayingConfirm"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Cancel",
|
text: i18n.t("common.cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
{
|
||||||
|
text: i18n.t("common.stop"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: onBackRef.current,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
8
eas.json
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": {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
|
||||||
|
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { NavigationContext } from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useContext, useMemo } from "react";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for expo-router's useRouter that automatically
|
* Drop-in replacement for expo-router's useRouter that automatically
|
||||||
* preserves offline state across navigation.
|
* preserves offline state across navigation and guards against duplicate
|
||||||
|
* screens from rapid taps.
|
||||||
*
|
*
|
||||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
* - For string URLs, passes through unchanged (caller handles offline param)
|
||||||
|
* - push() is a no-op while the source screen is not focused, so taps fired
|
||||||
|
* before the pushed screen has rendered (slow devices) can't stack duplicates
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* import useRouter from "@/hooks/useAppRouter";
|
* import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -19,8 +25,18 @@ export function useAppRouter() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
|
||||||
|
// Optional: undefined when used outside a navigator (root layout, providers).
|
||||||
|
// When present it reflects the focus state of the screen this hook lives in.
|
||||||
|
const navigation = useContext(NavigationContext);
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(href: Parameters<typeof router.push>[0]) => {
|
(href: Parameters<typeof router.push>[0]) => {
|
||||||
|
// Rapid-push guard: a push blurs the source screen synchronously in the
|
||||||
|
// navigation state (only the native render is slow). Any further push from
|
||||||
|
// this screen — duplicate or not — is dropped until focus returns, so taps
|
||||||
|
// fired before the pushed screen renders can't stack screens.
|
||||||
|
// No navigation context => nothing to guard (deep-link pushes from root).
|
||||||
|
if (navigation?.isFocused?.() === false) return;
|
||||||
if (typeof href === "string") {
|
if (typeof href === "string") {
|
||||||
router.push(href as any);
|
router.push(href as any);
|
||||||
} else {
|
} else {
|
||||||
@@ -36,7 +52,7 @@ export function useAppRouter() {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[router, isOffline, navigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replace = useCallback(
|
const replace = useCallback(
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"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",
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||||
|
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||||
|
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~56.0.7",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~56.0.16",
|
||||||
@@ -129,7 +132,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",
|
||||||
|
|||||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
return items.reduce((sum, item) => {
|
||||||
|
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||||
|
// as video + trickplay, the total must match.
|
||||||
|
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||||
|
// Read the live file size on disk so the total reflects actual usage and
|
||||||
|
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||||
|
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||||
|
// value if the file can't be stat'd.
|
||||||
|
if (item.videoFilePath) {
|
||||||
|
try {
|
||||||
|
const file = new File(filePathToUri(item.videoFilePath));
|
||||||
|
if (file.exists) {
|
||||||
|
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to stat downloaded file for size:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
const totalSize = calculateTotalDownloadedSize();
|
let totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
|
// Also count in-progress downloads (they write straight to their final
|
||||||
|
// path) so the growing file shows up as app usage instead of drifting
|
||||||
|
// into the generic device share until completion.
|
||||||
|
for (const process of processes) {
|
||||||
|
try {
|
||||||
|
const file = new File(
|
||||||
|
Paths.document,
|
||||||
|
`${generateFilename(process.item)}.mp4`,
|
||||||
|
);
|
||||||
|
if (file.exists) {
|
||||||
|
totalSize += file.size ?? 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not created yet — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, [processes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -40,6 +41,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 +55,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,
|
||||||
@@ -90,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
|
|||||||
export const userAtom = atom<UserDto | null>(initialUser);
|
export const userAtom = atom<UserDto | null>(initialUser);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
export const cacheVersionAtom = atom<number>(0);
|
export const cacheVersionAtom = atom<number>(0);
|
||||||
|
// Set by a login flow that wants the account saved: the protection picker
|
||||||
|
// shows AFTER the session is authorized (the login screen unmounts on
|
||||||
|
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
||||||
|
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
interface LoginOptions {
|
interface LoginOptions {
|
||||||
saveAccount?: boolean;
|
saveAccount?: boolean;
|
||||||
@@ -107,6 +115,11 @@ interface JellyfinContextValue {
|
|||||||
serverName?: string,
|
serverName?: string,
|
||||||
options?: LoginOptions,
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveCurrentAccount: (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
stopQuickConnectPolling: () => void;
|
stopQuickConnectPolling: () => void;
|
||||||
@@ -135,7 +148,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,
|
||||||
@@ -164,12 +177,52 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// --- Session-expiry handling ----------------------------------------------
|
||||||
|
// When the server revokes the token (e.g. the device/session is deleted), a
|
||||||
|
// 401 can surface from any authenticated request. Without central handling
|
||||||
|
// the dead token stays in storage, so every reload re-fires authed calls →
|
||||||
|
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
||||||
|
// state. A single response interceptor on the authenticated api clears the
|
||||||
|
// session on the first 401 so the app drops cleanly to the login screen.
|
||||||
|
const sessionExpiredRef = useRef(false);
|
||||||
|
|
||||||
|
const handleSessionExpired = useCallback(() => {
|
||||||
|
if (sessionExpiredRef.current) return; // run once per session
|
||||||
|
sessionExpiredRef.current = true;
|
||||||
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
|
setUser(null);
|
||||||
|
setApi(null);
|
||||||
|
queryClient.clear();
|
||||||
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||||
|
// Saved credentials are kept so the user can quick-login again.
|
||||||
|
}, [setUser, setApi, queryClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
||||||
|
// its own handling — a wrong-password 401 is not a session expiry.
|
||||||
|
if (!api?.accessToken) return;
|
||||||
|
sessionExpiredRef.current = false; // re-arm for this fresh session
|
||||||
|
const interceptorId = api.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
handleSessionExpired();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
api.axiosInstance.interceptors.response.eject(interceptorId);
|
||||||
|
};
|
||||||
|
}, [api, handleSessionExpired]);
|
||||||
|
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
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]);
|
||||||
|
|
||||||
@@ -306,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist the CURRENT session to secure storage — used by the post-login
|
||||||
|
// save-account modal (the protection picker shows AFTER a successful
|
||||||
|
// login, for both the password and Quick Connect flows).
|
||||||
|
const saveCurrentAccount = useCallback(
|
||||||
|
async (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => {
|
||||||
|
const token = storage.getString("token");
|
||||||
|
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
|
||||||
|
const securityType = options?.securityType || "none";
|
||||||
|
let pinHash: string | undefined;
|
||||||
|
if (securityType === "pin" && options?.pinCode) {
|
||||||
|
pinHash = await hashPIN(options.pinCode);
|
||||||
|
}
|
||||||
|
await saveAccountCredential({
|
||||||
|
serverUrl: api.basePath,
|
||||||
|
serverName: options?.serverName || "",
|
||||||
|
token,
|
||||||
|
userId: user.Id,
|
||||||
|
username: user.Name,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType,
|
||||||
|
pinHash,
|
||||||
|
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api?.basePath, user],
|
||||||
|
);
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
username,
|
username,
|
||||||
@@ -385,7 +469,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -508,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Quick login failed:", error);
|
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||||
|
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||||
|
console.warn("Quick login failed:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -619,54 +705,62 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
// Validate the token and refresh user data in the background. Do NOT
|
||||||
// fetch fresh user data in the background
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||||
setInitialLoaded(true);
|
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||||
|
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||||
|
// to render; on success we just refresh it.
|
||||||
|
getUserApi(apiInstance)
|
||||||
|
.getCurrentUser()
|
||||||
|
.then(async (response) => {
|
||||||
|
setUser(response.data);
|
||||||
|
|
||||||
try {
|
// Migrate current session to secure storage if not already saved
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
setUser(response.data);
|
const existingCredential = await getAccountCredential(
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
|
||||||
if (storedUser?.Id && storedUser?.Name) {
|
|
||||||
const existingCredential = await getAccountCredential(
|
|
||||||
serverUrl,
|
|
||||||
storedUser.Id,
|
|
||||||
);
|
|
||||||
if (!existingCredential) {
|
|
||||||
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) {
|
.catch((e) => {
|
||||||
// Background fetch failed — app already rendered with cached data
|
// Expected, handled case (offline, or a token the server rejects —
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
// the UI prompts re-login): warn, don't error. Log only
|
||||||
}
|
// status/message — never the raw error (axios errors carry the
|
||||||
} else {
|
// request config incl. the Authorization header / token).
|
||||||
setInitialLoaded(true);
|
console.warn(
|
||||||
|
"Background user validation failed:",
|
||||||
|
e?.response?.status ?? e?.message ?? "unknown error",
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -680,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName, options) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
|
saveCurrentAccount,
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
stopQuickConnectPolling,
|
||||||
|
|||||||
273
scripts/check-i18n-keys.mjs
Normal file
273
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* i18n key checker for Streamyfin.
|
||||||
|
*
|
||||||
|
* Detects:
|
||||||
|
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
|
||||||
|
* that does not exist in the source locale (translations/en.json). These are bugs —
|
||||||
|
* the app renders the raw key. Always fails CI.
|
||||||
|
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
|
||||||
|
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
|
||||||
|
* These are dead weight that also clutter every locale on Crowdin.
|
||||||
|
*
|
||||||
|
* Dynamic usage is handled conservatively:
|
||||||
|
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
|
||||||
|
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
|
||||||
|
* whitelist keys (in Streamyfin these are user-defined section
|
||||||
|
* titles, not translation keys).
|
||||||
|
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||||
|
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||||
|
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||||
|
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||||
|
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const flag = (name, def) => {
|
||||||
|
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||||
|
if (!a) return def;
|
||||||
|
const [, v] = a.split("=");
|
||||||
|
return v === undefined ? true : v;
|
||||||
|
};
|
||||||
|
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
|
||||||
|
const JSON_OUT = !!flag("json", false);
|
||||||
|
const FIX_UNUSED = !!flag("fix-unused", false);
|
||||||
|
|
||||||
|
// ---- config ----
|
||||||
|
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
localesDir: "translations",
|
||||||
|
sourceLocale: "en",
|
||||||
|
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||||
|
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
|
||||||
|
srcDirs: ["."],
|
||||||
|
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||||
|
excludeDirs: [
|
||||||
|
"node_modules",
|
||||||
|
"ios",
|
||||||
|
"android",
|
||||||
|
".expo",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"translations",
|
||||||
|
"scripts",
|
||||||
|
],
|
||||||
|
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||||
|
ignoreUnused: [],
|
||||||
|
};
|
||||||
|
const config = existsSync(CONFIG_PATH)
|
||||||
|
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||||
|
: DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
const flatten = (obj, prefix = "", out = {}) => {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||||
|
else out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globMatch = (key, pattern) => {
|
||||||
|
if (pattern.endsWith(".*"))
|
||||||
|
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||||
|
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||||
|
return key === pattern;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (dir, files = []) => {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
for (const name of entries) {
|
||||||
|
const full = join(dir, name);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
if (config.excludeDirs.includes(name)) continue;
|
||||||
|
walk(full, files);
|
||||||
|
} else if (config.srcExtensions.includes(extname(name))) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- load source keys ----
|
||||||
|
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||||
|
const sourceKeys = Object.keys(
|
||||||
|
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||||
|
);
|
||||||
|
const sourceKeySet = new Set(sourceKeys);
|
||||||
|
|
||||||
|
// ---- scan code ----
|
||||||
|
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
|
||||||
|
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
|
||||||
|
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||||
|
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||||
|
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||||
|
|
||||||
|
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||||
|
const dynamicPrefixes = new Set();
|
||||||
|
const fullyDynamic = []; // { file, line }
|
||||||
|
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||||
|
|
||||||
|
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||||
|
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||||
|
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||||
|
// `://` inside string URLs intact.
|
||||||
|
const stripComments = (src) =>
|
||||||
|
src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||||
|
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||||
|
|
||||||
|
const files = config.srcDirs.flatMap((d) =>
|
||||||
|
walk(join(ROOT, d === "." ? "" : d) || ROOT),
|
||||||
|
);
|
||||||
|
for (const file of files) {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const clean = stripComments(text);
|
||||||
|
codeBlob += `\n${clean}`;
|
||||||
|
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
|
||||||
|
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
|
||||||
|
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
|
||||||
|
for (const m of clean.matchAll(TPL_DYN_RE)) {
|
||||||
|
const prefix = m[1];
|
||||||
|
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
|
||||||
|
else {
|
||||||
|
const idx = clean.slice(0, m.index).split("\n").length;
|
||||||
|
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixList = [...dynamicPrefixes];
|
||||||
|
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in
|
||||||
|
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||||
|
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||||
|
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||||
|
const literalUsed = (key) =>
|
||||||
|
codeBlob.includes(`"${key}"`) ||
|
||||||
|
codeBlob.includes(`'${key}'`) ||
|
||||||
|
codeBlob.includes(`\`${key}\``);
|
||||||
|
const isUsed = (key) =>
|
||||||
|
literalUsed(key) ||
|
||||||
|
prefixList.some((p) => key.startsWith(p)) ||
|
||||||
|
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||||
|
|
||||||
|
// ---- compute ----
|
||||||
|
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
||||||
|
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
|
||||||
|
// affects the UNUSED calculation, never MISSING.
|
||||||
|
const missing = [...usedStatic]
|
||||||
|
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
||||||
|
.sort();
|
||||||
|
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
|
||||||
|
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
|
||||||
|
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
|
||||||
|
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
|
||||||
|
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||||
|
|
||||||
|
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||||
|
const removeKey = (obj, parts) => {
|
||||||
|
const [head, ...rest] = parts;
|
||||||
|
if (!(head in obj)) return;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
delete obj[head];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeKey(obj[head], rest);
|
||||||
|
if (
|
||||||
|
obj[head] &&
|
||||||
|
typeof obj[head] === "object" &&
|
||||||
|
Object.keys(obj[head]).length === 0
|
||||||
|
)
|
||||||
|
delete obj[head];
|
||||||
|
};
|
||||||
|
if (FIX_UNUSED && unused.length) {
|
||||||
|
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||||
|
// the keys from them automatically on the next sync once they disappear from the source.
|
||||||
|
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||||
|
for (const key of unused) removeKey(data, key.split("."));
|
||||||
|
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- report ----
|
||||||
|
if (JSON_OUT) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
sourceKeys: sourceKeys.length,
|
||||||
|
missing,
|
||||||
|
unused,
|
||||||
|
dynamicPrefixes: prefixList,
|
||||||
|
fullyDynamic,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
|
||||||
|
);
|
||||||
|
if (prefixList.length)
|
||||||
|
console.log(
|
||||||
|
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
|
||||||
|
);
|
||||||
|
if (fullyDynamic.length)
|
||||||
|
console.log(
|
||||||
|
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(
|
||||||
|
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
|
||||||
|
);
|
||||||
|
for (const k of missing) console.log(` - ${k}`);
|
||||||
|
} else console.log("\n✅ No missing keys.");
|
||||||
|
|
||||||
|
if (UNUSED_MODE !== "off") {
|
||||||
|
if (unused.length) {
|
||||||
|
console.log(
|
||||||
|
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
|
||||||
|
);
|
||||||
|
for (const k of unused) console.log(` - ${k}`);
|
||||||
|
console.log(
|
||||||
|
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||||
|
);
|
||||||
|
} else console.log("\n✅ No unused keys.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail =
|
||||||
|
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
46
scripts/i18n-keys.config.json
Normal file
46
scripts/i18n-keys.config.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"localesDir": "translations",
|
||||||
|
"sourceLocale": "en",
|
||||||
|
"srcDirs": [
|
||||||
|
"app",
|
||||||
|
"components",
|
||||||
|
"hooks",
|
||||||
|
"providers",
|
||||||
|
"utils",
|
||||||
|
"modules",
|
||||||
|
"packages",
|
||||||
|
"constants"
|
||||||
|
],
|
||||||
|
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||||
|
"excludeDirs": [
|
||||||
|
"node_modules",
|
||||||
|
"ios",
|
||||||
|
"android",
|
||||||
|
".expo",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"translations"
|
||||||
|
],
|
||||||
|
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
|
||||||
|
"ignoreUnused": [
|
||||||
|
"watchlists.add_to_watchlist",
|
||||||
|
"watchlists.remove_from_watchlist",
|
||||||
|
"watchlists.create_one_first",
|
||||||
|
"watchlists.no_compatible_watchlists",
|
||||||
|
"pin.confirm_pin",
|
||||||
|
"pin.pins_dont_match",
|
||||||
|
"player.search_subtitles",
|
||||||
|
"player.subtitle_search",
|
||||||
|
"player.subtitle_download_hint",
|
||||||
|
"player.subtitle_tracks",
|
||||||
|
"player.using_jellyfin_server",
|
||||||
|
"player.swipe_down_settings",
|
||||||
|
"player.stopPlayback",
|
||||||
|
"player.stopPlayingTitle",
|
||||||
|
"player.stopPlayingConfirm",
|
||||||
|
"home.settings.other.show_large_home_carousel",
|
||||||
|
"home.settings.logs.delete_all_logs",
|
||||||
|
"home.suggested_episodes"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "التنزيلات",
|
"downloads_title": "التنزيلات",
|
||||||
"tvseries": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"queue": "قائمة الانتظار",
|
"queue": "قائمة الانتظار",
|
||||||
"other_media": "وسائط أخرى",
|
"other_media": "وسائط أخرى",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
"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": "التنزيل الجاري",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "تم حذف التنزيل",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
"queue": "Cua",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No hi ha elements a la cua",
|
"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",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Download Deleted",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Stahování",
|
"downloads_title": "Stahování",
|
||||||
"tvseries": "Televizní série",
|
"series": "Televizní série",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"queue": "Fronta",
|
"queue": "Fronta",
|
||||||
"other_media": "Ostatní média",
|
"other_media": "Ostatní média",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Žádné položky ve frontě",
|
"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í",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Stahování smazáno",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-serier",
|
"series": "TV-serier",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"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",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Download Slettet",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "Serien",
|
"series": "Serien",
|
||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"queue": "Warteschlange",
|
"queue": "Warteschlange",
|
||||||
"other_media": "Andere Medien",
|
"other_media": "Andere Medien",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
"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",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Download gelöscht",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Λήψεις",
|
"downloads_title": "Λήψεις",
|
||||||
"tvseries": "Τηλεόραση-Σειρά",
|
"series": "Τηλεόραση-Σειρά",
|
||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
"queue": "Ουρά",
|
"queue": "Ουρά",
|
||||||
"other_media": "Άλλα μέσα",
|
"other_media": "Άλλα μέσα",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
"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": "Ενεργή Λήψη",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Η Λήψη Διαγράφηκε",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -229,14 +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": "Vico",
|
||||||
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||||
"no_items_in_queue": "Neniuj eroj en vico",
|
"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",
|
||||||
@@ -253,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",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descargas",
|
"downloads_title": "Descargas",
|
||||||
"tvseries": "Series",
|
"series": "Series",
|
||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
"queue": "Cola",
|
"queue": "Cola",
|
||||||
"other_media": "Otros medios",
|
"other_media": "Otros medios",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No hay ítems en la cola",
|
"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",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Descarga eliminada",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Lataukset",
|
"downloads_title": "Lataukset",
|
||||||
"tvseries": "TV-sarjat",
|
"series": "TV-sarjat",
|
||||||
"movies": "Elokuvat",
|
"movies": "Elokuvat",
|
||||||
"queue": "Jonot",
|
"queue": "Jonot",
|
||||||
"other_media": "Muu media",
|
"other_media": "Muu media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ei kohteita jonossa",
|
"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",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "Lataus Poistettu",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Téléchargements",
|
"downloads_title": "Téléchargements",
|
||||||
"tvseries": "Séries",
|
"series": "Séries",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "File d'attente",
|
"queue": "File d'attente",
|
||||||
"other_media": "Autres médias",
|
"other_media": "Autres médias",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
||||||
"no_downloaded_items": "Aucun média téléchargé",
|
"no_downloaded_items": "Aucun média téléchargé",
|
||||||
"delete_all_movies_button": "Supprimer tous les films",
|
"delete_all_movies_button": "Supprimer tous les films",
|
||||||
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
"delete_all_series_button": "Supprimer toutes les séries",
|
||||||
"delete_all_button": "Supprimer tous les médias",
|
"delete_all_button": "Supprimer tous les médias",
|
||||||
"delete_all_other_media_button": "Supprimer un autre média",
|
"delete_all_other_media_button": "Supprimer un autre média",
|
||||||
"active_download": "Téléchargement actif",
|
"active_download": "Téléchargement actif",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
||||||
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
||||||
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
||||||
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès !",
|
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||||
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
|
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||||
"download_deleted": "Téléchargement supprimé",
|
"download_deleted": "Téléchargement supprimé",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "הורדות",
|
"downloads_title": "הורדות",
|
||||||
"tvseries": "סדרות",
|
"series": "סדרות",
|
||||||
"movies": "סרטים",
|
"movies": "סרטים",
|
||||||
"queue": "תוֹר",
|
"queue": "תוֹר",
|
||||||
"other_media": "תוכן אחר",
|
"other_media": "תוכן אחר",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "אין פרטים בתור",
|
"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": "הורדה פעילה",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "ההורדה נמחקה",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Letöltések",
|
"downloads_title": "Letöltések",
|
||||||
"tvseries": "Sorozatok",
|
"series": "Sorozatok",
|
||||||
"movies": "Filmek",
|
"movies": "Filmek",
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Nincs Elem a Sorban",
|
"no_items_in_queue": "Nincs Elem a Sorban",
|
||||||
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
||||||
"delete_all_movies_button": "Összes Film Törlése",
|
"delete_all_movies_button": "Összes Film Törlése",
|
||||||
"delete_all_tvseries_button": "Összes Sorozat Törlése",
|
"delete_all_series_button": "Összes Sorozat Törlése",
|
||||||
"delete_all_button": "Összes Törlése",
|
"delete_all_button": "Összes Törlése",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Aktív Letöltés",
|
"active_download": "Aktív Letöltés",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
|
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
|
||||||
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
|
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
|
||||||
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
|
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
|
||||||
"deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
"deleted_all_series_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
||||||
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
|
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Letöltés Törölve",
|
"download_deleted": "Letöltés Törölve",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Scaricati",
|
"downloads_title": "Scaricati",
|
||||||
"tvseries": "Serie TV",
|
"series": "Serie TV",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Coda",
|
"queue": "Coda",
|
||||||
"other_media": "Altri supporti",
|
"other_media": "Altri supporti",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Nessun elemento in coda",
|
"no_items_in_queue": "Nessun elemento in coda",
|
||||||
"no_downloaded_items": "Nessun elemento scaricato",
|
"no_downloaded_items": "Nessun elemento scaricato",
|
||||||
"delete_all_movies_button": "Cancella tutti i film",
|
"delete_all_movies_button": "Cancella tutti i film",
|
||||||
"delete_all_tvseries_button": "Cancella tutte le serie TV",
|
"delete_all_series_button": "Cancella tutte le serie TV",
|
||||||
"delete_all_button": "Cancella tutti",
|
"delete_all_button": "Cancella tutti",
|
||||||
"delete_all_other_media_button": "Elimina altri supporti",
|
"delete_all_other_media_button": "Elimina altri supporti",
|
||||||
"active_download": "Scaricamento in corso",
|
"active_download": "Scaricamento in corso",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
|
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
|
||||||
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
|
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
|
||||||
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
|
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
|
||||||
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
|
"deleted_all_series_successfully": "Eliminate tutte le serie TV con successo!",
|
||||||
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
|
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
|
||||||
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
||||||
"failed_to_delete_media": "Impossibile eliminare altri media",
|
"failed_to_delete_media": "Impossibile eliminare altri media",
|
||||||
"download_deleted": "Download Eliminato",
|
"download_deleted": "Download Eliminato",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "ダウンロード",
|
"downloads_title": "ダウンロード",
|
||||||
"tvseries": "TVシリーズ",
|
"series": "TVシリーズ",
|
||||||
"movies": "映画",
|
"movies": "映画",
|
||||||
"queue": "キュー",
|
"queue": "キュー",
|
||||||
"other_media": "その他のメディア",
|
"other_media": "その他のメディア",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "キューにアイテムがありません",
|
"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": "アクティブなダウンロード",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"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_deleted": "ダウンロードが削除されました",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-Series",
|
"series": "TV-Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No Items in Queue",
|
"no_items_in_queue": "No Items in Queue",
|
||||||
"no_downloaded_items": "No Downloaded Items",
|
"no_downloaded_items": "No Downloaded Items",
|
||||||
"delete_all_movies_button": "Delete All Movies",
|
"delete_all_movies_button": "Delete All Movies",
|
||||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
"delete_all_series_button": "Delete All TV-Series",
|
||||||
"delete_all_button": "Delete All",
|
"delete_all_button": "Delete All",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Active Download",
|
"active_download": "Active Download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||||
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
||||||
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
||||||
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
|
||||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Download Deleted",
|
"download_deleted": "Download Deleted",
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastinger",
|
"downloads_title": "Nedlastinger",
|
||||||
"tvseries": "TV-serier",
|
"series": "TV-serier",
|
||||||
"movies": "Filmer",
|
"movies": "Filmer",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
|
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
|
||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||||
"delete_all_movies_button": "Slett alle filmer",
|
"delete_all_movies_button": "Slett alle filmer",
|
||||||
"delete_all_tvseries_button": "Slett alle TV-serier",
|
"delete_all_series_button": "Slett alle TV-serier",
|
||||||
"delete_all_button": "Slett alt",
|
"delete_all_button": "Slett alt",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
"no_active_downloads": "Ingen aktive nedlastinger",
|
"no_active_downloads": "Ingen aktive nedlastinger",
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
|
||||||
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
|
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
||||||
"deleted_all_tvseries_successfully": "Alle TV-serier ble slettet!",
|
"deleted_all_series_successfully": "Alle TV-serier ble slettet!",
|
||||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
||||||
"download_cancelled": "Nedlasting avbrutt",
|
"download_cancelled": "Nedlasting avbrutt",
|
||||||
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
|
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
|
||||||
"download_completed": "Nedlasting fullført",
|
"download_completed": "Nedlasting fullført",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "Series",
|
"series": "Series",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "Wachtrij",
|
"queue": "Wachtrij",
|
||||||
"other_media": "Andere media",
|
"other_media": "Andere media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Geen items in wachtrij",
|
"no_items_in_queue": "Geen items in wachtrij",
|
||||||
"no_downloaded_items": "Geen gedownloade items",
|
"no_downloaded_items": "Geen gedownloade items",
|
||||||
"delete_all_movies_button": "Verwijder alle films",
|
"delete_all_movies_button": "Verwijder alle films",
|
||||||
"delete_all_tvseries_button": "Verwijder alle Series",
|
"delete_all_series_button": "Verwijder alle Series",
|
||||||
"delete_all_button": "Verwijder alles",
|
"delete_all_button": "Verwijder alles",
|
||||||
"delete_all_other_media_button": "Andere media verwijderen",
|
"delete_all_other_media_button": "Andere media verwijderen",
|
||||||
"active_download": "Actieve download",
|
"active_download": "Actieve download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
||||||
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
|
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
|
||||||
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
||||||
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
|
"deleted_all_series_successfully": "Alle series succesvol verwijderd!",
|
||||||
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
|
||||||
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
||||||
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
||||||
"download_deleted": "Download verwijderd",
|
"download_deleted": "Download verwijderd",
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastingar",
|
"downloads_title": "Nedlastingar",
|
||||||
"tvseries": "TV-seriar",
|
"series": "TV-seriar",
|
||||||
"movies": "Filmar",
|
"movies": "Filmar",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
|
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
|
||||||
"no_items_in_queue": "Ingen element i køen",
|
"no_items_in_queue": "Ingen element i køen",
|
||||||
"no_downloaded_items": "Ingen nedlasta element",
|
"no_downloaded_items": "Ingen nedlasta element",
|
||||||
"delete_all_movies_button": "Slett alle filmar",
|
"delete_all_movies_button": "Slett alle filmar",
|
||||||
"delete_all_tvseries_button": "Slett alle TV-seriar",
|
"delete_all_series_button": "Slett alle TV-seriar",
|
||||||
"delete_all_button": "Slett alt",
|
"delete_all_button": "Slett alt",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
"no_active_downloads": "Ingen aktive nedlastingar",
|
"no_active_downloads": "Ingen aktive nedlastingar",
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
|
||||||
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
|
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
|
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
|
||||||
"deleted_all_tvseries_successfully": "Alle TV-seriar vart sletta!",
|
"deleted_all_series_successfully": "Alle TV-seriar vart sletta!",
|
||||||
"failed_to_delete_all_tvseries": "Kunne ikkje sletta alle TV-seriar",
|
"failed_to_delete_all_series": "Kunne ikkje sletta alle TV-seriar",
|
||||||
"download_cancelled": "Nedlasting avbroten",
|
"download_cancelled": "Nedlasting avbroten",
|
||||||
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
|
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
|
||||||
"download_completed": "Nedlasting fullført",
|
"download_completed": "Nedlasting fullført",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastinger",
|
"downloads_title": "Nedlastinger",
|
||||||
"tvseries": "TV-Serier",
|
"series": "TV-Serier",
|
||||||
"movies": "Filmer",
|
"movies": "Filmer",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||||
"delete_all_movies_button": "Slett alle filmer",
|
"delete_all_movies_button": "Slett alle filmer",
|
||||||
"delete_all_tvseries_button": "Slett alle TV-Serier",
|
"delete_all_series_button": "Slett alle TV-Serier",
|
||||||
"delete_all_button": "Slett alle",
|
"delete_all_button": "Slett alle",
|
||||||
"delete_all_other_media_button": "Slett andre media",
|
"delete_all_other_media_button": "Slett andre media",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
|
||||||
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
|
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
||||||
"deleted_all_tvseries_successfully": "Alle TV-Serier ble slettet!",
|
"deleted_all_series_successfully": "Alle TV-Serier ble slettet!",
|
||||||
"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": "Slettet andre media vellykket!",
|
"deleted_media_successfully": "Slettet andre media vellykket!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
"download_deleted": "Nedlasting slettet",
|
"download_deleted": "Nedlasting slettet",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Pobrane",
|
"downloads_title": "Pobrane",
|
||||||
"tvseries": "Seriale",
|
"series": "Seriale",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"queue": "Kolejka",
|
"queue": "Kolejka",
|
||||||
"other_media": "Inne media",
|
"other_media": "Inne media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Brak elementów w kolejce",
|
"no_items_in_queue": "Brak elementów w kolejce",
|
||||||
"no_downloaded_items": "Brak pobranych elementów",
|
"no_downloaded_items": "Brak pobranych elementów",
|
||||||
"delete_all_movies_button": "Usuń wszystkie filmy",
|
"delete_all_movies_button": "Usuń wszystkie filmy",
|
||||||
"delete_all_tvseries_button": "Usuń wszystkie seriale",
|
"delete_all_series_button": "Usuń wszystkie seriale",
|
||||||
"delete_all_button": "Usuń wszystko",
|
"delete_all_button": "Usuń wszystko",
|
||||||
"delete_all_other_media_button": "Usuń inne media",
|
"delete_all_other_media_button": "Usuń inne media",
|
||||||
"active_download": "Aktywne pobieranie",
|
"active_download": "Aktywne pobieranie",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Nie masz uprawnień do pobierania plików.",
|
"you_are_not_allowed_to_download_files": "Nie masz uprawnień do pobierania plików.",
|
||||||
"deleted_all_movies_successfully": "Wszystkie filmy zostały pomyślnie usunięte!",
|
"deleted_all_movies_successfully": "Wszystkie filmy zostały pomyślnie usunięte!",
|
||||||
"failed_to_delete_all_movies": "Nie udało się usunąć wszystkich filmów",
|
"failed_to_delete_all_movies": "Nie udało się usunąć wszystkich filmów",
|
||||||
"deleted_all_tvseries_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
|
"deleted_all_series_successfully": "Wszystkie seriale zostały pomyślnie usunięte!",
|
||||||
"failed_to_delete_all_tvseries": "Nie udało się usunąć wszystkich seriali",
|
"failed_to_delete_all_series": "Nie udało się usunąć wszystkich seriali",
|
||||||
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
|
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
|
||||||
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
|
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
|
||||||
"download_deleted": "Pobieranie usunięte",
|
"download_deleted": "Pobieranie usunięte",
|
||||||
|
|||||||
@@ -227,14 +227,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV/Séries",
|
"series": "TV/Séries",
|
||||||
"movies": "Filmes",
|
"movies": "Filmes",
|
||||||
"queue": "Fila",
|
"queue": "Fila",
|
||||||
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
|
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
|
||||||
"no_items_in_queue": "Nenhum item na fila",
|
"no_items_in_queue": "Nenhum item na fila",
|
||||||
"no_downloaded_items": "Nenhum item baixado",
|
"no_downloaded_items": "Nenhum item baixado",
|
||||||
"delete_all_movies_button": "Remover todos os filmes",
|
"delete_all_movies_button": "Remover todos os filmes",
|
||||||
"delete_all_tvseries_button": "Remover todos as TV/Séries",
|
"delete_all_series_button": "Remover todos as TV/Séries",
|
||||||
"delete_all_button": "Remover tudo",
|
"delete_all_button": "Remover tudo",
|
||||||
"active_download": "Downloads ativos",
|
"active_download": "Downloads ativos",
|
||||||
"no_active_downloads": "Nenhum download ativo",
|
"no_active_downloads": "Nenhum download ativo",
|
||||||
@@ -251,8 +251,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
|
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
|
||||||
"deleted_all_movies_successfully": "Todos os filmes foram removidos com sucesso!",
|
"deleted_all_movies_successfully": "Todos os filmes foram removidos com sucesso!",
|
||||||
"failed_to_delete_all_movies": "Falha ao remover todos os filmes",
|
"failed_to_delete_all_movies": "Falha ao remover todos os filmes",
|
||||||
"deleted_all_tvseries_successfully": "Todos as TV/Séries foram removidos com sucesso!",
|
"deleted_all_series_successfully": "Todos as TV/Séries foram removidos com sucesso!",
|
||||||
"failed_to_delete_all_tvseries": "Falha ao remover todos as TV/Séries",
|
"failed_to_delete_all_series": "Falha ao remover todos as TV/Séries",
|
||||||
"download_cancelled": "Download cancelado",
|
"download_cancelled": "Download cancelado",
|
||||||
"could_not_cancel_download": "Não foi possível cancelar o download",
|
"could_not_cancel_download": "Não foi possível cancelar o download",
|
||||||
"download_completed": "Download completo",
|
"download_completed": "Download completo",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-Séries",
|
"series": "TV-Séries",
|
||||||
"movies": "Filmes",
|
"movies": "Filmes",
|
||||||
"queue": "Fila",
|
"queue": "Fila",
|
||||||
"other_media": "Outras mídias",
|
"other_media": "Outras mídias",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Nenhum item na fila",
|
"no_items_in_queue": "Nenhum item na fila",
|
||||||
"no_downloaded_items": "Nenhum item baixado",
|
"no_downloaded_items": "Nenhum item baixado",
|
||||||
"delete_all_movies_button": "Excluir todos os filmes",
|
"delete_all_movies_button": "Excluir todos os filmes",
|
||||||
"delete_all_tvseries_button": "Excluir todas as séries",
|
"delete_all_series_button": "Excluir todas as séries",
|
||||||
"delete_all_button": "Excluir todos os",
|
"delete_all_button": "Excluir todos os",
|
||||||
"delete_all_other_media_button": "Excluir outras mídias",
|
"delete_all_other_media_button": "Excluir outras mídias",
|
||||||
"active_download": "Download ativo",
|
"active_download": "Download ativo",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
|
"you_are_not_allowed_to_download_files": "Você não tem permissão para baixar arquivos.",
|
||||||
"deleted_all_movies_successfully": "Todos os filmes excluídos com sucesso!",
|
"deleted_all_movies_successfully": "Todos os filmes excluídos com sucesso!",
|
||||||
"failed_to_delete_all_movies": "Falha ao excluir todos os filmes",
|
"failed_to_delete_all_movies": "Falha ao excluir todos os filmes",
|
||||||
"deleted_all_tvseries_successfully": "Todas as TV-Series excluídas com sucesso!",
|
"deleted_all_series_successfully": "Todas as TV-Series excluídas com sucesso!",
|
||||||
"failed_to_delete_all_tvseries": "Falha ao excluir todas as séries",
|
"failed_to_delete_all_series": "Falha ao excluir todas as séries",
|
||||||
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
|
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
|
||||||
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
||||||
"download_deleted": "Download Excluído",
|
"download_deleted": "Download Excluído",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user