Compare commits

..

8 Commits

Author SHA1 Message Date
Lance Chant
6f1d3e4f66 chore: PR comments
Fixing PR comments from coderabbit

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-09 09:41:49 +02:00
lance chant
151a39c7fc Merge branch 'fix/android-tv-issues' into fix/tv-seerr 2026-06-08 14:00:05 +02:00
Lance Chant
a4bc67bc23 fix: fixing seerr intergration
added an alert if seerr if configured via settings, but not authed
Added an alert if the auth for seerr expires it should fire
cleaned up a little

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-08 13:55:52 +02:00
lance chant
ac41fa7863 Merge branch 'develop' into fix/android-tv-issues 2026-06-08 13:54:36 +02:00
lance chant
cd5300e4ba Merge branch 'develop' into fix/android-tv-issues 2026-06-05 13:26:15 +02:00
Lance Chant
36d18e2bec wip: initial implementation of seerr for TV
Allowed the tv to login manually and discover content via search.

Still work to do, I.E centralize the logic for mobile and tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 13:24:11 +02:00
Lance Chant
326956dfda fix: search page crash on android
Ensured the search module is only apple specific to stop android
crashing

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:24:40 +02:00
Lance Chant
7528274249 fix: the home recommendations
there was an issue where the home recommendations was deleted and then
not shown again because of google TV policies

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:08:03 +02:00
69 changed files with 1599 additions and 1170 deletions

View File

@@ -75,13 +75,10 @@ body:
id: version
attributes:
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:
- 0.54.1
- 0.51.0
- 0.47.1
- 0.30.2
- 0.28.0
- Older
- TestFlight/Development build
validations:

54
.github/renovate.json vendored
View File

@@ -44,42 +44,22 @@
]
}
},
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
"matchPackageNames": [
"expo",
"react",
"react-dom",
"react-native",
"react-native-web",
"expo-*",
"@expo/*"
],
"groupName": "Expo SDK",
"dependencyDashboardApproval": true
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
"matchDatasources": ["maven"],
"registryUrls": [
"https://dl.google.com/dl/android/maven2/",
"https://repo.maven.apache.org/maven2/"
]
}
]
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
}

View File

@@ -11,15 +11,6 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
@@ -37,12 +28,12 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: false
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -120,12 +111,12 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: false
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -196,7 +187,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -228,10 +219,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -240,9 +231,7 @@ jobs:
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -263,7 +252,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -295,7 +284,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
@@ -323,7 +312,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -355,10 +344,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -367,7 +356,7 @@ jobs:
- name: 🚀 Build iOS app
env:
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
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -391,7 +380,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -423,7 +412,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- 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:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@@ -21,7 +21,7 @@ jobs:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -97,11 +97,10 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive

View File

@@ -63,7 +63,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
@@ -88,7 +88,7 @@ jobs:
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
@@ -182,7 +182,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
show-progress: false

View File

@@ -1,60 +0,0 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -1,102 +1,67 @@
name: 🐛 Update Issue Form Versions
name: 🐛 Update Bug Report Template
on:
release:
# Only full releases populate the dropdown (no drafts/prereleases).
types: [released]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
types: [published] # Run on every published release on any branch
# Fixed group so a release event and the weekly cron can't race on the same
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency:
group: update-issue-form
cancel-in-progress: false
permissions:
contents: read
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
jobs:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-24.04
update-bug-report:
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- 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:
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
node-version: '24.x'
cache: 'npm'
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
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
id: populate
run: bun scripts/update-issue-form.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: 📬 Create pull request
id: cpr
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci/update-issue-form
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
title: 'chore(): Update bug report template to match release version'
body: |
# 📦 Pull Request
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive 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."
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 }})

View File

@@ -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
The modal uses these default styles (can be overridden via options):

View File

