Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
5d9fbf2839 Merge branch 'develop' into fix/series-next-up-cross-series 2026-06-07 13:57:49 +10:00
Alex Kim
d3808bc196 fix(series): filter Next Up items by SeriesId client-side
Some Jellyfin server versions ignore the seriesId query parameter on
/Shows/NextUp and return the global next-up list, causing the series
detail page to show episodes from unrelated series (matching the home
tab's Next Up row).

Defensively filter the response by SeriesId on the client and hide the
section entirely when there are no matching items, instead of rendering
an empty 'No items to display' block.
2026-06-06 00:18:06 +10:00
197 changed files with 7856 additions and 6493 deletions

29
.gitattributes vendored
View File

@@ -1,28 +1 @@
# Normalise line endings to LF for everyone. Files are stored as LF in git and
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
* text=auto eol=lf
# Windows-only scripts must stay CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary assets — never touched / never normalised
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.icns binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.mp3 binary
*.mp4 binary
*.mov binary
*.pdf binary
*.keystore binary
*.jks binary
*.p12 binary
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

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:

View File

@@ -1,21 +0,0 @@
name: Refresh PR build comment
description: >-
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
comment reflects live per-platform progress as each build job finishes.
runs:
using: composite
steps:
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
# artifact-comment's concurrency group collapses simultaneous nudges, so
# this can't spam the comment. Skipped on forks (their read-only token
# cannot dispatch). github.token is used because composite actions cannot
# read the secrets context.
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.head_ref }}
REPO: ${{ github.repository }}
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

64
.github/renovate.json vendored
View File

@@ -30,17 +30,9 @@
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
},
{
"customType": "regex",
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
"datasourceTemplate": "npm",
"depNameTemplate": "bun"
}
],
"customDatasources": {
@@ -52,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

@@ -18,7 +18,7 @@ jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-26.04
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,35 +184,21 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// Map our build targets to their job display names. Exact name is
// tried first so a signed target never collides with its
// "(Unsigned)" sibling (whose name contains the signed name).
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)'],
'Android TV': ['🤖 Build Android APK (TV)'],
'iOS': ['🍎 Build iOS IPA (Phone)'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
'tvOS': ['🍎 Build tvOS IPA'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
};
// Prefer an exact name match over a substring match so
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
const findJobForTarget = (jobs, jobNames) =>
jobs.find(j => jobNames.some(name => j.name === name)) ||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
// Format a millisecond duration as "Xm Ys".
const fmtDuration = (ms) => {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return `${min}m ${sec}s`;
};
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -243,8 +229,10 @@ jobs:
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = findJobForTarget(jobs.jobs, jobNames);
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -370,43 +358,6 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Pull per-job durations from the latest successful develop build so
// in-progress / queued targets can show a realistic ETA instead of
// an open-ended spinner. Best-effort: any failure just drops the ETA.
let referenceDurations = {};
try {
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build-apps.yml',
branch: 'develop',
status: 'success',
per_page: 1
});
if (devRuns.workflow_runs.length > 0) {
const refRun = devRuns.workflow_runs[0];
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: refRun.id
});
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = findJobForTarget(refJobs.jobs, jobNames);
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
}
}
console.log(`Reference durations from develop run ${refRun.id}:`,
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
} else {
console.log('No successful develop build found for ETA reference');
}
} catch (error) {
console.log('Failed to fetch develop reference durations:', error.message);
}
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -418,9 +369,9 @@ jobs:
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
@@ -436,12 +387,11 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// Signed tvOS stays disabled until EAS has tvOS provisioning
// profiles (app + TopShelf targets); non-interactive builds can't
// create them. Unsigned tvOS builds, so it flows through normally.
if (target.name === 'tvOS') {
// tvOS builds are temporarily disabled until feat/tv-interface
// is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
status = '💤 Disabled';
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
downloadLink = '*Disabled until feat/tv-interface is merged*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
@@ -456,9 +406,11 @@ jobs:
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
durationInfo = ` - ${fmtDuration(durationMs)}`;
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
@@ -468,16 +420,10 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
: '*Build in progress...*';
downloadLink = '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
: '*Waiting to start...*';
downloadLink = '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
@@ -498,22 +444,7 @@ jobs:
commentBody += `\n`;
// Static rundown of the build optimisations + what each artifact
// installs on. Always shown (even mid-build) so testers know what
// to expect before downloads are ready.
commentBody += `<details>\n`;
commentBody += `<summary>📦 Build details &amp; device compatibility</summary>\n\n`;
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
commentBody += `| Artifact | Architectures | Installs on |\n`;
commentBody += `|---|---|---|\n`;
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
commentBody += `</details>\n\n`;
// Installation instructions only matter once something is downloadable.
// Show installation instructions if we have any artifacts
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;

View File

@@ -11,102 +11,77 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → 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]'))
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
# sideload target). Overrides app.json buildArchs for this build only,
# so local `bun run android` (x86_64 emulator) is unaffected.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -120,94 +95,76 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -221,53 +178,34 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
@@ -277,12 +215,23 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 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
@@ -294,46 +243,34 @@ jobs:
path: build-*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
@@ -343,6 +280,12 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -358,57 +301,36 @@ jobs:
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-tv:
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build tvOS IPA
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
@@ -418,10 +340,23 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive --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
@@ -442,37 +377,28 @@ jobs:
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
@@ -482,6 +408,12 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
@@ -496,6 +428,3 @@ jobs:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment

View File

@@ -13,33 +13,30 @@ concurrency:
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: 🛡️ Verify lockfile consistency
run: |

View File

@@ -8,14 +8,11 @@ on:
schedule:
- cron: '24 2 * * *'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
@@ -27,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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

@@ -10,14 +10,14 @@ on:
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
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

@@ -1,51 +1,51 @@
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-26.04
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -15,23 +15,21 @@ jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.ts
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
on:
pull_request:
types: [opened, synchronize, reopened]
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
@@ -11,19 +11,47 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -37,41 +65,30 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-26.04
if: false
runs-on: ubuntu-24.04
steps:
- parallel:
- name: 🛒 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
- name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
# without failing the gate (some checks are known-noisy for this setup).
continue-on-error: true
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
@@ -80,29 +97,24 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- parallel:
- name: "📥 Checkout PR code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
- name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -12,7 +12,7 @@ on:
jobs:
notify:
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
@@ -29,7 +29,7 @@ jobs:
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure

View File

@@ -1,81 +0,0 @@
name: 📝 PR Title Comment
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
# read-only token on forks). workflow_run runs from the base branch with the base
# repo's token. This is comment-only — the required check stays pr-title.yml.
on:
workflow_run:
workflows: ["📝 PR Title"]
types: [completed]
permissions:
contents: read
actions: read # download the artifact from the triggering run
pull-requests: write # post/update/delete the sticky comment
concurrency:
group: pr-title-comment-${{ github.event.workflow_run.id }}
cancel-in-progress: true
jobs:
comment:
# Only run when the lint run actually produced a result artifact: a run
# cancelled by concurrency (rapid pushes to the same PR) completes with
# conclusion "cancelled" before uploading it, and would fail the download.
if: >
github.event.workflow_run.event == 'pull_request' &&
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-26.04
steps:
- name: ⬇️ Download lint result
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh run download "${{ github.event.workflow_run.id }}" \
--repo "${{ github.repository }}" \
--name pr-title-result \
--dir result
- name: 🔎 Read result
id: result
run: |
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
if [ -s result/error ]; then
echo "has_error=true" >> "$GITHUB_OUTPUT"
else
echo "has_error=false" >> "$GITHUB_OUTPUT"
fi
- name: 📥 Load error message
if: steps.result.outputs.has_error == 'true'
run: |
{
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
cat result/error
echo
echo "PR_TITLE_ERR_EOF"
} >> "$GITHUB_ENV"
- name: 💬 Post title error
if: steps.result.outputs.has_error == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ env.LINT_ERROR }}
```
- name: 🧹 Clear title error
if: steps.result.outputs.has_error == 'false'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
delete: true

View File

@@ -1,42 +0,0 @@
name: 📝 PR Title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-26.04
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
# off the result (PR number + lint error) as an artifact for it to read.
- if: always()
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
run: |
mkdir -p pr-title-result
printf '%s' "$PR_NUMBER" > pr-title-result/number
printf '%s' "$LINT_ERROR" > pr-title-result/error
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-title-result
path: pr-title-result/
retention-days: 1

View File

@@ -13,8 +13,6 @@ name: 🚀 Release (EAS build + submit)
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Queue successive releases in order instead of dropping the extra pending run.
queue: max
on:
push:
@@ -24,9 +22,8 @@ on:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
environment: production
permissions: {}
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
@@ -34,7 +31,7 @@ jobs:
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
@@ -65,40 +62,38 @@ jobs:
artifact_name: streamyfin-android-tv-apk
steps:
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials
@@ -181,13 +176,13 @@ jobs:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
show-progress: false

View File

@@ -1,50 +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-26.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Trivy's own action caches the vulnerability DB + binary internally
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
# step is needed — it only duplicated the cache.
- 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,104 +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-26.04
update-bug-report:
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.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
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
cache: 'npm'
- 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: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with:
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: 📬 Create pull request
id: cpr
- 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: ⚙️ 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 }})

15
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
@@ -12,12 +13,18 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Native module build outputs (any module)
modules/*/android/build/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
# Generated Applications
Streamyfin.app
@@ -62,6 +69,10 @@ certs/
# Version and Backup Files
/version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

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

@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin

29
app.config.js Normal file
View File

@@ -0,0 +1,29 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
};

View File

@@ -1,79 +0,0 @@
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// 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: string[]): string | null => {
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(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") {
config.plugins?.push("expo-background-task");
config.plugins?.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins?.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
@@ -107,9 +107,6 @@
],
"expo-localization",
"expo-asset",
"expo-audio",
"expo-image",
"expo-sharing",
[
"react-native-edge-to-edge",
{
@@ -134,17 +131,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.ts",
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -161,7 +161,9 @@ export default function FavoritesSeeAllScreen() {
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
@@ -192,7 +194,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items")}
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}

View File

@@ -137,12 +137,12 @@ export default function DownloadsPage() {
deleteFileByType("Episode")
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_series_successfully"),
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteOtherMedia = () =>
Promise.all(
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.series")}
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_series_button")}
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>

View File

@@ -179,15 +179,18 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm"),
t("home.settings.storage.clear_all_cache_confirm_desc"),
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[
{
text: t("common.cancel"),
text: t("common.cancel", "Cancel"),
style: "cancel",
},
{
text: t("common.ok"),
text: t("common.ok", "OK"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
@@ -240,8 +243,11 @@ export default function SettingsTV() {
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files"),
t("home.settings.storage.clear_all_cache_error_desc"),
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
);
}
},

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -23,7 +23,6 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -135,12 +134,6 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
// Collections open with a clean filter slate: the genre/year/tag atoms are
// global, so without this the previously viewed library's selection bleeds
// in (libraries now keep their own per-library memory).
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
if (!collection) return;
@@ -211,39 +204,40 @@ const page: React.FC = () => {
],
);
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
@@ -332,7 +326,7 @@ const page: React.FC = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton libraryId={collectionId} />,
component: <ResetFiltersButton />,
},
{
key: "genre",
@@ -472,6 +466,7 @@ const page: React.FC = () => {
setSortBy,
sortOrder,
setSortOrder,
isFetching,
],
);
@@ -638,45 +633,43 @@ const page: React.FC = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FilterSheetProvider>
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

@@ -9,19 +9,14 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
BackHandler,
FlatList,
Platform,
ScrollView,
@@ -35,7 +30,6 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -45,7 +39,6 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -57,9 +50,7 @@ import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
getFilterByPreference,
getMultiFilterPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -70,12 +61,11 @@ import {
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -90,9 +80,8 @@ const Page = () => {
sortBy?: string;
sortOrder?: string;
filterBy?: string;
fromSeeAll?: string;
};
const { libraryId, fromSeeAll } = searchParams;
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
@@ -113,9 +102,6 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation();
@@ -126,22 +112,6 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
// When this library detail was opened from the home "See All" button, its
// libraries stack is just [detail], so the default TV Back would exit to home.
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
// route to the library list instead, so the user can switch libraries. Normal
// entries from the list keep their native pop-to-list behavior.
useFocusEffect(
useCallback(() => {
if (!Platform.isTV || fromSeeAll !== "true") return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
router.replace("/(auth)/(tabs)/(libraries)");
return true;
});
return () => sub.remove();
}, [fromSeeAll, router]),
);
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
@@ -213,13 +183,6 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
}
// Genres / years / tags: per-library saved preference (no URL params), so
// switching libraries restores each library's own selection instead of
// bleeding the previous one.
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
}, [
libraryId,
sortOrderPreference,
@@ -228,12 +191,6 @@ const Page = () => {
_setSortBy,
filterByPreference,
_setFilterBy,
genrePreference,
yearPreference,
tagPreference,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
@@ -278,32 +235,6 @@ const Page = () => {
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
// Genres / years / tags: save the per-library memory then update the active
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
const setGenres = useCallback(
(genres: string[]) => {
setGenrePreference({ ...genrePreference, [libraryId]: genres });
setSelectedGenres(genres);
},
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
);
const setYears = useCallback(
(years: string[]) => {
setYearPreference({ ...yearPreference, [libraryId]: years });
setSelectedYears(years);
},
[libraryId, yearPreference, setYearPreference, setSelectedYears],
);
const setTags = useCallback(
(tags: string[]) => {
setTagPreference({ ...tagPreference, [libraryId]: tags });
setSelectedTags(tags);
},
[libraryId, tagPreference, setTagPreference, setSelectedTags],
);
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
@@ -338,23 +269,6 @@ const Page = () => {
});
}, [library]);
// If this See-All detail was deep-linked on top of the libraries index, collapse
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
// which the native bottom tab reliably auto-pops back to the index (the detail
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
const didCollapseRef = useRef(false);
useEffect(() => {
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
return;
const state = navigation.getState();
if (state?.routes && state.routes.length > 1) {
didCollapseRef.current = true;
const top = state.routes[state.routes.length - 1];
navigation.reset({ index: 0, routes: [top] } as any);
}
}, [navigation, fromSeeAll]);
const fetchItems = useCallback(
async ({
pageParam,
@@ -462,29 +376,6 @@ const Page = () => {
);
}, [data]);
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
// Jump the grid to the top when the filters/sort change (incl. reset).
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
const pendingScrollTopRef = useRef(false);
// Instant feedback: pin to the top the moment the filters change, without
// waiting for the new fetch — and flag a re-pin for once it settles.
useEffect(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
pendingScrollTopRef.current = true;
}, [filterSignature]);
// Safety net: FlashList can restore the previous offset as the filtered list
// grows, so re-pin once the fetch settles. Pagination keeps the same
// signature, so it never re-pins.
useEffect(() => {
if (pendingScrollTopRef.current && !isFetching) {
pendingScrollTopRef.current = false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
}, [isFetching, flatData]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
@@ -600,6 +491,7 @@ const Page = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
<FlatList
@@ -614,7 +506,7 @@ const Page = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton libraryId={libraryId} />,
component: <ResetFiltersButton />,
},
{
key: "genre",
@@ -633,7 +525,7 @@ const Page = () => {
});
return response.data.Genres || [];
}}
set={setGenres}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
@@ -660,7 +552,7 @@ const Page = () => {
});
return response.data.Years || [];
}}
set={setYears}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
@@ -685,7 +577,7 @@ const Page = () => {
});
return response.data.Tags || [];
}}
set={setTags}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
@@ -765,23 +657,35 @@ const Page = () => {
api,
user?.Id,
selectedGenres,
setGenres,
setSelectedGenres,
selectedYears,
setYears,
setSelectedYears,
selectedTags,
setTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
// sort/order can't be forgotten on one path (it used to be reset on neither).
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
@@ -875,15 +779,15 @@ const Page = () => {
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setGenres([]);
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setGenres(selectedGenres.filter((g) => g !== value));
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setGenres([...selectedGenres, value]);
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
@@ -891,15 +795,15 @@ const Page = () => {
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setYears([]);
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setYears(selectedYears.filter((y) => y !== value));
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setYears([...selectedYears, value]);
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
@@ -907,15 +811,15 @@ const Page = () => {
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setTags([]);
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setTags(selectedTags.filter((tag) => tag !== value));
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setTags([...selectedTags, value]);
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
@@ -963,45 +867,42 @@ const Page = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FilterSheetProvider>
<FlashList
ref={flashListRef}
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

@@ -305,8 +305,6 @@ export default function SearchPage() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: color of the user-typed text (was dark and unreadable on the dark header)
textColor: "#fff",
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",

View File

@@ -3,24 +3,16 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { Stack, useSegments, withLayoutContext } from "expo-router";
import { withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap
>(Navigator);
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
function TVTabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
const segments = useSegments();
const router = useRouter();
const currentTab = segments.find(isTabRoute);
const lastSegment = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo(
() =>
[
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
: { key: "(custom-links)", label: t("tabs.custom_links") },
{ key: "(settings)", label: t("tabs.settings") },
].filter((tab): tab is TVNavBarTab => tab !== null),
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.showCustomMenuLinks,
t,
],
);
const activeTabKey = currentTab ?? "(home)";
const visibleKeys = useMemo(
() => new Set(tabs.map((tab) => tab.key)),
[tabs],
);
const handleTabChange = useCallback(
(key: string) => {
if (key === currentTab) return;
if (key === "(home)") eventBus.emit("scrollToTop");
if (key === "(search)") eventBus.emit("searchTabPressed");
router.replace(`/(auth)/(tabs)/${key}`);
},
[currentTab, router],
);
const navigateHome = useCallback(() => {
router.replace("/(auth)/(tabs)/(home)");
}, [router]);
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
// If current tab is no longer visible (setting changed), navigate to home
useEffect(() => {
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
router.replace("/(auth)/(tabs)/(home)");
}
}, [visibleKeys, activeTabKey, router]);
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<Stack
screenOptions={{ headerShown: false, animation: "none" }}
initialRouteName='(home)'
>
<Stack.Screen name='index' redirect />
</Stack>
<TVNavBar
tabs={tabs}
activeTabKey={activeTabKey}
onTabChange={handleTabChange}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
</View>
);
}
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Must be called before any conditional return (rules of hooks)
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />

View File

@@ -456,23 +456,10 @@ export default function DirectPlayerPage() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
// Synchronously destroy the mpv instance + decoder + surface buffers
// BEFORE the screen unmounts. Otherwise the next screen (or the next
// episode's player) mounts while the old 4K decoder is still alive,
// causing OOM on low-RAM devices. Native stop() is idempotent so the
// later React unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
// Pre-libmpv-1.0 used `stop()`:
// videoRef.current?.stop();
videoRef.current?.pause();
revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
// Release the keep-awake wakelock acquired during playback so it
// doesn't follow us back to the home screen and block the TV
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
// and only released on the "paused" event; without this, navigating
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
@@ -893,27 +880,6 @@ export default function DirectPlayerPage() {
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// A transcoded stream only carries the audio track the server encoded
// into it — switching requires re-negotiating the stream with the new
// index (like the mobile menu's replacePlayer), not an mpv aid change.
if (isTranscoding) {
const queryParams = new URLSearchParams({
itemId: item?.Id ?? "",
audioIndex: String(index),
subtitleIndex: String(currentSubtitleIndex),
mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(),
}).toString();
// Destroy the current mpv instance BEFORE navigating, same rationale as
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
// during the transition, and two simultaneous decoders OOM-kill low-RAM
// devices. Resume is preserved via the playbackPosition param.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
return;
}
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
@@ -925,14 +891,7 @@ export default function DirectPlayerPage() {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[
stream?.mediaSource,
item?.Id,
currentSubtitleIndex,
bitrateValue,
router,
progress,
],
[stream?.mediaSource],
);
// TV subtitle track change handler
@@ -1146,15 +1105,6 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// Destroy the current mpv instance BEFORE navigating so the old 4K
// decoder + surface buffers are freed before the new player screen
// mounts. Without this, Expo Router briefly holds two simultaneous
// mpv instances during the transition (~768 MB of surface buffers
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
// devices. Native stop() is idempotent so the subsequent React
// unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
@@ -1165,7 +1115,6 @@ export default function DirectPlayerPage() {
bitrateValue,
router,
isPlaybackStopped,
videoRef,
]);
// Apply subtitle settings when video loads
@@ -1318,7 +1267,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occurred_while_playing_the_video"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
InteractionManager,
ScrollView,
StyleSheet,
TVFocusGuideView,
@@ -76,20 +75,6 @@ export default function TVOptionModal() {
}, [isReady]);
const handleSelect = (value: any) => {
if (modalState?.deferApplyUntilDismissed) {
// onSelect navigates (the transcode audio switch replacing the player);
// a router.replace fired while this modal is the active route would be
// swallowed. Close FIRST, apply after dismissal.
const onSelect = modalState.onSelect;
store.set(tvOptionModalAtom, null);
router.back();
InteractionManager.runAfterInteractions(() => onSelect?.(value));
return;
}
// State-only callers (detail page, library filters, settings): run before
// closing so the re-render happens while the modal is up. Deferring it until
// after dismissal re-renders the page after focus returns and yanks TV
// focus, leaving navigation stuck.
modalState?.onSelect(value);
store.set(tvOptionModalAtom, null);
router.back();

View File

@@ -7,7 +7,6 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
@@ -85,8 +84,7 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -102,22 +100,6 @@ SplashScreen.setOptions({
fade: true,
});
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
try {
Image.configureCache({
maxMemoryCost: Platform.isTV
? 8 * 1024 * 1024 // ~8 MB on TV
: 128 * 1024 * 1024, // ~128 MB on mobile
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
});
} catch {
// configureCache is a no-op on some platforms/versions; safe to ignore.
}
function useNotificationObserver() {
const router = useRouter();
@@ -351,12 +333,9 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log(
"Notification received while app running:",
notification.request.content.title,
"Notification received while app running",
notification,
);
},
);

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

458
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -62,7 +61,6 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
@@ -94,10 +92,7 @@ export const BitrateSheet: React.FC<Props> = ({
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -108,7 +103,6 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}

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

@@ -1,9 +1,8 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -24,7 +23,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -46,10 +44,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
@@ -58,7 +53,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")}
data={item.MediaSources || []}
values={selected ? [selected] : []}

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

99
components/TrackSheet.tsx Normal file
View File

@@ -0,0 +1,99 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
streamType?: string;
title: string;
}
export const TrackSheet: React.FC<Props> = ({
source,
onChange,
selected,
streamType,
title,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source],
);
const selectedSteam = useMemo(
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => {
const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index);
}
}}
/>
</View>
);
};

View File

@@ -1,119 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { Platform, View, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
const isAggregateType = (item: BaseItemDto) =>
item.Type === "Series" || item.Type === "BoxSet";
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
const tvBadgeBase: ViewStyle = {
position: "absolute",
top: scaleSize(8),
right: scaleSize(8),
height: scaleSize(28),
borderRadius: scaleSize(14),
backgroundColor: "rgba(255,255,255,0.92)",
alignItems: "center",
justifyContent: "center",
};
// Mobile uses raw dp — no scaling.
const mobileBadgeBase: ViewStyle = {
position: "absolute",
top: 4,
right: 4,
height: 20,
borderRadius: 10,
backgroundColor: "#9333ea",
alignItems: "center",
justifyContent: "center",
};
/**
* Renders the unplayed-episode count badge for Series/BoxSet items that still
* have episodes left to watch. Returns null for non-aggregate types, fully
* watched items, or items with no unplayed count, so it is safe to mount
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
* the watched checkmark is drawn natively and only the count needs RN).
*/
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
if (!isAggregateType(item)) return null;
if (item.UserData?.Played) return null;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
if (unplayed <= 0) return null;
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
if (Platform.isTV) {
return (
<View
style={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{label}
</Text>
</View>
);
}
return (
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
{label}
</Text>
</View>
);
},
);
import type React from "react";
import { Platform, View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
const isAggregate = isAggregateType(item);
const isPlayed = item.UserData?.Played === true;
if (Platform.isTV) {
// Fully watched → white checkmark badge (top-right)
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
return (
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
);
}
// Series/BoxSet with remaining episodes → count badge
return <UnplayedCountBadge item={item} />;
return null;
}
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
// Mobile: Show purple triangle for unwatched
return (
<>
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
{isMovieOrEpisode && item.UserData?.Played === false && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Fully watched Series/BoxSet → small purple checkmark */}
{isAggregate && isPlayed && (
<View style={[mobileBadgeBase, { width: 20 }]}>
<Ionicons name='checkmark' size={13} color='white' />
</View>
)}
{/* Series/BoxSet with remaining episodes → count badge */}
<UnplayedCountBadge item={item} />
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
</>
);
};

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

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>

View File

@@ -1,11 +1,9 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
import { useFilterSheet } from "./FilterSheetProvider";
interface FilterButtonProps<T> extends ViewProps {
id: string;
@@ -35,63 +33,22 @@ export const FilterButton = <T,>({
icon = "filter",
...props
}: FilterButtonProps<T>) => {
// When a FilterSheetProvider is present (library / collections), all buttons
// share one sheet so two can never stack. Outside a provider (e.g. logs,
// discover), fall back to this button's own standalone sheet.
const shared = useFilterSheet();
const [open, setOpen] = useState(false);
const { data: filters, isLoading } = useQuery<T[]>({
const { data: filters } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
// Standalone-mode state (unused in shared mode).
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const onButtonPress = useCallback(() => {
if (shared) {
shared.openFilter({
key: `${id}:${queryKey}`,
id,
queryKey,
queryFn,
title,
values: values as unknown[],
set: set as (value: unknown[]) => void,
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
searchFilter: searchFilter as
| ((item: unknown, query: string) => boolean)
| undefined,
disableSearch,
multiple,
});
return;
}
// present() must run from the press handler: from an effect after a state
// update it silently no-ops on the new architecture and the sheet never
// appears.
setOpen(true);
sheetModalRef.current?.present();
}, [
shared,
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch,
multiple,
]);
return (
<>
<TouchableOpacity onPress={onButtonPress}>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -128,22 +85,18 @@ export const FilterButton = <T,>({
)}
</View>
</TouchableOpacity>
{!shared && (
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
)}
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
</>
);
};

View File

@@ -7,14 +7,7 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
@@ -26,21 +19,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -83,18 +66,16 @@ const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
loading = false,
open,
set,
setOpen,
modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = modalRef;
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -103,24 +84,19 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => {
if (!deferredSearch) return _data;
if (!search) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
if (_data && searchFilter?.(_data[i], search)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
}, [search, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -151,28 +127,21 @@ export const FilterSheet = <T,>({
setData(newData);
}, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (!open && wasPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
}
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
if (index === -1) {
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
if (search.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
}, [search, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -185,54 +154,6 @@ export const FilterSheet = <T,>({
[],
);
// Memoized so typing in the search input (urgent render with an unchanged
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
// Match the deep-equality rule used to render the selected
// state below — option objects are recreated across renders,
// so reference checks would re-add an already selected item.
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!isSelected) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
)),
[renderData, values, multiple, set, setOpen, renderItemLabel],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
}}
>
<Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{showSearch && (
<Input
placeholder={t("search.search")}
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
{renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
if (multiple) {
if (!values.includes(item)) set(values.concat(item));
else set(values.filter((v) => v !== item));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
))}
</View>
{data.length < (_data?.length || 0) && (
<Button

View File

@@ -1,74 +0,0 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
interface FilterSheetContextType {
openFilter: (config: FilterConfig) => void;
}
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
/**
* Returns the shared-sheet controller, or null when rendered outside a
* FilterSheetProvider — FilterButton then falls back to its own standalone
* sheet (used by screens that don't host a provider, e.g. logs / discover).
*/
export const useFilterSheet = (): FilterSheetContextType | null =>
useContext(FilterSheetContext);
/**
* Hosts the single shared filter sheet for a screen. Every FilterButton under
* it calls openFilter() to show its options in that one sheet — so two sheets
* can never stack regardless of how fast the buttons are tapped. present() runs
* synchronously from the button's press handler (the modal is always mounted).
*/
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const modalRef = useRef<BottomSheetModal | null>(null);
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<FilterConfig | null>(null);
// First-wins guard. With a single shared sheet there is exactly one source of
// truth (this ref) reset on the one close path — so unlike a per-button guard
// it can't get stuck on remounts or multiple instances. A second tap during
// the first sheet's open animation is ignored; the first tapped filter wins.
const openRef = useRef(false);
const openFilter = useCallback((next: FilterConfig) => {
if (openRef.current) return;
openRef.current = true;
setConfig(next);
setOpen(true);
modalRef.current?.present();
}, []);
// Single close path for every dismissal (select / swipe / backdrop) — frees
// the guard reliably.
const closeSheet = useCallback(() => {
openRef.current = false;
setOpen(false);
}, []);
const value = useMemo(() => ({ openFilter }), [openFilter]);
return (
<FilterSheetContext.Provider value={value}>
{children}
<SharedFilterSheet
modalRef={modalRef}
open={open}
setOpen={closeSheet}
config={config}
/>
</FilterSheetContext.Provider>
);
};

View File

@@ -1,24 +1,38 @@
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFilterReset } from "@/hooks/useFilterReset";
import {
filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
interface Props extends TouchableOpacityProps {
libraryId: string;
}
interface Props extends TouchableOpacityProps {}
export const ResetFiltersButton: React.FC<Props> = ({
libraryId,
...props
}) => {
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
if (!hasActiveFilters) {
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0 &&
selectedFilters.length === 0
) {
return null;
}
return (
<TouchableOpacity
onPress={resetAllFilters}
onPress={() => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
setSelectedFilters([]);
}}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
>

View File

@@ -1,286 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
/**
* Config for the filter currently shown by the shared sheet. Generics are erased
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
*/
export interface FilterConfig {
/** Stable identity — changing it remounts the content with fresh state. */
key: string;
id: string;
queryKey: string;
queryFn: (params: any) => Promise<any>;
title: string;
values: any[];
set: (value: any[]) => void;
renderItemLabel: (item: any) => React.ReactNode;
searchFilter?: (item: any, query: string) => boolean;
disableSearch?: boolean;
multiple?: boolean;
}
const LIMIT = 100;
interface SharedFilterSheetProps {
modalRef: React.RefObject<BottomSheetModal | null>;
open: boolean;
setOpen: (open: boolean) => void;
config: FilterConfig | null;
}
/**
* The single shared filter sheet — one BottomSheetModal hosted by
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
* Because only one modal ever exists, rapid taps across buttons can never stack
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
* stable ref (present() can run synchronously from the tapping button); the
* inner content is keyed by the active filter so its pagination/search reset
* cleanly between filters.
*/
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
modalRef,
open,
setOpen,
config,
}) => {
const snapPoints = useMemo(() => ["85%"], []);
const insets = useSafeAreaInsets();
// Opening is imperative (the provider calls present()); this effect only
// closes, and never dismisses a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (!open && wasPresentedRef.current) {
modalRef.current?.dismiss();
}
}, [open, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
},
[setOpen],
);
const requestClose = useCallback(() => setOpen(false), [setOpen]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={modalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
>
<BottomSheetScrollView style={{ flex: 1 }}>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{config && (
<SharedFilterSheetContent
key={config.key}
config={config}
onRequestClose={requestClose}
/>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};
interface SharedFilterSheetContentProps {
config: FilterConfig;
onRequestClose: () => void;
}
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
config,
onRequestClose,
}) => {
const {
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
} = config;
const { t } = useTranslation();
// The options query lives here (deduped with the FilterButton's own query via
// the shared React Query key), so the list stays live after the sheet opens.
const { data: _data, isLoading: loading } = useQuery<any[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
const [data, setData] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
// Filtering on every keystroke blocks the JS thread on large lists; defer the
// value so the keystroke render stays cheap and the list update runs after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState(false);
const filteredData = useMemo(() => {
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
if (data.length > 15) {
setShowSearch(true);
}
}, [data, disableSearch]);
// Loads data in batches of LIMIT from offset (efficient "load more").
useEffect(() => {
if (!_data || _data.length === 0) return;
const newData = [...data];
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
const item = _data[i];
// Option objects are recreated across renders → dedupe by value.
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
}
setData(newData);
}, [offset, _data]);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => onRequestClose(), 250);
} else if (!isSelected) {
set([item]);
setTimeout(() => onRequestClose(), 250);
}
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{ height: StyleSheet.hairlineWidth }}
className='h-1 divide-neutral-700'
/>
</View>
)),
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
);
return (
<>
<Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
className='my-2 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
<View
style={{ borderRadius: 20, overflow: "hidden" }}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
)}
</>
);
};

View File

@@ -140,11 +140,9 @@ export const Home = () => {
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
// decoded ARGB) is too large to pin in the memory cache on every
// focus change. Disk cache is fast enough for a 500ms crossfade.
// Prefetch the image before starting the crossfade
try {
await Image.prefetch(backdropUrl, "disk");
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}

View File

@@ -201,18 +201,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const handleSeeAllPress = useCallback(() => {
if (!parentId) return;
// Navigate into the library detail (lives in the libraries tab) sorted by most
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
// the libraries stack so the native tab can't auto-pop it back to the list, and
// (b) intercept Back to route to the library list so the user can switch
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
router.push({
pathname: "/[libraryId]",
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: {
libraryId: parentId,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
fromSeeAll: "true",
},
} as any);
}, [router, parentId]);
@@ -332,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={4}
maxToRenderPerBatch={2}
windowSize={3}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
@@ -354,14 +348,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
// No fixed width: the footer must size to the "See All" card so the
// FlatList's scrollable content extends to fully reveal it. A fixed
// (narrow) width clipped the card at the right edge. Trailing space is
// provided by contentContainerStyle.paddingRight.
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (

View File

@@ -256,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
let isCancelled = false;
const performCrossfade = async () => {
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
// out of the memory cache avoids bloat when the user cycles through
// hero items quickly.
try {
await Image.prefetch(backdropUrl, "disk");
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
@@ -382,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = scaleSize(145);
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (

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

@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});

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

@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
let isCancelled = false;
const performCrossfade = async () => {
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
// Prefetch the image before starting the crossfade
try {
await Image.prefetch(backdropUrl, "disk");
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}

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

@@ -1,8 +1,8 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps {
item: BaseItemDto;

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

@@ -3,7 +3,6 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
<WatchedIndicator item={item} />
</View>
);
};

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
TextInput fallback on Android TV (the native module is Apple-only). */}
{Platform.OS === "ios" ? (
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
</View>
<ScrollView

View File

@@ -3,6 +3,7 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -33,13 +34,16 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
staleTime: 0,
});
if (!items?.length)
return (
<View className='px-4'>
<Text className='text-lg font-bold mb-2'>{t("item_card.next_up")}</Text>
<Text className='opacity-50'>{t("item_card.no_items_to_display")}</Text>
</View>
);
// Defensive client-side filter: some Jellyfin server versions ignore the
// `seriesId` query param on /Shows/NextUp and return next-up items across all
// series (the same content as the home tab's Next Up row). Filter to ensure
// we only ever show episodes belonging to this series.
const filteredItems = useMemo(
() => items?.filter((item) => item.SeriesId === seriesId) ?? [],
[items, seriesId],
);
if (!filteredItems.length) return null;
return (
<View>
@@ -50,7 +54,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
showsHorizontalScrollIndicator={false}
data={items}
data={filteredItems}
renderItem={({ item, index }) => (
<TouchableItemRouter
item={item}

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

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_authorized"),
t("home.settings.quick_connect.quick_connect_autorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {

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

View File

@@ -1,155 +0,0 @@
import React from "react";
import {
Animated,
Pressable,
ScrollView,
StyleProp,
View,
ViewStyle,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVPadding } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVNavBarTab {
key: string;
label: string;
}
export interface TVNavBarProps {
tabs: TVNavBarTab[];
activeTabKey: string;
onTabChange: (key: string) => void;
style?: StyleProp<ViewStyle>;
}
const TVNavBarTabItem: React.FC<{
label: string;
isActive: boolean;
onSelect: () => void;
onLayout: (e: {
nativeEvent: { layout: { x: number; width: number } };
}) => void;
hasTVPreferredFocus: boolean;
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const bg = focused
? "rgba(255, 255, 255, 0.95)"
: isActive
? "rgba(255, 255, 255, 0.15)"
: "transparent";
const textColor = focused
? "#000"
: isActive
? "#fff"
: "rgba(255, 255, 255, 0.7)";
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
onLayout={onLayout}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: bg,
borderRadius: scaleSize(24),
borderWidth: isActive && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.3)",
paddingHorizontal: scaleSize(28),
paddingVertical: scaleSize(14),
},
]}
>
<Text
style={{
fontSize: typography.heading,
color: textColor,
fontWeight: isActive || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVNavBar: React.FC<TVNavBarProps> = ({
tabs,
activeTabKey,
onTabChange,
style,
}) => {
const scrollRef = React.useRef<ScrollView>(null);
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
{},
);
const insets = useSafeAreaInsets();
const handleTabLayout = React.useCallback(
(key: string) =>
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
tabLayouts.current[key] = e.nativeEvent.layout;
},
[],
);
const handleTabChange = React.useCallback(
(key: string) => {
onTabChange(key);
const layout = tabLayouts.current[key];
if (layout && scrollRef.current) {
scrollRef.current.scrollTo({
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
animated: true,
});
}
},
[onTabChange],
);
if (tabs.length === 0) return null;
return (
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
gap: scaleSize(12),
}}
>
{tabs.map((tab) => (
<TVNavBarTabItem
key={tab.key}
label={tab.label}
isActive={tab.key === activeTabKey}
onSelect={() => handleTabChange(tab.key)}
onLayout={handleTabLayout(tab.key)}
hasTVPreferredFocus={tab.key === activeTabKey}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -12,10 +12,7 @@ import {
} from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
@@ -430,12 +427,6 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
{/*
The glass view draws the watched checkmark natively but cannot show
an unplayed-episode count, so render it as an RN overlay on top.
Returns null when not applicable (non-series / fully watched).
*/}
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
</View>
);
}
@@ -457,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<Image
placeholder={{ blurhash }}
key={item.Id}
id={item.Id}
source={{ uri: imageUrl }}
recyclingKey={item.Id}
cachePolicy='memory-disk'
contentFit='cover'
style={{
@@ -468,7 +459,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
{showWatchedIndicator && <WatchedIndicator item={item} />}
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);

View File

@@ -35,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";

View File

@@ -564,9 +564,6 @@ export const Controls: FC<Props> = ({
title: t("item_card.audio"),
options: audioOptions,
onSelect: handleAudioChange,
// In-player audio selection navigates (replacePlayer while transcoding);
// apply it after the modal is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
});
controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]);

View File

@@ -0,0 +1,560 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVLanguageCard,
TVSubtitleResultCard,
TVTabButton,
TVTrackCard,
} from "@/components/tv";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
visible: boolean;
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: MediaStream[];
currentSubtitleIndex: number;
onSubtitleIndexChange: (index: number) => void;
onClose: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
}
type TabType = "tracks" | "download";
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleIndexChange,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
console.log(
"[TVSubtitleSheet] visible:",
visible,
"tracks:",
subtitleTracks.length,
);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(null);
const {
hasOpenSubtitlesApiKey,
isSearching,
searchError,
searchResults,
search,
downloadAsync,
reset,
} = useRemoteSubtitles({
itemId: item.Id ?? "",
item,
mediaSourceId,
});
const resetRef = useRef(reset);
resetRef.current = reset;
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (!visible) {
setHasSearchedThisSession(false);
setActiveTab("tracks");
resetRef.current();
setIsReady(false);
}
}, [visible]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (visible && activeTab === "download" && !hasSearchedThisSession) {
search({ language: selectedLanguage });
setHasSearchedThisSession(true);
}
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
useEffect(() => {
if (isReady) {
setIsTabContentReady(false);
const timer = setTimeout(() => setIsTabContentReady(true), 50);
return () => clearTimeout(timer);
}
setIsTabContentReady(false);
}, [activeTab, isReady]);
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
search({ language: code });
},
[search],
);
const handleTrackSelect = useCallback(
(index: number) => {
onSubtitleIndexChange(index);
onClose();
},
[onSubtitleIndexChange, onClose],
);
const handleDownload = useCallback(
async (result: SubtitleSearchResult) => {
setDownloadingId(result.id);
try {
const downloadResult = await downloadAsync(result);
if (downloadResult.type === "server") {
onServerSubtitleDownloaded?.();
} else if (downloadResult.type === "local" && downloadResult.path) {
onLocalSubtitleDownloaded?.(downloadResult.path);
}
onClose();
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {
setDownloadingId(null);
}
},
[
downloadAsync,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
onClose,
],
);
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
const trackOptions = useMemo(() => {
const noneOption = {
label: t("item_card.subtitles.none"),
sublabel: undefined as string | undefined,
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
value: track.Index!,
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<TVLanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<TVSubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
{/* Cancel button */}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={onClose}
label={t("common.cancel") || "Cancel"}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
maxHeight: "70%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 48,
},
header: {
paddingHorizontal: 48,
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
tabRow: {
flexDirection: "row",
gap: 24,
},
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
},
tracksScroll: {
overflow: "visible",
},
tracksScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.6)",
marginTop: 12,
fontSize: 14,
},
errorContainer: {
paddingVertical: 40,
paddingHorizontal: 48,
alignItems: "center",
},
errorText: {
color: "rgba(255,100,100,0.9)",
marginTop: 8,
fontSize: 16,
fontWeight: "500",
},
errorHint: {
color: "rgba(255,255,255,0.5)",
marginTop: 4,
fontSize: 13,
textAlign: "center",
},
emptyContainer: {
paddingVertical: 40,
alignItems: "center",
},
emptyText: {
color: "rgba(255,255,255,0.5)",
marginTop: 8,
fontSize: 14,
},
apiKeyHint: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 48,
paddingTop: 8,
},
apiKeyHintText: {
color: "rgba(255,255,255,0.4)",
fontSize: 12,
},
cancelButtonContainer: {
paddingHorizontal: 48,
paddingTop: 20,
alignItems: "flex-start",
},
});

View File

@@ -342,12 +342,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.cacheSeconds !== undefined && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
{info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
")"
: ""}
</Text>
)}
{info?.voDriver && (
@@ -356,12 +350,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames

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

@@ -3,13 +3,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
const MediaTypes = {
export default {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;
};

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -52,7 +52,7 @@
}
},
"production": {
"bun": "1.3.14",
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -64,7 +64,7 @@
}
},
"production-apk": {
"bun": "1.3.14",
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -74,7 +74,7 @@
}
},
"production-apk-tv": {
"bun": "1.3.14",
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -87,7 +87,7 @@
}
},
"production_tv": {
"bun": "1.3.14",
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"env": {
@@ -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 };
};

View File

@@ -1,108 +0,0 @@
import { useAtom } from "jotai";
import { useCallback } from "react";
import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortByPreferenceAtom,
sortOrderAtom,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
/**
* Single source of truth for the library filter bar's "reset" action and its
* visibility. The mobile ResetFiltersButton and the TV filter header both use
* this so they can't drift — sort/order used to be reset on neither path, so
* the reset (X) never reflected a changed sort.
*
* A reset clears the session filters AND the per-library in-memory preferences
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
* resurfaces when the library's mount effect re-applies it on the next entry.
*/
export const useFilterReset = (libraryId: string) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [filterBy, setFilterBy] = useAtom(filterByAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
const [, setYearPreference] = useAtom(yearPreferenceAtom);
const [, setTagPreference] = useAtom(tagPreferenceAtom);
// SortName / Ascending is the baseline a library opens with (mount-effect
// fallback), so any other value counts as an active, resettable sort.
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0 ||
sortBy[0] !== SortByOption.SortName ||
sortOrder[0] !== SortOrderOption.Ascending;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
setFilterBy([]);
setSortBy([SortByOption.SortName]);
setSortOrder([SortOrderOption.Ascending]);
setSortByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setSortOrderPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setFilterByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setGenrePreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setYearPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setTagPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
}, [
libraryId,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
setFilterBy,
setSortBy,
setSortOrder,
setSortByPreference,
setSortOrderPreference,
setFilterByPreference,
setGenrePreference,
setYearPreference,
setTagPreference,
]);
return { hasActiveFilters, resetAllFilters };
};

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

@@ -4,42 +4,41 @@ import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
useTVBackPress,
} from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress";
/** All tab route names used in the bottom tab navigator. */
export const TAB_ROUTES = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(custom-links)",
"(settings)",
] as const;
export type TabRoute = (typeof TAB_ROUTES)[number];
/** Check if a segment string is a tab route. */
export function isTabRoute(s: string): s is TabRoute {
return (TAB_ROUTES as readonly string[]).includes(s);
}
/**
* Check if we're at the root of a tab
*/
function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1];
return isTabRoute(lastSegment) || lastSegment === "index";
const tabNames = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(settings)",
"(custom-links)",
];
return tabNames.includes(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
function getCurrentTab(segments: string[]): TabRoute | undefined {
return segments.find(isTabRoute);
function getCurrentTab(segments: string[]): string | undefined {
return segments.find(
(s) =>
s === "(home)" ||
s === "(search)" ||
s === "(favorites)" ||
s === "(libraries)" ||
s === "(watchlists)" ||
s === "(settings)" ||
s === "(custom-links)",
);
}
/**
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}
/**
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
*
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
* built-in tab-level back handling — pressing back at a tab root would pop the
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
* to Home instead.
*/
export function useTVTabRootBackHandler(
onNavigateHome: () => void,
isAtTabRoot: boolean,
currentTab: string | undefined,
) {
useTVBackPress(() => {
if (!Platform.isTV || Platform.OS !== "android") return false;
if (!isAtTabRoot || currentTab === "(home)") return false;
onNavigateHome();
return true;
}, [isAtTabRoot, currentTab, onNavigateHome]);
}

View File

@@ -12,7 +12,6 @@ interface ShowOptionsParams<T> {
onSelect: (value: T) => void;
cardWidth?: number;
cardHeight?: number;
deferApplyUntilDismissed?: boolean;
}
export const useTVOptionModal = () => {
@@ -27,7 +26,6 @@ export const useTVOptionModal = () => {
onSelect: params.onSelect,
cardWidth: params.cardWidth,
cardHeight: params.cardHeight,
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
});
router.push("/(auth)/tv-option-modal");
},

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

View File

@@ -2,8 +2,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".DownloadService"

View File

@@ -9,7 +9,6 @@ import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -28,7 +27,6 @@ class DownloadService : Service() {
private var currentDownloadTitle = "Preparing download..."
private var currentProgress = 0
private var isForegroundStarted = false
private var wakeLock: PowerManager.WakeLock? = null
inner class DownloadServiceBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
@@ -38,12 +36,6 @@ class DownloadService : Service() {
super.onCreate()
Log.d(TAG, "DownloadService created")
createNotificationChannel()
val pm = getSystemService(PowerManager::class.java)
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
wakeLock?.acquire()
Log.d(TAG, "Wake lock acquired")
}
override fun onBind(intent: Intent?): IBinder {
@@ -101,8 +93,6 @@ class DownloadService : Service() {
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
Log.d(TAG, "Wake lock released")
Log.d(TAG, "DownloadService destroyed")
super.onDestroy()
}

View File

@@ -53,5 +53,5 @@ android {
dependencies {
// libmpv from Maven Central
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
}

Some files were not shown because too many files have changed in this diff Show More