mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-15 02:10:23 +01:00
Compare commits
9 Commits
ci/free-di
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e6f6311e | ||
|
|
2597b4af49 | ||
|
|
2ad9753957 | ||
|
|
c2c6bf0b45 | ||
|
|
7f68506ceb | ||
|
|
ac41fa7863 | ||
|
|
cd5300e4ba | ||
|
|
326956dfda | ||
|
|
7528274249 |
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,13 +75,10 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
description: What version of Streamyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.54.1
|
||||||
- 0.51.0
|
- 0.51.0
|
||||||
- 0.47.1
|
|
||||||
- 0.30.2
|
|
||||||
- 0.28.0
|
|
||||||
- Older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
54
.github/renovate.json
vendored
54
.github/renovate.json
vendored
@@ -44,42 +44,22 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vulnerabilityAlerts": {
|
"lockFileMaintenance": {
|
||||||
"enabled": true,
|
"vulnerabilityAlerts": {
|
||||||
"addLabels": ["security", "vulnerability"],
|
"enabled": true,
|
||||||
"assigneesFromCodeOwners": true,
|
"addLabels": ["security", "vulnerability"],
|
||||||
"commitMessageSuffix": " [SECURITY]"
|
"assigneesFromCodeOwners": true,
|
||||||
},
|
"commitMessageSuffix": " [SECURITY]"
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
|
||||||
"matchPackageNames": [
|
|
||||||
"expo",
|
|
||||||
"react",
|
|
||||||
"react-dom",
|
|
||||||
"react-native",
|
|
||||||
"react-native-web",
|
|
||||||
"expo-*",
|
|
||||||
"@expo/*"
|
|
||||||
],
|
|
||||||
"groupName": "Expo SDK",
|
|
||||||
"dependencyDashboardApproval": true
|
|
||||||
},
|
},
|
||||||
{
|
"packageRules": [
|
||||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
{
|
||||||
"matchManagers": ["github-actions"],
|
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||||
"groupName": "CI dependencies",
|
"matchManagers": ["github-actions"],
|
||||||
"groupSlug": "ci-deps",
|
"groupName": "CI dependencies",
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
"groupSlug": "ci-deps",
|
||||||
"automerge": true
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
},
|
"automerge": true
|
||||||
{
|
}
|
||||||
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
]
|
||||||
"matchDatasources": ["maven"],
|
}
|
||||||
"registryUrls": [
|
|
||||||
"https://dl.google.com/dl/android/maven2/",
|
|
||||||
"https://repo.maven.apache.org/maven2/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
43
.github/workflows/build-apps.yml
vendored
43
.github/workflows/build-apps.yml
vendored
@@ -11,15 +11,6 @@ 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]'))
|
||||||
@@ -37,12 +28,12 @@ jobs:
|
|||||||
android: false
|
android: false
|
||||||
dotnet: true
|
dotnet: true
|
||||||
haskell: true
|
haskell: true
|
||||||
large-packages: false
|
large-packages: true
|
||||||
docker-images: true
|
docker-images: true
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -120,12 +111,12 @@ jobs:
|
|||||||
android: false
|
android: false
|
||||||
dotnet: true
|
dotnet: true
|
||||||
haskell: true
|
haskell: true
|
||||||
large-packages: false
|
large-packages: true
|
||||||
docker-images: true
|
docker-images: true
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -196,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -228,10 +219,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.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -240,9 +231,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
run: eas build -p ios --local --non-interactive
|
||||||
# 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
|
||||||
@@ -263,7 +252,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -295,7 +284,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.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -323,7 +312,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -355,10 +344,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.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -367,7 +356,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 --profile ci_tv
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- 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
|
||||||
@@ -391,7 +380,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
@@ -423,7 +412,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.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|||||||
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@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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,11 +97,10 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
- "i18n:check"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
4
.github/workflows/trivy-scan.yml
vendored
4
.github/workflows/trivy-scan.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
security-events: write # upload SARIF to code scanning
|
security-events: write # upload SARIF to code scanning
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
# 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.
|
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
output: trivy-results.sarif
|
output: trivy-results.sarif
|
||||||
|
|
||||||
- name: 📤 Upload results to code scanning
|
- name: 📤 Upload results to code scanning
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-fs
|
category: trivy-fs
|
||||||
|
|||||||
123
.github/workflows/update-issue-form.yml
vendored
123
.github/workflows/update-issue-form.yml
vendored
@@ -1,102 +1,67 @@
|
|||||||
name: 🐛 Update Issue Form Versions
|
name: 🐛 Update Bug Report Template
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
types: [published] # Run on every published release on any branch
|
||||||
types: [released]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Fixed group so a release event and the weekly cron can't race on the same
|
|
||||||
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-issue-form
|
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-issue-form:
|
update-bug-report:
|
||||||
name: 🔢 Populate version dropdown
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
node-version: '24.x'
|
||||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
cache: 'npm'
|
||||||
# PR would revert any form edits made on develop since that release.
|
|
||||||
ref: develop
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🔍 Extract minor version from app.json
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
id: minor
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
result-encoding: string
|
||||||
|
script: |
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const semver = require('semver');
|
||||||
|
const content = fs.readJsonSync('./app.json');
|
||||||
|
const version = content.expo.version;
|
||||||
|
const minorVersion = semver.minor(version);
|
||||||
|
return minorVersion.toString();
|
||||||
|
|
||||||
- name: 🔢 Populate version dropdown from GitHub releases
|
- name: 📝 Update bug report version
|
||||||
id: populate
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
run: bun scripts/update-issue-form.mjs
|
with:
|
||||||
env:
|
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||||
GH_TOKEN: ${{ github.token }}
|
dry_run: no-push
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: 📬 Create pull request
|
- name: ⚙️ Update bug report node version dropdown
|
||||||
id: cpr
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
|
with:
|
||||||
|
dropdown: _node_version
|
||||||
|
package: node
|
||||||
|
semver: '>=24.0.0'
|
||||||
|
dry_run: no-push
|
||||||
|
|
||||||
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci/update-issue-form
|
branch: ci-update-bug-report
|
||||||
base: develop
|
base: develop
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
labels: ⚙️ ci, 🤖 github-actions
|
labels: ⚙️ ci, 🤖 github-actions
|
||||||
commit-message: "chore: update issue form version dropdown"
|
title: 'chore(): Update bug report template to match release version'
|
||||||
title: "chore: update issue form version dropdown"
|
|
||||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
|
||||||
body: |
|
body: |
|
||||||
# 📦 Pull Request
|
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||||
|
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
## 📝 Description
|
|
||||||
|
|
||||||
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
|
||||||
|
|
||||||
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
|
||||||
|
|
||||||
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
|
||||||
|
|
||||||
N/A — automated maintenance.
|
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
|
||||||
|
|
||||||
N/A — issue-template metadata only, no app UI.
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
|
||||||
- [x] Verified that changes behave as expected for all platforms
|
|
||||||
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
|
||||||
- [x] No secrets, hardcoded credentials, or private config files are included
|
|
||||||
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
|
||||||
|
|
||||||
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
|
||||||
|
|
||||||
- name: 🔀 Enable auto-merge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
|
||||||
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
|
||||||
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
|
||||||
# Auto-merge is still worth enabling: once checks run and reviews land,
|
|
||||||
# the PR merges itself.
|
|
||||||
run: |
|
|
||||||
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
|
||||||
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -1,47 +1,3 @@
|
|||||||
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");
|
||||||
@@ -66,8 +22,6 @@ 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,
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
||||||
|
import { TVNavBar } from "@/components/tv/TVNavBar";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
isTabRoute,
|
||||||
|
useTVHomeBackHandler,
|
||||||
|
useTVTabRootBackHandler,
|
||||||
|
} from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -33,13 +41,107 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
|
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
||||||
|
|
||||||
|
function TVTabLayout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const currentTab = segments.find(isTabRoute);
|
||||||
|
const atTabRoot = isTabRoute(segments[segments.length - 1] ?? "");
|
||||||
|
|
||||||
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "(home)", label: t("tabs.home") },
|
||||||
|
{ key: "(search)", label: t("tabs.search") },
|
||||||
|
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||||
|
? null
|
||||||
|
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||||
|
{ key: "(libraries)", label: t("tabs.library") },
|
||||||
|
!settings?.showCustomMenuLinks
|
||||||
|
? null
|
||||||
|
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||||
|
{ key: "(settings)", label: t("tabs.settings") },
|
||||||
|
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||||
|
[
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
settings?.hideWatchlistsTab,
|
||||||
|
settings?.showCustomMenuLinks,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTabKey = currentTab ?? "(home)";
|
||||||
|
|
||||||
|
const visibleKeys = useMemo(
|
||||||
|
() => new Set(tabs.map((tab) => tab.key)),
|
||||||
|
[tabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === currentTab) return;
|
||||||
|
|
||||||
|
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||||
|
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||||
|
|
||||||
|
router.replace(`/(auth)/(tabs)/${key}`);
|
||||||
|
},
|
||||||
|
[currentTab, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateHome = useCallback(() => {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}, [router]);
|
||||||
|
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||||
|
|
||||||
|
// If current tab is no longer visible (setting changed), navigate to home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||||
|
router.replace("/(auth)/(tabs)/(home)");
|
||||||
|
}
|
||||||
|
}, [visibleKeys, activeTabKey, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<SystemBars hidden={false} style='light' />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{ headerShown: false, animation: "none" }}
|
||||||
|
initialRouteName='(home)'
|
||||||
|
>
|
||||||
|
<Stack.Screen name='index' redirect />
|
||||||
|
</Stack>
|
||||||
|
<TVNavBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabKey={activeTabKey}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Handle TV back button - prevent app exit when at root
|
// Must be called before any conditional return (rules of hooks)
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
|
if (IS_ANDROID_TV) {
|
||||||
|
return <TVTabLayout />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this.replaceAll("_", " ").replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
6
bun.lock
6
bun.lock
@@ -108,7 +108,7 @@
|
|||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.7",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
@@ -969,7 +969,7 @@
|
|||||||
|
|
||||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||||
|
|
||||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
|
||||||
|
|
||||||
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
||||||
|
|
||||||
@@ -1599,7 +1599,7 @@
|
|||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
"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-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=="],
|
||||||
|
|
||||||
|
|||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -69,23 +69,17 @@ 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 if (isPresentedRef.current) {
|
} else {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source, streamType],
|
[source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -379,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
const tvosTopPadding = scaleSize(145);
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[api, library],
|
[library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
@@ -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_button")}
|
label={t("login.login")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
|
</Text>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Graduated build identifier — see utils/version.ts:
|
const version =
|
||||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
Application?.nativeApplicationVersion ||
|
||||||
const { display: version } = getVersionInfo();
|
Application?.nativeBuildVersion ||
|
||||||
|
"N/A";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
|
|||||||
148
components/tv/TVNavBar.tsx
Normal file
148
components/tv/TVNavBar.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPadding } from "@/constants/TVSizes";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVNavBarTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVNavBarProps {
|
||||||
|
tabs: TVNavBarTab[];
|
||||||
|
activeTabKey: string;
|
||||||
|
onTabChange: (key: string) => void;
|
||||||
|
style?: ViewStyleProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVNavBarTabItem: React.FC<{
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onLayout: (e: {
|
||||||
|
nativeEvent: { layout: { x: number; width: number } };
|
||||||
|
}) => void;
|
||||||
|
hasTVPreferredFocus: boolean;
|
||||||
|
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bg = focused
|
||||||
|
? "rgba(255, 255, 255, 0.95)"
|
||||||
|
: isActive
|
||||||
|
? "rgba(255, 255, 255, 0.15)"
|
||||||
|
: "transparent";
|
||||||
|
|
||||||
|
const textColor = focused
|
||||||
|
? "#000"
|
||||||
|
: isActive
|
||||||
|
? "#fff"
|
||||||
|
: "rgba(255, 255, 255, 0.7)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
onLayout={onLayout}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: scaleSize(24),
|
||||||
|
borderWidth: isActive && !focused ? 1 : 0,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
paddingHorizontal: scaleSize(28),
|
||||||
|
paddingVertical: scaleSize(14),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: isActive || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
||||||
|
tabs,
|
||||||
|
activeTabKey,
|
||||||
|
onTabChange,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const scrollRef = React.useRef<ScrollView>(null);
|
||||||
|
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleTabLayout = React.useCallback(
|
||||||
|
(key: string) =>
|
||||||
|
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
|
||||||
|
tabLayouts.current[key] = e.nativeEvent.layout;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = React.useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
onTabChange(key);
|
||||||
|
|
||||||
|
const layout = tabLayouts.current[key];
|
||||||
|
if (layout && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: scaleSize(12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TVNavBarTabItem
|
||||||
|
key={tab.key}
|
||||||
|
label={tab.label}
|
||||||
|
isActive={tab.key === activeTabKey}
|
||||||
|
onSelect={() => handleTabChange(tab.key)}
|
||||||
|
onLayout={handleTabLayout(tab.key)}
|
||||||
|
hasTVPreferredFocus={tab.key === activeTabKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
|
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||||
|
export { TVNavBar } from "./TVNavBar";
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "English", value: "eng" },
|
||||||
|
{ label: "Spanish", value: "spa" },
|
||||||
|
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||||
|
{ label: "Hindi", value: "hin" },
|
||||||
|
{ label: "Arabic", value: "ara" },
|
||||||
|
{ label: "French", value: "fra" },
|
||||||
|
{ label: "Russian", value: "rus" },
|
||||||
|
{ label: "Portuguese", value: "por" },
|
||||||
|
{ label: "Japanese", value: "jpn" },
|
||||||
|
{ label: "German", value: "deu" },
|
||||||
|
{ label: "Italian", value: "ita" },
|
||||||
|
{ label: "Korean", value: "kor" },
|
||||||
|
{ label: "Turkish", value: "tur" },
|
||||||
|
{ label: "Dutch", value: "nld" },
|
||||||
|
{ label: "Polish", value: "pol" },
|
||||||
|
{ label: "Vietnamese", value: "vie" },
|
||||||
|
{ label: "Thai", value: "tha" },
|
||||||
|
{ label: "Indonesian", value: "ind" },
|
||||||
|
{ label: "Greek", value: "ell" },
|
||||||
|
{ label: "Swedish", value: "swe" },
|
||||||
|
{ label: "Danish", value: "dan" },
|
||||||
|
{ label: "Norwegian", value: "nor" },
|
||||||
|
{ label: "Finnish", value: "fin" },
|
||||||
|
{ label: "Czech", value: "ces" },
|
||||||
|
{ label: "Hungarian", value: "hun" },
|
||||||
|
{ label: "Romanian", value: "ron" },
|
||||||
|
{ label: "Ukrainian", value: "ukr" },
|
||||||
|
{ label: "Hebrew", value: "heb" },
|
||||||
|
{ label: "Bengali", value: "ben" },
|
||||||
|
{ label: "Punjabi", value: "pan" },
|
||||||
|
{ label: "Tagalog", value: "tgl" },
|
||||||
|
{ label: "Swahili", value: "swa" },
|
||||||
|
{ label: "Malay", value: "msa" },
|
||||||
|
{ label: "Persian", value: "fas" },
|
||||||
|
{ label: "Urdu", value: "urd" },
|
||||||
|
];
|
||||||
8
eas.json
8
eas.json
@@ -97,14 +97,6 @@
|
|||||||
"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": {
|
||||||
|
|||||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!item.Id) {
|
||||||
|
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||||
|
console.error("Attempted to open a file without an ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOfflineSettings, setPlayUrl, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { openFile };
|
||||||
|
};
|
||||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
|
import {
|
||||||
|
adjustToNearBlack,
|
||||||
|
calculateTextColor,
|
||||||
|
isCloseToBlack,
|
||||||
|
itemThemeColorAtom,
|
||||||
|
} from "@/utils/atoms/primaryColor";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
|
*
|
||||||
|
* @param item - The BaseItemDto object representing the item.
|
||||||
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useImageColors = ({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api) return;
|
||||||
|
if (url) return { uri: url };
|
||||||
|
if (item)
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [api, item, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv) return;
|
||||||
|
if (disabled) return;
|
||||||
|
if (source?.uri) {
|
||||||
|
const _primary = storage.getString(`${source.uri}-primary`);
|
||||||
|
const _text = storage.getString(`${source.uri}-text`);
|
||||||
|
|
||||||
|
if (_primary && _text) {
|
||||||
|
setPrimaryColor({
|
||||||
|
primary: _primary,
|
||||||
|
text: _text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from the image
|
||||||
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: false,
|
||||||
|
})
|
||||||
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
|
let primary = "#fff";
|
||||||
|
let text = "#000";
|
||||||
|
let backup = "#fff";
|
||||||
|
|
||||||
|
// Select the appropriate color based on the platform
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
primary = colors.dominant;
|
||||||
|
backup = colors.vibrant;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
primary = colors.detail;
|
||||||
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the primary color if it's too close to black
|
||||||
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
|
setPrimaryColor({
|
||||||
|
primary,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the colors in storage
|
||||||
|
if (source.uri && primary) {
|
||||||
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
|
storage.set(`${source.uri}-text`, text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||||
|
|
||||||
|
if (isTv) return;
|
||||||
|
};
|
||||||
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
|
|||||||
import {
|
import {
|
||||||
disableTVMenuKeyInterception,
|
disableTVMenuKeyInterception,
|
||||||
enableTVMenuKeyInterception,
|
enableTVMenuKeyInterception,
|
||||||
|
useTVBackPress,
|
||||||
} from "./useTVBackPress";
|
} from "./useTVBackPress";
|
||||||
|
|
||||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||||
|
|
||||||
|
/** All tab route names used in the bottom tab navigator. */
|
||||||
|
export const TAB_ROUTES = [
|
||||||
|
"(home)",
|
||||||
|
"(search)",
|
||||||
|
"(favorites)",
|
||||||
|
"(libraries)",
|
||||||
|
"(watchlists)",
|
||||||
|
"(custom-links)",
|
||||||
|
"(settings)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TabRoute = (typeof TAB_ROUTES)[number];
|
||||||
|
|
||||||
|
/** Check if a segment string is a tab route. */
|
||||||
|
export function isTabRoute(s: string): s is TabRoute {
|
||||||
|
return (TAB_ROUTES as readonly string[]).includes(s);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're at the root of a tab
|
* Check if we're at the root of a tab
|
||||||
*/
|
*/
|
||||||
function isAtTabRoot(segments: string[]): boolean {
|
function isAtTabRoot(segments: string[]): boolean {
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
const tabNames = [
|
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
"(home)",
|
|
||||||
"(search)",
|
|
||||||
"(favorites)",
|
|
||||||
"(libraries)",
|
|
||||||
"(watchlists)",
|
|
||||||
"(settings)",
|
|
||||||
"(custom-links)",
|
|
||||||
];
|
|
||||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current tab name from segments
|
* Get the current tab name from segments
|
||||||
*/
|
*/
|
||||||
function getCurrentTab(segments: string[]): string | undefined {
|
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||||
return segments.find(
|
return segments.find(isTabRoute);
|
||||||
(s) =>
|
|
||||||
s === "(home)" ||
|
|
||||||
s === "(search)" ||
|
|
||||||
s === "(favorites)" ||
|
|
||||||
s === "(libraries)" ||
|
|
||||||
s === "(watchlists)" ||
|
|
||||||
s === "(settings)" ||
|
|
||||||
s === "(custom-links)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
|
|||||||
export function useTVHomeBackHandler() {
|
export function useTVHomeBackHandler() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
// Get current state
|
|
||||||
const currentTab = getCurrentTab(segments);
|
const currentTab = getCurrentTab(segments);
|
||||||
const atTabRoot = isAtTabRoot(segments);
|
const atTabRoot = isAtTabRoot(segments);
|
||||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||||
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
|
|||||||
enableTVMenuKeyInterception();
|
enableTVMenuKeyInterception();
|
||||||
}, [isOnHomeRoot]);
|
}, [isOnHomeRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
||||||
|
*
|
||||||
|
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
||||||
|
* built-in tab-level back handling — pressing back at a tab root would pop the
|
||||||
|
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
||||||
|
* to Home instead.
|
||||||
|
*/
|
||||||
|
export function useTVTabRootBackHandler(
|
||||||
|
onNavigateHome: () => void,
|
||||||
|
isAtTabRoot: boolean,
|
||||||
|
currentTab: string | undefined,
|
||||||
|
) {
|
||||||
|
useTVBackPress(() => {
|
||||||
|
if (!Platform.isTV || Platform.OS !== "android") return false;
|
||||||
|
if (!isAtTabRoot || currentTab === "(home)") return false;
|
||||||
|
onNavigateHome();
|
||||||
|
return true;
|
||||||
|
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
|||||||
const fetchSSID = useCallback(async () => {
|
const fetchSSID = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
const result = await getSSID();
|
const result = await getSSID();
|
||||||
|
console.log("[WiFi Debug] Native module SSID:", result);
|
||||||
setSSID(result);
|
setSSID(result);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const WifiSsidModule =
|
|||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<string | null> {
|
export async function getSSID(): Promise<string | null> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
|
console.log("[WifiSsid] Module not available on this platform");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,7 @@
|
|||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||||
"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": {
|
||||||
@@ -131,7 +129,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.9",
|
"expo-doctor": "1.19.7",
|
||||||
"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",
|
||||||
|
|||||||
@@ -142,12 +142,31 @@ export function useDownloadEventHandlers({
|
|||||||
} else {
|
} else {
|
||||||
// Transcoding - estimate from bitrate
|
// Transcoding - estimate from bitrate
|
||||||
const process = processes.find((p) => p.id === processId);
|
const process = processes.find((p) => p.id === processId);
|
||||||
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
|
console.log(
|
||||||
const { estimateDownloadSize } = require("@/utils/download");
|
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
|
||||||
estimatedTotalBytes = estimateDownloadSize(
|
process ? "yes" : "no",
|
||||||
process.maxBitrate.value,
|
);
|
||||||
process.item.RunTimeTicks,
|
if (process) {
|
||||||
);
|
console.log(`[DPL] Process bitrate:`, {
|
||||||
|
key: process.maxBitrate.key,
|
||||||
|
value: process.maxBitrate.value,
|
||||||
|
runTimeTicks: process.item.RunTimeTicks,
|
||||||
|
});
|
||||||
|
if (process.maxBitrate.value && process.item.RunTimeTicks) {
|
||||||
|
const { estimateDownloadSize } = require("@/utils/download");
|
||||||
|
estimatedTotalBytes = estimateDownloadSize(
|
||||||
|
process.maxBitrate.value,
|
||||||
|
process.item.RunTimeTicks,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[DPL] Calculated estimatedTotalBytes:`,
|
||||||
|
estimatedTotalBytes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ 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;
|
||||||
@@ -54,7 +53,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: APP_VERSION },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -136,7 +135,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: APP_VERSION },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -170,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
@@ -28,20 +28,6 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
|
|||||||
["episodes"],
|
["episodes"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Query keys that depend on per-user playback state (resume position, played
|
|
||||||
// status, favorites) and should be refreshed when the server reports a
|
|
||||||
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
|
|
||||||
// episode does not pointlessly refetch "recently added" or suggestions.
|
|
||||||
const USER_DATA_CHANGE_QUERY_KEYS = [
|
|
||||||
["home", "continueAndNextUp"],
|
|
||||||
["home", "resumeItems"],
|
|
||||||
["home", "nextUp-all"],
|
|
||||||
["home", "heroItems"],
|
|
||||||
["resumeItems"],
|
|
||||||
["nextUp-all"],
|
|
||||||
["nextUp"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
MessageType: string;
|
MessageType: string;
|
||||||
Data: any;
|
Data: any;
|
||||||
@@ -52,30 +38,10 @@ interface WebSocketProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler invoked for every message of a given `MessageType`. Receives the
|
|
||||||
* message `Data` payload and the full message.
|
|
||||||
*/
|
|
||||||
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
|
|
||||||
|
|
||||||
interface WebSocketContextType {
|
interface WebSocketContextType {
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/**
|
|
||||||
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
|
|
||||||
* message, so bursts arriving in the same tick are coalesced and lost. Kept
|
|
||||||
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
|
|
||||||
*/
|
|
||||||
lastMessage: WebSocketMessage | null;
|
lastMessage: WebSocketMessage | null;
|
||||||
/**
|
|
||||||
* Subscribe to a given message type. The handler is called synchronously for
|
|
||||||
* every matching message (no coalescing, unlike `lastMessage`). Returns an
|
|
||||||
* unsubscribe function to call on cleanup.
|
|
||||||
*/
|
|
||||||
subscribe: (
|
|
||||||
messageType: string,
|
|
||||||
handler: WebSocketMessageHandler,
|
|
||||||
) => () => void;
|
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: any) => void;
|
||||||
clearLastMessage: () => void;
|
clearLastMessage: () => void;
|
||||||
}
|
}
|
||||||
@@ -88,6 +54,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
return getOrSetDeviceId();
|
return getOrSetDeviceId();
|
||||||
@@ -96,76 +63,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const userDataChangeDebounceRef = useRef<ReturnType<
|
|
||||||
typeof setTimeout
|
|
||||||
> | null>(null);
|
|
||||||
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
|
|
||||||
// another path (foreground, network reconnect, effect re-run) can cancel a
|
|
||||||
// pending one — an untracked timer would later open a second socket.
|
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
|
|
||||||
// subscribing/dispatching never triggers a re-render.
|
|
||||||
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
|
|
||||||
new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscribe = useCallback(
|
|
||||||
(messageType: string, handler: WebSocketMessageHandler) => {
|
|
||||||
const listeners = listenersRef.current;
|
|
||||||
let handlers = listeners.get(messageType);
|
|
||||||
if (!handlers) {
|
|
||||||
handlers = new Set();
|
|
||||||
listeners.set(messageType, handlers);
|
|
||||||
}
|
|
||||||
handlers.add(handler);
|
|
||||||
return () => {
|
|
||||||
handlers?.delete(handler);
|
|
||||||
// Only drop the map entry if it still points at THIS set. After an
|
|
||||||
// unsubscribe + re-subscribe for the same type, a stale second call to
|
|
||||||
// this cleanup would otherwise delete the new subscribers' set and
|
|
||||||
// silently stop delivering their messages.
|
|
||||||
if (
|
|
||||||
handlers &&
|
|
||||||
handlers.size === 0 &&
|
|
||||||
listeners.get(messageType) === handlers
|
|
||||||
) {
|
|
||||||
listeners.delete(messageType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dispatchMessage = useCallback((message: WebSocketMessage) => {
|
|
||||||
const handlers = listenersRef.current.get(message.MessageType);
|
|
||||||
if (!handlers || handlers.size === 0) return;
|
|
||||||
// Copy to tolerate handlers that unsubscribe during dispatch.
|
|
||||||
for (const handler of [...handlers]) {
|
|
||||||
// Isolate each handler so one throwing subscriber can't abort the rest
|
|
||||||
// (and isn't misreported as a parse failure by the outer onmessage catch).
|
|
||||||
try {
|
|
||||||
handler(message.Data, message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error handling WebSocket message type "${message.MessageType}":`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
// Cancel any reconnect queued by a previous onerror before opening a new
|
|
||||||
// socket, so we never end up with two live sockets — each would double the
|
|
||||||
// message fan-out and double-invalidate queries.
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,10 +85,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onopen = () => {
|
newWebSocket.onopen = () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
@@ -201,15 +96,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
// Don't log errors - this is expected when offline or server unreachable
|
// Don't log errors - this is expected when offline or server unreachable
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|
||||||
// Replace any still-pending reconnect so only one is ever queued; the
|
|
||||||
// previously untracked handle could leak and open a second socket.
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
setTimeout(() => {
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}, reconnectDelay);
|
}, reconnectDelay);
|
||||||
}
|
}
|
||||||
@@ -224,10 +113,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
newWebSocket.onmessage = (e) => {
|
newWebSocket.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(e.data);
|
const message = JSON.parse(e.data);
|
||||||
// Legacy single-slot state, still consumed by useWebsockets.
|
setLastMessage(message); // Store the last message in context
|
||||||
setLastMessage(message);
|
|
||||||
// Pub/sub: deliver to every subscriber without coalescing.
|
|
||||||
dispatchMessage(message);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing WebSocket message:", error);
|
console.error("Error parsing WebSocket message:", error);
|
||||||
}
|
}
|
||||||
@@ -238,13 +124,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
newWebSocket.close();
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
|
}, [api, deviceId, isNetworkConnected]);
|
||||||
|
|
||||||
const handleLibraryChanged = useCallback(
|
const handleLibraryChanged = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
@@ -275,80 +157,47 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUserDataChanged = useCallback(
|
useEffect(() => {
|
||||||
(data: any) => {
|
if (!lastMessage) {
|
||||||
// Jellyfin sends UserDataChanged when playback position, played status
|
return;
|
||||||
// or favorites change (e.g. finishing an episode). Only the
|
}
|
||||||
// progression-based home sections care about it.
|
if (lastMessage.MessageType === "Play") {
|
||||||
if (!((data?.UserDataList?.length ?? 0) > 0)) {
|
handlePlayCommand(lastMessage.Data);
|
||||||
return;
|
} else if (lastMessage.MessageType === "LibraryChanged") {
|
||||||
}
|
handleLibraryChanged(lastMessage.Data);
|
||||||
|
}
|
||||||
// Finishing an item can emit several UserDataChanged messages, so
|
}, [lastMessage, router, handleLibraryChanged]);
|
||||||
// debounce to invalidate the affected sections only once.
|
|
||||||
if (userDataChangeDebounceRef.current) {
|
|
||||||
clearTimeout(userDataChangeDebounceRef.current);
|
|
||||||
}
|
|
||||||
userDataChangeDebounceRef.current = setTimeout(() => {
|
|
||||||
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
|
||||||
}
|
|
||||||
}, 800);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh library-dependent queries when the server reports a change.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("LibraryChanged", handleLibraryChanged),
|
|
||||||
[subscribe, handleLibraryChanged],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh "Continue Watching" / "Next Up" when playback state changes.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("UserDataChanged", handleUserDataChanged),
|
|
||||||
[subscribe, handleUserDataChanged],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (libraryChangeDebounceRef.current) {
|
if (libraryChangeDebounceRef.current) {
|
||||||
clearTimeout(libraryChangeDebounceRef.current);
|
clearTimeout(libraryChangeDebounceRef.current);
|
||||||
}
|
}
|
||||||
if (userDataChangeDebounceRef.current) {
|
|
||||||
clearTimeout(userDataChangeDebounceRef.current);
|
|
||||||
}
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlayCommand = useCallback((data: any) => {
|
const handlePlayCommand = useCallback(
|
||||||
if (!data?.ItemIds?.length) {
|
(data: any) => {
|
||||||
return;
|
if (!data?.ItemIds?.length) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const itemId = data.ItemIds[0];
|
const itemId = data.ItemIds[0];
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/player/direct-player",
|
pathname: "/(auth)/player/direct-player",
|
||||||
params: {
|
params: {
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
playCommand: data.PlayCommand || "PlayNow",
|
playCommand: data.PlayCommand || "PlayNow",
|
||||||
audioIndex: data.AudioStreamIndex?.toString(),
|
audioIndex: data.AudioStreamIndex?.toString(),
|
||||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||||
mediaSourceId: data.MediaSourceId || "",
|
mediaSourceId: data.MediaSourceId || "",
|
||||||
bitrateValue: "",
|
bitrateValue: "",
|
||||||
offline: "false",
|
offline: "false",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[router],
|
||||||
// Server-initiated "Play me this item" remote command.
|
|
||||||
useEffect(
|
|
||||||
() => subscribe("Play", handlePlayCommand),
|
|
||||||
[subscribe, handlePlayCommand],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -418,14 +267,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
value={{
|
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||||
ws,
|
|
||||||
isConnected,
|
|
||||||
lastMessage,
|
|
||||||
subscribe,
|
|
||||||
sendMessage,
|
|
||||||
clearLastMessage,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Populates the "Streamyfin Version" dropdown in the issue report form with the
|
|
||||||
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
|
|
||||||
* release events + a weekly cron (and manually via workflow_dispatch).
|
|
||||||
*
|
|
||||||
* Source: published, non-draft, non-prerelease GitHub releases, newest first.
|
|
||||||
* Non-version sentinels (e.g. "older", "TestFlight/Development build") are
|
|
||||||
* preserved at the end of the list.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun scripts/update-issue-form.mjs # rewrite the form in place
|
|
||||||
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
|
|
||||||
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
|
|
||||||
*
|
|
||||||
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
readFileSync as read,
|
|
||||||
writeFileSync as write,
|
|
||||||
} from "node:fs";
|
|
||||||
|
|
||||||
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
|
|
||||||
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
|
|
||||||
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
|
|
||||||
const LIMIT =
|
|
||||||
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
|
|
||||||
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
|
||||||
const DRY = process.argv.includes("--dry-run");
|
|
||||||
|
|
||||||
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
|
|
||||||
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
|
|
||||||
|
|
||||||
// 1. Fetch the latest published releases (newest first) — drafts and prereleases
|
|
||||||
// aren't a full release users run, so they don't belong in the dropdown.
|
|
||||||
const raw = execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"release",
|
|
||||||
"list",
|
|
||||||
"--repo",
|
|
||||||
REPO,
|
|
||||||
"--exclude-drafts",
|
|
||||||
"--exclude-pre-releases",
|
|
||||||
"--limit",
|
|
||||||
String(LIMIT),
|
|
||||||
"--json",
|
|
||||||
"tagName",
|
|
||||||
"--jq",
|
|
||||||
".[].tagName",
|
|
||||||
],
|
|
||||||
// Bounded timeout so a stuck gh process fails the job fast instead of
|
|
||||||
// holding the workflow open until the job-level timeout.
|
|
||||||
{ encoding: "utf8", timeout: 30_000 },
|
|
||||||
);
|
|
||||||
const seen = new Set();
|
|
||||||
const versions = [];
|
|
||||||
for (const tag of raw.split("\n")) {
|
|
||||||
if (!tag) continue;
|
|
||||||
const ver = tag.trim().replace(/^v/, "");
|
|
||||||
if (!isVersion(ver) || seen.has(ver)) continue;
|
|
||||||
seen.add(ver);
|
|
||||||
versions.push(ver);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!versions.length) {
|
|
||||||
console.error("No release versions found — leaving the form untouched.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. rewrite the dropdown options, preserving non-version sentinels
|
|
||||||
// (e.g. "older", "TestFlight/Development build") at the end of the list.
|
|
||||||
const lines = read(FORM, "utf8").split("\n");
|
|
||||||
const idIdx = lines.findIndex((l) =>
|
|
||||||
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
|
|
||||||
);
|
|
||||||
if (idIdx === -1)
|
|
||||||
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
|
|
||||||
const optIdx = lines.findIndex(
|
|
||||||
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
|
|
||||||
);
|
|
||||||
if (optIdx === -1)
|
|
||||||
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
|
|
||||||
|
|
||||||
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
|
|
||||||
let end = optIdx + 1;
|
|
||||||
const sentinels = [];
|
|
||||||
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
|
|
||||||
const val = lines[end].replace(/^\s*-\s+/, "");
|
|
||||||
if (!isVersion(val)) sentinels.push(val);
|
|
||||||
end++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOptions = [...versions, ...sentinels].map(
|
|
||||||
(v) => `${itemIndent}- ${v}`,
|
|
||||||
);
|
|
||||||
const updated = [
|
|
||||||
...lines.slice(0, optIdx + 1),
|
|
||||||
...newOptions,
|
|
||||||
...lines.slice(end),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
|
|
||||||
);
|
|
||||||
if (DRY) {
|
|
||||||
console.log("--dry-run: not writing.");
|
|
||||||
} else {
|
|
||||||
write(FORM, updated);
|
|
||||||
console.log(`Updated ${FORM}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose the resulting list for the workflow (PR description).
|
|
||||||
if (process.env.GITHUB_OUTPUT) {
|
|
||||||
appendFileSync(
|
|
||||||
process.env.GITHUB_OUTPUT,
|
|
||||||
`versions=${versions.join(", ")}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and series to watch offline.",
|
"downloads_feature_description": "Download movies and series to watch offline. Use either the default method or install the optimize server to download files in the background.",
|
||||||
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
@@ -261,6 +261,43 @@
|
|||||||
"None": "None",
|
"None": "None",
|
||||||
"OnlyForced": "Only forced"
|
"OnlyForced": "Only forced"
|
||||||
},
|
},
|
||||||
|
"text_color": "Text color",
|
||||||
|
"background_color": "Background color",
|
||||||
|
"outline_color": "Outline color",
|
||||||
|
"outline_thickness": "Outline thickness",
|
||||||
|
"background_opacity": "Background opacity",
|
||||||
|
"outline_opacity": "Outline opacity",
|
||||||
|
"bold_text": "Bold text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Black",
|
||||||
|
"Gray": "Gray",
|
||||||
|
"Silver": "Silver",
|
||||||
|
"White": "White",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Red",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Yellow",
|
||||||
|
"Olive": "Olive",
|
||||||
|
"Green": "Green",
|
||||||
|
"Teal": "Teal",
|
||||||
|
"Lime": "Lime",
|
||||||
|
"Purple": "Purple",
|
||||||
|
"Navy": "Navy",
|
||||||
|
"Blue": "Blue",
|
||||||
|
"Aqua": "Aqua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "None",
|
||||||
|
"Thin": "Thin",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Thick"
|
||||||
|
},
|
||||||
|
"subtitle_color": "Subtitle color",
|
||||||
|
"subtitle_background_color": "Background color",
|
||||||
|
"subtitle_font": "Subtitle font",
|
||||||
|
"ksplayer_title": "KSPlayer settings",
|
||||||
|
"hardware_decode": "Hardware decoding",
|
||||||
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API key",
|
"opensubtitles_api_key": "API key",
|
||||||
@@ -278,6 +315,25 @@
|
|||||||
"bottom": "Bottom"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vlc_subtitles": {
|
||||||
|
"title": "VLC subtitle settings",
|
||||||
|
"hint": "Customize subtitle appearance for the VLC player. Changes take effect on next playback.",
|
||||||
|
"text_color": "Text color",
|
||||||
|
"background_color": "Background color",
|
||||||
|
"background_opacity": "Background opacity",
|
||||||
|
"outline_color": "Outline color",
|
||||||
|
"outline_opacity": "Outline opacity",
|
||||||
|
"outline_thickness": "Outline thickness",
|
||||||
|
"bold": "Bold text",
|
||||||
|
"margin": "Bottom margin"
|
||||||
|
},
|
||||||
|
"video_player": {
|
||||||
|
"title": "Video player",
|
||||||
|
"video_player": "Video player",
|
||||||
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
|
"ksplayer": "KSPlayer",
|
||||||
|
"vlc": "VLC"
|
||||||
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"video_orientation": "Video orientation",
|
"video_orientation": "Video orientation",
|
||||||
@@ -295,6 +351,11 @@
|
|||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
|
"video_player": "Video player",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||||
|
},
|
||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
@@ -306,6 +367,9 @@
|
|||||||
"max_auto_play_episode_count": "Max auto-play episode count",
|
"max_auto_play_episode_count": "Max auto-play episode count",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Downloads"
|
||||||
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"playback_title": "Playback",
|
"playback_title": "Playback",
|
||||||
@@ -320,6 +384,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -348,18 +413,23 @@
|
|||||||
"read_more_about_marlin": "Read more about Marlin.",
|
"read_more_about_marlin": "Read more about Marlin.",
|
||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved"
|
"saved": "Saved",
|
||||||
}
|
"refreshed": "Settings refreshed from server"
|
||||||
|
},
|
||||||
|
"refresh_from_server": "Refresh settings from server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "Disable Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for search",
|
"enable_search": "Use for search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read more about Streamystats.",
|
"read_more_about_streamystats": "Read more about Streamystats.",
|
||||||
|
"save_button": "Save",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
|
"home_sections_title": "Home sections",
|
||||||
"enable_movie_recommendations": "Movie recommendations",
|
"enable_movie_recommendations": "Movie recommendations",
|
||||||
"enable_series_recommendations": "Series recommendations",
|
"enable_series_recommendations": "Series recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted watchlists",
|
"enable_promoted_watchlists": "Promoted watchlists",
|
||||||
@@ -375,7 +445,8 @@
|
|||||||
"refresh_from_server": "Refresh settings from server"
|
"refresh_from_server": "Refresh settings from server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Enable watchlist integration"
|
"watchlist_enabler": "Enable watchlist integration",
|
||||||
|
"watchlist_button": "Toggle watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -386,6 +457,7 @@
|
|||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
|
"enable_music_cache": "Enable music cache",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
"music_cache_size": "{{size}} cached",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Music cache cleared",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
@@ -395,6 +467,8 @@
|
|||||||
"clear_all_cache": "Clear all cache",
|
"clear_all_cache": "Clear all cache",
|
||||||
"clear_all_cache_confirm": "Clear all cache?",
|
"clear_all_cache_confirm": "Clear all cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
"clear_all_cache_success": "Cache cleared",
|
||||||
|
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
@@ -416,12 +490,15 @@
|
|||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error deleting files"
|
"error_deleting_files": "Error deleting files",
|
||||||
|
"background_downloads_enabled": "Background downloads enabled",
|
||||||
|
"background_downloads_disabled": "Background downloads disabled"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity timeout",
|
"title": "Inactivity timeout",
|
||||||
|
"description": "Auto logout after inactivity",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
@@ -441,7 +518,10 @@
|
|||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
|
"queue": "Queue",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
|
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||||
|
"no_items_in_queue": "No items in queue",
|
||||||
"no_downloaded_items": "No downloaded items",
|
"no_downloaded_items": "No downloaded items",
|
||||||
"delete_all_movies_button": "Delete all movies",
|
"delete_all_movies_button": "Delete all movies",
|
||||||
"delete_all_series_button": "Delete all series",
|
"delete_all_series_button": "Delete all series",
|
||||||
@@ -466,8 +546,13 @@
|
|||||||
"failed_to_delete_all_series": "Failed to delete all series",
|
"failed_to_delete_all_series": "Failed to delete all series",
|
||||||
"deleted_media_successfully": "Deleted other media successfully!",
|
"deleted_media_successfully": "Deleted other media successfully!",
|
||||||
"failed_to_delete_media": "Failed to delete other media",
|
"failed_to_delete_media": "Failed to delete other media",
|
||||||
|
"download_deleted": "Download deleted",
|
||||||
"download_cancelled": "Download cancelled",
|
"download_cancelled": "Download cancelled",
|
||||||
"could_not_delete_download": "Could not delete download",
|
"could_not_delete_download": "Could not delete download",
|
||||||
|
"download_paused": "Download paused",
|
||||||
|
"could_not_pause_download": "Could not pause download",
|
||||||
|
"download_resumed": "Download resumed",
|
||||||
|
"could_not_resume_download": "Could not resume download",
|
||||||
"download_completed": "Download completed",
|
"download_completed": "Download completed",
|
||||||
"download_failed": "Download failed",
|
"download_failed": "Download failed",
|
||||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||||
@@ -477,7 +562,10 @@
|
|||||||
"item_already_downloading": "{{item}} is already downloading",
|
"item_already_downloading": "{{item}} is already downloading",
|
||||||
"all_files_deleted": "All downloads deleted successfully",
|
"all_files_deleted": "All downloads deleted successfully",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||||
|
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
|
"go_to_downloads": "Go to Downloads",
|
||||||
"file_deleted": "{{item}} deleted"
|
"file_deleted": "{{item}} deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,17 +583,16 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"next": "Next",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"episodes": "Episodes",
|
"refresh": "Refresh"
|
||||||
"movies": "Movies",
|
|
||||||
"loading": "Loading…",
|
|
||||||
"seeAll": "See all"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -604,6 +691,10 @@
|
|||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
"next_episode": "Next episode",
|
"next_episode": "Next episode",
|
||||||
|
"refresh_tracks": "Refresh tracks",
|
||||||
|
"audio_tracks": "Audio tracks:",
|
||||||
|
"playback_state": "Playback state:",
|
||||||
|
"index": "Index:",
|
||||||
"continue_watching": "Continue watching",
|
"continue_watching": "Continue watching",
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
@@ -632,8 +723,7 @@
|
|||||||
"stopPlayback": "Stop playback",
|
"stopPlayback": "Stop playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded",
|
"downloaded": "Downloaded"
|
||||||
"missing_parameters": "Missing playback parameters"
|
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Chapters",
|
||||||
@@ -671,6 +761,7 @@
|
|||||||
"show_more": "Show more",
|
"show_more": "Show more",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
"left": "left",
|
"left": "left",
|
||||||
|
"more_info": "More info",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical details",
|
"technical_details": "Technical details",
|
||||||
@@ -693,8 +784,7 @@
|
|||||||
"resume_playback": "Resume playback",
|
"resume_playback": "Resume playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from start",
|
"play_from_start": "Play from start",
|
||||||
"continue_from": "Continue from {{time}}",
|
"continue_from": "Continue from {{time}}"
|
||||||
"no_data_available": "No data available"
|
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@@ -798,9 +888,13 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"tracks": "Songs"
|
"tracks": "Songs"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"recently_added": "Recently added",
|
"recently_added": "Recently added",
|
||||||
"recently_played": "Recently played",
|
"recently_played": "Recently played",
|
||||||
"frequently_played": "Frequently played",
|
"frequently_played": "Frequently played",
|
||||||
|
"explore": "Explore",
|
||||||
"top_tracks": "Top songs",
|
"top_tracks": "Top songs",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"shuffle": "Shuffle",
|
"shuffle": "Shuffle",
|
||||||
@@ -934,6 +1028,7 @@
|
|||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with phone",
|
"pair_with_phone": "Pair with phone",
|
||||||
"pair_with_phone_title": "Log in on TV",
|
"pair_with_phone_title": "Log in on TV",
|
||||||
|
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Logging in...",
|
||||||
|
|||||||
18
utils/bToMb.ts
Normal file
18
utils/bToMb.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Convert bits to megabits or gigabits
|
||||||
|
*
|
||||||
|
* Return nice looking string
|
||||||
|
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||||
|
if (!bits) return "0MB";
|
||||||
|
|
||||||
|
const megabits = bits / 1000000;
|
||||||
|
|
||||||
|
if (megabits < 1000) {
|
||||||
|
return `${Math.round(megabits)}MB`;
|
||||||
|
}
|
||||||
|
const gigabits = megabits / 1000;
|
||||||
|
return `${gigabits.toFixed(1)}GB`;
|
||||||
|
}
|
||||||
47
utils/collectionTypeToItemType.ts
Normal file
47
utils/collectionTypeToItemType.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||||
|
*
|
||||||
|
* CollectionTypes
|
||||||
|
* readonly Unknown: "unknown";
|
||||||
|
readonly Movies: "movies";
|
||||||
|
readonly Tvshows: "tvshows";
|
||||||
|
readonly Trailers: "trailers";
|
||||||
|
readonly Homevideos: "homevideos";
|
||||||
|
readonly Boxsets: "boxsets";
|
||||||
|
readonly Books: "books";
|
||||||
|
readonly Photos: "photos";
|
||||||
|
readonly Livetv: "livetv";
|
||||||
|
readonly Playlists: "playlists";
|
||||||
|
readonly Folders: "folders";
|
||||||
|
*/
|
||||||
|
export const colletionTypeToItemType = (
|
||||||
|
collectionType?: CollectionType | null,
|
||||||
|
): BaseItemKind | undefined => {
|
||||||
|
if (!collectionType) return undefined;
|
||||||
|
|
||||||
|
switch (collectionType) {
|
||||||
|
case CollectionType.Movies:
|
||||||
|
return BaseItemKind.Movie;
|
||||||
|
case CollectionType.Tvshows:
|
||||||
|
return BaseItemKind.Series;
|
||||||
|
case CollectionType.Homevideos:
|
||||||
|
return BaseItemKind.Video;
|
||||||
|
case CollectionType.Books:
|
||||||
|
return BaseItemKind.Book;
|
||||||
|
case CollectionType.Playlists:
|
||||||
|
return BaseItemKind.Playlist;
|
||||||
|
case CollectionType.Folders:
|
||||||
|
return BaseItemKind.Folder;
|
||||||
|
case CollectionType.Photos:
|
||||||
|
return BaseItemKind.Photo;
|
||||||
|
case CollectionType.Trailers:
|
||||||
|
return BaseItemKind.Trailer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
language: string;
|
||||||
|
default: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
autoSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseM3U8ForSubtitles(
|
||||||
|
url: string,
|
||||||
|
): Promise<SubtitleTrack[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: "text" });
|
||||||
|
const lines = response.data.split(/\r?\n/);
|
||||||
|
const subtitleTracks: SubtitleTrack[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||||
|
const attributes = parseAttributes(line);
|
||||||
|
const track: SubtitleTrack = {
|
||||||
|
index: index++,
|
||||||
|
name: attributes.NAME || "",
|
||||||
|
uri: attributes.URI || "",
|
||||||
|
language: attributes.LANGUAGE || "",
|
||||||
|
default: attributes.DEFAULT === "YES",
|
||||||
|
forced: attributes.FORCED === "YES",
|
||||||
|
autoSelect: attributes.AUTOSELECT === "YES",
|
||||||
|
};
|
||||||
|
subtitleTracks.push(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subtitleTracks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributes(line: string): { [key: string]: string } {
|
||||||
|
const attributes: { [key: string]: string } = {};
|
||||||
|
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
|
||||||
|
for (const match of line.matchAll(regex)) {
|
||||||
|
const key = match[1];
|
||||||
|
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||||
|
attributes[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
56
utils/jellyfin/session/capabilities.ts
Normal file
56
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Settings } from "../../atoms/settings";
|
||||||
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
deviceProfile: Settings["deviceProfile"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = api.axiosInstance.post(
|
||||||
|
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
|
supportedCommands: [
|
||||||
|
"PlayState",
|
||||||
|
"Play",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayMessage",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
DeviceProfile: generateDeviceProfile(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return d;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface NextUpParams {
|
||||||
|
itemId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
api?: Api | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next up episodes for a series or all series for a user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for fetching next up episodes
|
||||||
|
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||||
|
*/
|
||||||
|
export const nextUp = async ({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
api,
|
||||||
|
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||||
|
if (!userId || !api) {
|
||||||
|
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||||
|
`${api.basePath}/Shows/NextUp`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
SeriesId: itemId || undefined,
|
||||||
|
UserId: userId,
|
||||||
|
Fields: "MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.Items;
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
34
utils/jellyfin/user-library/getItemById.ts
Normal file
34
utils/jellyfin/user-library/getItemById.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an item by its ID from the API.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param itemId - The ID of the item to retrieve.
|
||||||
|
* @returns The item object or undefined if no item matches the ID.
|
||||||
|
*/
|
||||||
|
export const getItemById = async (
|
||||||
|
api?: Api | null | undefined,
|
||||||
|
itemId?: string | null | undefined,
|
||||||
|
): Promise<BaseItemDto | undefined> => {
|
||||||
|
if (!api || !itemId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||||
|
|
||||||
|
const item = itemData.data;
|
||||||
|
if (!item) {
|
||||||
|
console.error("No items found with the specified ID:", itemId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve the item:", error);
|
||||||
|
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -72,6 +72,21 @@ export const readFromLog = (): LogEntry[] => {
|
|||||||
return logs ? JSON.parse(logs) : [];
|
return logs ? JSON.parse(logs) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearLogs = () => {
|
||||||
|
storage.remove("logs");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||||
|
const diagnostics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
processes: extra?.processes || [],
|
||||||
|
nativeTasks: extra?.nativeTasks || [],
|
||||||
|
focusedProcess: extra?.focusedProcess || null,
|
||||||
|
};
|
||||||
|
writeDebugLog("Download diagnostics", diagnostics);
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
export function useLog() {
|
export function useLog() {
|
||||||
const context = useContext(LogContext);
|
const context = useContext(LogContext);
|
||||||
if (context === null) {
|
if (context === null) {
|
||||||
|
|||||||
5
utils/secondsToTicks.ts
Normal file
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
@@ -203,6 +203,27 @@ export async function hasAccountCredential(
|
|||||||
return stored !== null;
|
return stored !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all credentials for all accounts on all servers.
|
||||||
|
*/
|
||||||
|
export async function clearAllCredentials(): Promise<void> {
|
||||||
|
const previousServers = getPreviousServers();
|
||||||
|
|
||||||
|
for (const server of previousServers) {
|
||||||
|
for (const account of server.accounts) {
|
||||||
|
const key = credentialKey(server.address, account.userId);
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all accounts from servers
|
||||||
|
const clearedServers = previousServers.map((server) => ({
|
||||||
|
...server,
|
||||||
|
accounts: [],
|
||||||
|
}));
|
||||||
|
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an account in a server's accounts list.
|
* Add or update an account in a server's accounts list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import Constants from "expo-constants";
|
|
||||||
|
|
||||||
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
|
|
||||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
|
||||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
|
||||||
|
|
||||||
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
|
|
||||||
export interface BuildMeta {
|
|
||||||
commit?: string | null;
|
|
||||||
branch?: string | null;
|
|
||||||
profile?: string | null;
|
|
||||||
runNumber?: string | null;
|
|
||||||
builtAt?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionInfo {
|
|
||||||
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
|
|
||||||
version: string | null;
|
|
||||||
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
|
|
||||||
build: string | null;
|
|
||||||
/** Short git commit the build was made from, e.g. "a1b2c3d". */
|
|
||||||
commit: string | null;
|
|
||||||
/** Git branch the build was made from, e.g. "develop". */
|
|
||||||
branch: string | null;
|
|
||||||
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
|
||||||
profile: string | null;
|
|
||||||
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
|
||||||
runNumber: string | null;
|
|
||||||
isDev: boolean;
|
|
||||||
isProduction: boolean;
|
|
||||||
/** Graduated label for the Settings "App version" row (see tiering below). */
|
|
||||||
display: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a graduated version string for Settings.
|
|
||||||
*
|
|
||||||
* Tiering (most → least detailed):
|
|
||||||
* - dev / local build → `version · branch · commit` (full context for debugging)
|
|
||||||
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
|
||||||
* Actions run number maps the build to its run — artifacts + logs — without
|
|
||||||
* Expo access)
|
|
||||||
* - production (store / TestFlight) → `version` (build number intentionally
|
|
||||||
* not shown: TestFlight already displays it to testers, and the commit pins the
|
|
||||||
* binary better)
|
|
||||||
*/
|
|
||||||
export function getVersionInfo(): VersionInfo {
|
|
||||||
// Read native/config values defensively — a version string must never crash Settings
|
|
||||||
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
|
|
||||||
const read = <T>(fn: () => T): T | null => {
|
|
||||||
try {
|
|
||||||
return fn() ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const version = read(() => Application.nativeApplicationVersion);
|
|
||||||
const build = read(() => Application.nativeBuildVersion);
|
|
||||||
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
|
|
||||||
{}) as BuildMeta;
|
|
||||||
const commit = meta.commit ?? null;
|
|
||||||
const branch = meta.branch ?? null;
|
|
||||||
const profile = meta.profile ?? null;
|
|
||||||
const runNumber = meta.runNumber ?? null;
|
|
||||||
const isDev = __DEV__ === true;
|
|
||||||
const isProduction =
|
|
||||||
typeof profile === "string" && profile.startsWith("production");
|
|
||||||
|
|
||||||
let display: string;
|
|
||||||
if (isDev) {
|
|
||||||
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
|
||||||
} else if (isProduction) {
|
|
||||||
display = version ?? build ?? "N/A";
|
|
||||||
} else {
|
|
||||||
display =
|
|
||||||
[version, commit, runNumber && `#${runNumber}`]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ") || "N/A";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
build,
|
|
||||||
commit,
|
|
||||||
branch,
|
|
||||||
profile,
|
|
||||||
runNumber,
|
|
||||||
isDev,
|
|
||||||
isProduction,
|
|
||||||
display,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user