@@ -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 }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
@@ -66,8 +22,6 @@ module.exports = ({ config }) => {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,

View File

@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,81 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
@@ -20,7 +25,13 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -106,8 +120,44 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
// Alert when seerr server is configured but user hasn't connected (only when focused)
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
t(
"jellyseerr.connect_in_settings",
"Jellyseerr is available. Connect in Settings to enable request features.",
),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate jellyseerr session when switching to Discover
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired", "Session expired"),
t(
"jellyseerr.session_expired_connect_again",
"Your Jellyseerr session has expired. Please reconnect in Settings.",
),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[

View File

@@ -1,3 +1,4 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,6 +3,7 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

14
augmentations/string.ts Normal file
View 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 {};

View File

@@ -108,7 +108,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.19.7",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"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-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=="],
@@ -1599,7 +1599,7 @@
"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=="],

View File

View 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>
);
};

View File

@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid],
);
const isPresentedRef = useRef(false);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else if (isPresentedRef.current) {
} else {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
if (index === -1) {
resetState();
onClose();
}

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType],
[source],
);
const selectedSteam = useMemo(

View 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>
);
};

View 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>
);
};

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -22,8 +23,6 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps {
item: MovieResult | TvResult;
isFirstItem?: boolean;
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[
animatedStyle,
{
width: 210,
width: posterWidth,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
<View
style={{
width: 210,
width: posterWidth,
aspectRatio: 10 / 15,
borderRadius: 24,
borderRadius: sizes.gaps.small,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
{title}
</Text>
{year && (
{year != null && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
marginTop: sizes.gaps.small,
}}
>
{year}
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{slideTitle}
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
onEndReached={() => {

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[api, library],
[library],
);
const itemType = useMemo(() => {

View File

@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
<View style={styles.buttonContainer}>
<TVSubmitButton
onPress={handleSubmit}
label={t("login.login_button")}
label={t("login.login")}
loading={isLoading}
disabled={!password}
/>

View 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} />;
}

View 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>
);
};

View 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;

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -14,20 +15,21 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps {
item: MovieResult | TvResult;
onPress: () => void;
isFirstItem?: boolean;
disabled?: boolean;
}
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item,
onPress,
isFirstItem = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -43,18 +45,22 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem}
hasTVPreferredFocus={isFirstItem && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
width: 210,
width: posterWidth,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -64,9 +70,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
>
<View
style={{
width: 210,
width: posterWidth,
aspectRatio: 10 / 15,
borderRadius: 24,
borderRadius: sizes.gaps.small,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -117,13 +123,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
marginTop: sizes.gaps.small,
}}
numberOfLines={2}
>
{title}
</Text>
{year && (
{year != null && (
<Text
style={{
fontSize: typography.callout,
@@ -142,13 +148,16 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
interface TVJellyseerrPersonPosterProps {
item: PersonResult;
onPress: () => void;
disabled?: boolean;
}
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
item,
onPress,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
@@ -157,13 +166,21 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
width: 160,
width: avatarSize,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
@@ -174,9 +191,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
@@ -198,7 +215,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center",
}}
>
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
<Ionicons
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
@@ -207,7 +228,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
marginTop: 12,
marginTop: sizes.gaps.small,
textAlign: "center",
}}
numberOfLines={2}
@@ -223,6 +244,7 @@ interface TVJellyseerrMovieSectionProps {
title: string;
items: MovieResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: MovieResult) => void;
}
@@ -230,20 +252,22 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
title,
items,
isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -254,9 +278,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -264,6 +288,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/>
)}
/>
@@ -275,6 +300,7 @@ interface TVJellyseerrTvSectionProps {
title: string;
items: TvResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: TvResult) => void;
}
@@ -282,20 +308,22 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
title,
items,
isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -306,9 +334,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -316,6 +344,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/>
)}
/>
@@ -327,6 +356,7 @@ interface TVJellyseerrPersonSectionProps {
title: string;
items: PersonResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: PersonResult) => void;
}
@@ -334,20 +364,22 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
title,
items,
isFirstSection: _isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -358,15 +390,16 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item }) => (
<TVJellyseerrPersonPoster
item={item}
onPress={() => onItemPress(item)}
disabled={disabled}
/>
)}
/>
@@ -384,6 +417,7 @@ export interface TVJellyseerrSearchResultsProps {
onMoviePress: (item: MovieResult) => void;
onTvPress: (item: TvResult) => void;
onPersonPress: (item: PersonResult) => void;
disabled?: boolean;
}
export const TVJellyseerrSearchResults: React.FC<
@@ -398,8 +432,10 @@ export const TVJellyseerrSearchResults: React.FC<
onMoviePress,
onTvPress,
onPersonPress,
disabled = false,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
if (loading) {
return null;
@@ -410,7 +446,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: 24,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -418,7 +454,9 @@ export const TVJellyseerrSearchResults: React.FC<
>
{t("search.no_results_found_for")}
</Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
<Text
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}"
</Text>
</View>
@@ -435,18 +473,21 @@ export const TVJellyseerrSearchResults: React.FC<
title={t("search.request_movies")}
items={movieResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items
const getImageUrl = useMemo(() => {
@@ -270,6 +271,9 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/>
</View>
)}
@@ -290,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
disabled={isSearchFocused}
/>
</View>
)}
@@ -316,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
@@ -339,6 +345,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
disabled={isSearchFocused}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}

View 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>
);
};

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
return (
<View {...props}>

39
constants/Languages.ts Normal file
View 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" },
];

View File

@@ -97,14 +97,6 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View 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 };
};

View 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
View 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;
};

View File

@@ -70,6 +70,30 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -450,7 +474,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
}, []);
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -53,6 +53,7 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application>
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<intent-filter>

View File

@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -163,24 +162,16 @@ internal object TvRecommendationsPublisher {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
if (programIds.optLong(key, -1L) == programId) {
programIds.remove(key)
break
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
@@ -331,8 +322,7 @@ internal object TvRecommendationsPublisher {
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
@@ -373,7 +363,7 @@ internal object TvRecommendationsPublisher {
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
prefs.edit().remove(KEY_CHANNEL_ID).apply()
}
// Create a new channel
@@ -394,7 +384,6 @@ internal object TvRecommendationsPublisher {
} ?: return -1L
val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -402,10 +391,6 @@ internal object TvRecommendationsPublisher {
return channelId
}
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -477,18 +462,13 @@ internal object TvRecommendationsPublisher {
}
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
* Append a cache-busting parameter to ensure unique URIs when images change.
* Per Android docs: "Use unique Uris for all images... the old image will
* continue to appear if you don't change the Uri."
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
@@ -551,8 +531,8 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
fun getChannelId(context: Context): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
}
private fun preferences(context: Context): SharedPreferences {
@@ -587,10 +567,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
}
}
}

View File

@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -15,6 +15,7 @@ const WifiSsidModule =
*/
export async function getSSID(): Promise<string | null> {
if (!WifiSsidModule) {
console.log("[WifiSsid] Module not available on this platform");
return null;
}

View File

@@ -22,9 +22,7 @@
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
"postinstall": "patch-package"
},
"dependencies": {
@@ -131,7 +129,7 @@
"@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"expo-doctor": "1.19.7",
"husky": "9.1.7",
"lint-staged": "17.0.5",
"react-test-renderer": "19.2.3",

View File

@@ -142,12 +142,31 @@ export function useDownloadEventHandlers({
} else {
// Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId);
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
console.log(
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
process ? "yes" : "no",
);
if (process) {
console.log(`[DPL] Process bitrate:`, {
key: process.maxBitrate.key,
value: process.maxBitrate.value,
runTimeTicks: process.item.RunTimeTicks,
});
if (process.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
console.log(
`[DPL] Calculated estimatedTotalBytes:`,
estimatedTotalBytes,
);
} else {
console.log(
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
);
}
}
}

View File

@@ -40,7 +40,6 @@ import {
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
interface Server {
address: string;
@@ -54,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -136,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: APP_VERSION },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -170,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
}, DeviceId="${deviceId}", Version="0.54.1"`,
};
}, [deviceId]);
@@ -248,12 +247,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
}, [api, secret, headers, jellyfin]);
useEffect(() => {
(async () => {
await refreshStreamyfinPluginSettings();
})();
}, []);
useEffect(() => {
store.set(apiAtom, api);
}, [api]);
@@ -554,7 +547,20 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
// Refresh plugin settings
await refreshStreamyfinPluginSettings();
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
recentPluginSettings.jellyseerrServerUrl.value,
);
await jellyseerrApi.test().then((result) => {
if (result.isValid && result.requiresPass) {
jellyseerrApi
.login(username, password)
.then(setJellyseerrUser)
.catch(console.error);
}
});
}
}
},
onError: (error) => {

View File

@@ -1,5 +1,4 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
@@ -12,6 +11,7 @@ import {
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -28,20 +28,6 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
["episodes"],
] as const;
// Query keys that depend on per-user playback state (resume position, played
// status, favorites) and should be refreshed when the server reports a
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
// episode does not pointlessly refetch "recently added" or suggestions.
const USER_DATA_CHANGE_QUERY_KEYS = [
["home", "continueAndNextUp"],
["home", "resumeItems"],
["home", "nextUp-all"],
["home", "heroItems"],
["resumeItems"],
["nextUp-all"],
["nextUp"],
] as const;
interface WebSocketMessage {
MessageType: string;
Data: any;
@@ -52,30 +38,10 @@ interface WebSocketProviderProps {
children: ReactNode;
}
/**
* Handler invoked for every message of a given `MessageType`. Receives the
* message `Data` payload and the full message.
*/
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
/**
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
* message, so bursts arriving in the same tick are coalesced and lost. Kept
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
*/
lastMessage: WebSocketMessage | null;
/**
* Subscribe to a given message type. The handler is called synchronously for
* every matching message (no coalescing, unlike `lastMessage`). Returns an
* unsubscribe function to call on cleanup.
*/
subscribe: (
messageType: string,
handler: WebSocketMessageHandler,
) => () => void;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
@@ -88,6 +54,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
@@ -96,76 +63,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const userDataChangeDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
// another path (foreground, network reconnect, effect re-run) can cancel a
// pending one — an untracked timer would later open a second socket.
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
// subscribing/dispatching never triggers a re-render.
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
new Map(),
);
const subscribe = useCallback(
(messageType: string, handler: WebSocketMessageHandler) => {
const listeners = listenersRef.current;
let handlers = listeners.get(messageType);
if (!handlers) {
handlers = new Set();
listeners.set(messageType, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
// Only drop the map entry if it still points at THIS set. After an
// unsubscribe + re-subscribe for the same type, a stale second call to
// this cleanup would otherwise delete the new subscribers' set and
// silently stop delivering their messages.
if (
handlers &&
handlers.size === 0 &&
listeners.get(messageType) === handlers
) {
listeners.delete(messageType);
}
};
},
[],
);
const dispatchMessage = useCallback((message: WebSocketMessage) => {
const handlers = listenersRef.current.get(message.MessageType);
if (!handlers || handlers.size === 0) return;
// Copy to tolerate handlers that unsubscribe during dispatch.
for (const handler of [...handlers]) {
// Isolate each handler so one throwing subscriber can't abort the rest
// (and isn't misreported as a parse failure by the outer onmessage catch).
try {
handler(message.Data, message);
} catch (error) {
console.error(
`Error handling WebSocket message type "${message.MessageType}":`,
error,
);
}
}
}, []);
const connectWebSocket = useCallback(() => {
// Cancel any reconnect queued by a previous onerror before opening a new
// socket, so we never end up with two live sockets — each would double the
// message fan-out and double-invalidate queries.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
return;
}
@@ -186,10 +85,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onopen = () => {
setIsConnected(true);
reconnectAttemptsRef.current = 0;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
@@ -201,15 +96,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
// Don't log errors - this is expected when offline or server unreachable
setIsConnected(false);
// Replace any still-pending reconnect so only one is ever queued; the
// previously untracked handle could leak and open a second socket.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = null;
setTimeout(() => {
connectWebSocket();
}, reconnectDelay);
}
@@ -224,10 +113,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
// Legacy single-slot state, still consumed by useWebsockets.
setLastMessage(message);
// Pub/sub: deliver to every subscriber without coalescing.
dispatchMessage(message);
setLastMessage(message); // Store the last message in context
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
@@ -238,13 +124,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
newWebSocket.close();
};
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
}, [api, deviceId, isNetworkConnected]);
const handleLibraryChanged = useCallback(
(data: any) => {
@@ -275,80 +157,47 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
[queryClient],
);
const handleUserDataChanged = useCallback(
(data: any) => {
// Jellyfin sends UserDataChanged when playback position, played status
// or favorites change (e.g. finishing an episode). Only the
// progression-based home sections care about it.
if (!((data?.UserDataList?.length ?? 0) > 0)) {
return;
}
// Finishing an item can emit several UserDataChanged messages, so
// debounce to invalidate the affected sections only once.
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
userDataChangeDebounceRef.current = setTimeout(() => {
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}
}, 800);
},
[queryClient],
);
// Refresh library-dependent queries when the server reports a change.
useEffect(
() => subscribe("LibraryChanged", handleLibraryChanged),
[subscribe, handleLibraryChanged],
);
// Refresh "Continue Watching" / "Next Up" when playback state changes.
useEffect(
() => subscribe("UserDataChanged", handleUserDataChanged),
[subscribe, handleUserDataChanged],
);
useEffect(() => {
if (!lastMessage) {
return;
}
if (lastMessage.MessageType === "Play") {
handlePlayCommand(lastMessage.Data);
} else if (lastMessage.MessageType === "LibraryChanged") {
handleLibraryChanged(lastMessage.Data);
}
}, [lastMessage, router, handleLibraryChanged]);
useEffect(() => {
return () => {
if (libraryChangeDebounceRef.current) {
clearTimeout(libraryChangeDebounceRef.current);
}
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
const handlePlayCommand = useCallback((data: any) => {
if (!data?.ItemIds?.length) {
return;
}
const handlePlayCommand = useCallback(
(data: any) => {
if (!data?.ItemIds?.length) {
return;
}
const itemId = data.ItemIds[0];
const itemId = data.ItemIds[0];
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
}, []);
// Server-initiated "Play me this item" remote command.
useEffect(
() => subscribe("Play", handlePlayCommand),
[subscribe, handlePlayCommand],
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
},
[router],
);
useEffect(() => {
@@ -418,14 +267,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []);
return (
<WebSocketContext.Provider
value={{
ws,
isConnected,
lastMessage,
subscribe,
sendMessage,
clearLastMessage,
}}
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
>
{children}
</WebSocketContext.Provider>

View File

@@ -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);

View File

@@ -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"
]
}

View File

@@ -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`,
);
}

View File

@@ -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:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"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.",
"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.",
@@ -261,6 +261,43 @@
"None": "None",
"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_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",
@@ -278,6 +315,25 @@
"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_title": "Other",
"video_orientation": "Video orientation",
@@ -295,6 +351,11 @@
"UNKNOWN": "Unknown"
},
"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_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries",
@@ -306,6 +367,9 @@
"max_auto_play_episode_count": "Max auto-play episode count",
"disabled": "Disabled"
},
"downloads": {
"downloads_title": "Downloads"
},
"music": {
"title": "Music",
"playback_title": "Playback",
@@ -320,6 +384,7 @@
"plugins": {
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Seerr URL",
@@ -348,18 +413,23 @@
"read_more_about_marlin": "Read more about Marlin.",
"save_button": "Save",
"toasts": {
"saved": "Saved"
}
"saved": "Saved",
"refreshed": "Settings refreshed from server"
},
"refresh_from_server": "Refresh settings from server"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read more about Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home sections",
"enable_movie_recommendations": "Movie recommendations",
"enable_series_recommendations": "Series recommendations",
"enable_promoted_watchlists": "Promoted watchlists",
@@ -375,7 +445,8 @@
"refresh_from_server": "Refresh settings from server"
},
"kefinTweaks": {
"watchlist_enabler": "Enable watchlist integration"
"watchlist_enabler": "Enable watchlist integration",
"watchlist_button": "Toggle watchlist integration"
}
},
"storage": {
@@ -386,6 +457,7 @@
"delete_all_downloaded_files": "Delete all downloaded files",
"music_cache_title": "Music cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable music cache",
"clear_music_cache": "Clear music cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
@@ -395,6 +467,8 @@
"clear_all_cache": "Clear all cache",
"clear_all_cache_confirm": "Clear all cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
@@ -416,12 +490,15 @@
"system": "System"
},
"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": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
@@ -441,7 +518,10 @@
"downloads_title": "Downloads",
"series": "Series",
"movies": "Movies",
"queue": "Queue",
"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",
"delete_all_movies_button": "Delete all movies",
"delete_all_series_button": "Delete all series",
@@ -466,8 +546,13 @@
"failed_to_delete_all_series": "Failed to delete all series",
"deleted_media_successfully": "Deleted other media successfully!",
"failed_to_delete_media": "Failed to delete other media",
"download_deleted": "Download deleted",
"download_cancelled": "Download cancelled",
"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_failed": "Download failed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
@@ -477,7 +562,10 @@
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All downloads deleted successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"all_files_folders_and_jobs_deleted_successfully": "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}}",
"go_to_downloads": "Go to Downloads",
"file_deleted": "{{item}} deleted"
}
}
@@ -495,17 +583,17 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"refresh": "Refresh",
"loading": "Loading..."
},
"search": {
"search": "Search...",
@@ -604,6 +692,10 @@
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}",
"next_episode": "Next episode",
"refresh_tracks": "Refresh tracks",
"audio_tracks": "Audio tracks:",
"playback_state": "Playback state:",
"index": "Index:",
"continue_watching": "Continue watching",
"go_back": "Go back",
"downloaded_file_title": "You have this file downloaded",
@@ -632,8 +724,7 @@
"stopPlayback": "Stop playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded",
"missing_parameters": "Missing playback parameters"
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
@@ -671,6 +762,7 @@
"show_more": "Show more",
"show_less": "Show less",
"left": "left",
"more_info": "More info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical details",
@@ -693,8 +785,7 @@
"resume_playback": "Resume playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from start",
"continue_from": "Continue from {{time}}",
"no_data_available": "No data available"
"continue_from": "Continue from {{time}}"
},
"live_tv": {
"next": "Next",
@@ -731,7 +822,11 @@
"report_issue_button": "Report issue",
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in",
"failed_to_login": "Failed to Login",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"cast": "Cast",
"details": "Details",
"status": "Status",
@@ -798,9 +893,13 @@
"playlists": "Playlists",
"tracks": "Songs"
},
"filters": {
"all": "All"
},
"recently_added": "Recently added",
"recently_played": "Recently played",
"frequently_played": "Frequently played",
"explore": "Explore",
"top_tracks": "Top songs",
"play": "Play",
"shuffle": "Shuffle",
@@ -934,6 +1033,7 @@
"pairing": {
"pair_with_phone": "Pair with phone",
"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...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",

18
utils/bToMb.ts Normal file
View 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`;
}

View 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;
};

View 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;
}

View File

@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
console.log("Downloading item directly");
return {
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null,
};
}

View 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");
}
};

View 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 [];
}
};

View 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}`);
}
};

View File

@@ -72,6 +72,21 @@ export const readFromLog = (): LogEntry[] => {
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() {
const context = useContext(LogContext);
if (context === null) {

5
utils/secondsToTicks.ts Normal file
View File

@@ -0,0 +1,5 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

View File

@@ -203,6 +203,27 @@ export async function hasAccountCredential(
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.
*/

View File

@@ -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,
};
}