diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml
new file mode 100644
index 00000000..e6d902b1
--- /dev/null
+++ b/.github/workflows/artifact-comment.yml
@@ -0,0 +1,466 @@
+name: π Artifact Comment on PR
+
+concurrency:
+ group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }}
+ cancel-in-progress: true
+
+on:
+ workflow_dispatch: # Allow manual testing
+ pull_request: # Show in PR checks and provide status updates
+ types: [opened, synchronize, reopened]
+ workflow_run: # Triggered when build workflows complete
+ workflows:
+ - "ποΈ Build Apps"
+ types:
+ - completed
+
+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-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ actions: read
+
+ steps:
+ - name: π Get PR and Artifacts
+ uses: actions/github-script@v8
+ with:
+ script: |
+ // Check if we're running from a fork
+ const isFromFork = context.payload.pull_request?.head?.repo?.full_name !== context.repo.owner + '/' + context.repo.repo;
+ const workflowFromFork = context.payload.workflow_run?.head_repository?.full_name !== context.repo.owner + '/' + context.repo.repo;
+
+ if (isFromFork || workflowFromFork) {
+ console.log('π« Workflow running from fork - skipping comment creation to avoid permission errors');
+ console.log('Fork repository:', context.payload.pull_request?.head?.repo?.full_name || context.payload.workflow_run?.head_repository?.full_name);
+ console.log('Target repository:', context.repo.owner + '/' + context.repo.repo);
+ return;
+ }
+
+ // Handle repository_dispatch, pull_request, and manual dispatch events
+ let pr;
+ let targetCommitSha;
+
+ if (context.eventName === 'workflow_run') {
+ // Find PR associated with this workflow run commit
+ console.log('Workflow run event:', context.payload.workflow_run.name);
+
+ const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ commit_sha: context.payload.workflow_run.head_sha
+ });
+
+ if (pullRequests.length === 0) {
+ console.log('No pull request found for commit:', context.payload.workflow_run.head_sha);
+ return;
+ }
+
+ pr = pullRequests[0];
+ targetCommitSha = context.payload.workflow_run.head_sha;
+
+ } else if (context.eventName === 'pull_request') {
+ // Direct PR event
+ pr = context.payload.pull_request;
+ targetCommitSha = pr.head.sha;
+
+ } else if (context.eventName === 'workflow_dispatch') {
+ // For manual testing, try to find PR for current branch/commit
+ console.log('Manual workflow dispatch triggered');
+
+ // First, try to find PRs associated with current commit
+ try {
+ const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ commit_sha: context.sha
+ });
+
+ if (pullRequests.length > 0) {
+ pr = pullRequests[0];
+ targetCommitSha = pr.head.sha;
+ console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`);
+ } else {
+ // Fallback: get latest open PR
+ const { data: openPRs } = await github.rest.pulls.list({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: 1
+ });
+
+ if (openPRs.length > 0) {
+ pr = openPRs[0];
+ targetCommitSha = pr.head.sha;
+ console.log(`Using latest open PR #${pr.number} for manual testing`);
+ } else {
+ console.log('No open PRs found for manual testing');
+ return;
+ }
+ }
+ } catch (error) {
+ console.log('Error finding PR for manual testing:', error.message);
+ return;
+ }
+
+ } else {
+ console.log('Unsupported event type:', context.eventName);
+ return;
+ }
+
+ console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`);
+
+ // Get all recent workflow runs for this PR to collect artifacts from multiple builds
+ const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ head_sha: targetCommitSha,
+ per_page: 30
+ });
+
+ // Filter for build workflows only, include active runs even if marked as cancelled
+ const buildRuns = workflowRuns.workflow_runs
+ .filter(run =>
+ (run.name.includes('Build Apps') ||
+ run.name.includes('Android APK Build') ||
+ run.name.includes('iOS IPA Build'))
+ )
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+
+ console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
+
+ // Log current status of each build for debugging
+ buildRuns.forEach(run => {
+ console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`);
+ });
+
+ // Collect artifacts and statuses from builds - prioritize active runs over completed ones
+ let allArtifacts = [];
+ let buildStatuses = {};
+
+ // Get the most relevant run for each workflow type (prioritize active over cancelled)
+ const findBestRun = (nameFilter) => {
+ const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter));
+
+ // First try to find an in-progress run
+ const inProgressRun = matchingRuns.find(run => run.status === 'in_progress');
+ if (inProgressRun) return inProgressRun;
+
+ // Then try to find a queued run
+ const queuedRun = matchingRuns.find(run => run.status === 'queued');
+ if (queuedRun) return queuedRun;
+
+ // Check if the workflow is completed but has non-cancelled jobs
+ const completedRuns = matchingRuns.filter(run => run.status === 'completed');
+ for (const run of completedRuns) {
+ // We'll check individual jobs later to see if they're actually running
+ if (run.conclusion !== 'cancelled') {
+ return run;
+ }
+ }
+
+ // Finally fall back to most recent run (even if cancelled at workflow level)
+ return matchingRuns[0]; // Already sorted by most recent first
+ };
+
+ const latestAppsRun = findBestRun('Build Apps');
+ const latestAndroidRun = findBestRun('Android APK Build');
+ const latestIOSRun = findBestRun('iOS IPA Build');
+
+ // 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'})`);
+
+ try {
+ // Get all jobs for this workflow run
+ const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: latestAppsRun.id
+ });
+
+ console.log(`Found ${jobs.jobs.length} jobs in workflow run`);
+ jobs.jobs.forEach(job => {
+ console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`);
+ });
+
+ // Check if we have any actually running jobs (not cancelled)
+ const activeJobs = jobs.jobs.filter(job =>
+ job.status === 'in_progress' ||
+ job.status === 'queued' ||
+ (job.status === 'completed' && job.conclusion !== 'cancelled')
+ );
+
+ console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`);
+
+ // If no jobs are actually running, skip this workflow
+ if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') {
+ console.log('All jobs are cancelled, skipping this workflow run');
+ return; // Exit early
+ }
+
+ // Map job names to our build targets
+ const jobMappings = {
+ 'Android Phone': ['π€ Build Android APK (Phone)', 'build-android-phone'],
+ 'Android TV': ['π€ Build Android APK (TV)', 'build-android-tv'],
+ 'iOS Phone': ['π Build iOS IPA (Phone)', 'build-ios-phone']
+ };
+
+ // Create individual status for each job
+ for (const [platform, jobNames] of Object.entries(jobMappings)) {
+ const job = jobs.jobs.find(j =>
+ jobNames.some(name => j.name.includes(name) || j.name === name)
+ );
+
+ if (job) {
+ buildStatuses[platform] = {
+ name: job.name,
+ status: job.status,
+ conclusion: job.conclusion,
+ url: job.html_url,
+ runId: latestAppsRun.id,
+ created_at: job.started_at || latestAppsRun.created_at
+ };
+ console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
+ } else {
+ console.log(`No job found for ${platform}, using workflow status as fallback`);
+ buildStatuses[platform] = {
+ name: latestAppsRun.name,
+ status: latestAppsRun.status,
+ conclusion: latestAppsRun.conclusion,
+ url: latestAppsRun.html_url,
+ runId: latestAppsRun.id,
+ created_at: latestAppsRun.created_at
+ };
+ }
+ }
+
+ } catch (error) {
+ console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
+ // Fallback to workflow-level status
+ buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
+ name: latestAppsRun.name,
+ status: latestAppsRun.status,
+ conclusion: latestAppsRun.conclusion,
+ url: latestAppsRun.html_url,
+ runId: latestAppsRun.id,
+ created_at: latestAppsRun.created_at
+ };
+ }
+
+ // Collect artifacts if any job has completed successfully
+ if (latestAppsRun.status === 'completed' ||
+ Object.values(buildStatuses).some(status => status.conclusion === 'success')) {
+ try {
+ const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: latestAppsRun.id
+ });
+ allArtifacts.push(...artifacts.artifacts);
+ } catch (error) {
+ console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message);
+ }
+ }
+ } else {
+ // Fallback to separate workflows (for backward compatibility)
+ if (latestAndroidRun) {
+ buildStatuses['Android'] = {
+ name: latestAndroidRun.name,
+ status: latestAndroidRun.status,
+ conclusion: latestAndroidRun.conclusion,
+ url: latestAndroidRun.html_url,
+ runId: latestAndroidRun.id,
+ created_at: latestAndroidRun.created_at
+ };
+
+ if (latestAndroidRun.conclusion === 'success') {
+ try {
+ const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: latestAndroidRun.id
+ });
+ allArtifacts.push(...artifacts.artifacts);
+ } catch (error) {
+ console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message);
+ }
+ }
+ }
+
+ if (latestIOSRun) {
+ buildStatuses['iOS'] = {
+ name: latestIOSRun.name,
+ status: latestIOSRun.status,
+ conclusion: latestIOSRun.conclusion,
+ url: latestIOSRun.html_url,
+ runId: latestIOSRun.id,
+ created_at: latestIOSRun.created_at
+ };
+
+ if (latestIOSRun.conclusion === 'success') {
+ try {
+ const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: latestIOSRun.id
+ });
+ allArtifacts.push(...artifacts.artifacts);
+ } catch (error) {
+ console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message);
+ }
+ }
+ }
+ }
+
+ console.log(`Collected ${allArtifacts.length} total artifacts from all builds`);
+
+ // Debug: Show which workflow we're using and its status
+ if (latestAppsRun) {
+ console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`);
+ } else {
+ console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`);
+ }
+
+ // Debug: List all artifacts found
+ allArtifacts.forEach(artifact => {
+ console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
+ });
+
+ // 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
+ commentBody += `### π¦ Build Artifacts\n\n`;
+ commentBody += `| Platform | Device | Status | Download |\n`;
+ commentBody += `|----------|--------|--------|---------|\n`;
+
+ // Process each expected build target individually
+ const buildTargets = [
+ { name: 'Android Phone', platform: 'π€', device: 'π±', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
+ { name: 'Android TV', platform: 'π€', device: 'πΊ', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
+ { name: 'iOS Phone', platform: 'π', device: 'π±', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
+ { name: 'iOS TV', platform: 'π', device: 'πΊ', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
+ ];
+
+ for (const target of buildTargets) {
+ // Find matching job status directly
+ const matchingStatus = buildStatuses[target.statusKey];
+
+ // Find matching artifact
+ const matchingArtifact = allArtifacts.find(artifact =>
+ target.artifactPattern.test(artifact.name)
+ );
+
+ let status = 'β³ Pending';
+ let downloadLink = '*Waiting for build...*';
+
+ // Special case for iOS TV - show as disabled
+ if (target.name === 'iOS TV') {
+ status = 'π€ Disabled';
+ downloadLink = '*Disabled for now*';
+ } else if (matchingStatus) {
+ if (matchingStatus.conclusion === 'success' && matchingArtifact) {
+ status = 'β
Complete';
+ const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
+ const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
+ downloadLink = `[π₯ Download ${fileType}](${directLink})`;
+ } else if (matchingStatus.conclusion === 'failure') {
+ status = `β [Failed](${matchingStatus.url})`;
+ downloadLink = '*Build failed*';
+ } else if (matchingStatus.conclusion === 'cancelled') {
+ status = `βͺ [Cancelled](${matchingStatus.url})`;
+ downloadLink = '*Build cancelled*';
+ } else if (matchingStatus.status === 'in_progress') {
+ status = `π [Building...](${matchingStatus.url})`;
+ downloadLink = '*Build in progress...*';
+ } else if (matchingStatus.status === 'queued') {
+ status = `β³ [Queued](${matchingStatus.url})`;
+ 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})`;
+ downloadLink = '*Finalizing build...*';
+ } else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) {
+ // Build succeeded but artifacts not yet available
+ status = `β³ [Processing artifacts...](${matchingStatus.url})`;
+ downloadLink = '*Preparing download...*';
+ } else {
+ // Fallback for any unexpected states
+ status = `β [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`;
+ downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`;
+ }
+ }
+
+ commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
+ }
+
+ commentBody += `\n`;
+
+ // 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`;
+ commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
+ commentBody += `> β οΈ **Note**: Artifacts expire in 7 days from build date\n\n`;
+ } else {
+ commentBody += `β³ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
+ }
+
+ commentBody += `*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`;
+ commentBody += `\n`;
+
+ // Try to find existing bot comment to update (with permission check)
+ try {
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number
+ });
+
+ const botComment = comments.find(comment =>
+ comment.user.type === 'Bot' &&
+ comment.body.includes('')
+ );
+
+ if (botComment) {
+ // Update existing comment
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: commentBody
+ });
+ console.log(`β
Updated comment ${botComment.id} on PR #${pr.number}`);
+ } else {
+ // Create new comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ body: commentBody
+ });
+ console.log(`β
Created new comment on PR #${pr.number}`);
+ }
+ } catch (error) {
+ if (error.status === 403) {
+ console.log('π« Permission denied - likely running from a fork. Skipping comment creation.');
+ console.log('Error details:', error.message);
+
+ // Log the build status instead of commenting
+ console.log('π Build Status Summary:');
+ for (const target of buildTargets) {
+ const matchingStatus = buildStatuses[target.statusKey];
+ if (matchingStatus) {
+ console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`);
+ }
+ }
+ } else {
+ // Re-throw other errors
+ throw error;
+ }
+ }
diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml
deleted file mode 100644
index bc9b9ea1..00000000
--- a/.github/workflows/build-android.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-name: π€ Android APK Build (Phone + TV)
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- workflow_dispatch:
- pull_request:
- branches: [develop, master]
- push:
- branches: [develop, master]
-
-jobs:
- build-android:
- if: (!contains(github.event.head_commit.message, '[skip ci]'))
- runs-on: ubuntu-24.04
- name: ποΈ Build Android APK
- permissions:
- contents: read
-
- strategy:
- fail-fast: false
- matrix:
- target: [phone, tv]
-
- steps:
- - name: π₯ Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
-
- - name: πΎ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
- restore-keys: ${{ runner.os }}-gradle-develop
-
- - name: π οΈ Generate project files
- run: |
- if [ "${{ matrix.target }}" = "tv" ]; then
- bun run prebuild:tv
- else
- bun run prebuild
- fi
-
- - name: πΎ Cache project Gradle (.gradle)
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: android/.gradle
- 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: ${{ matrix.target == 'tv' && 1 || 0 }}
- run: bun run build:android:local
-
- - name: π
Set date tag
- run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
-
- - name: π€ Upload APK artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- with:
- name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
- path: |
- android/app/build/outputs/apk/release/*.apk
- retention-days: 7
diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
new file mode 100644
index 00000000..ad770414
--- /dev/null
+++ b/.github/workflows/build-apps.yml
@@ -0,0 +1,280 @@
+name: ποΈ Build Apps
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [develop, master]
+ push:
+ branches: [develop, master]
+
+jobs:
+ build-android-phone:
+ if: (!contains(github.event.head_commit.message, '[skip ci]'))
+ runs-on: ubuntu-24.04
+ name: π€ Build Android APK (Phone)
+ permissions:
+ contents: read
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ with:
+ bun-version: latest
+
+ - name: πΎ Cache Bun dependencies
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: android/.gradle
+ 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
+ run: bun run build:android:local
+
+ - name: π
Set date tag
+ run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
+
+ - name: π€ Upload APK artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
+ path: |
+ android/app/build/outputs/apk/release/*.apk
+ retention-days: 7
+
+ build-android-tv:
+ if: (!contains(github.event.head_commit.message, '[skip ci]'))
+ runs-on: ubuntu-24.04
+ name: π€ Build Android APK (TV)
+ permissions:
+ contents: read
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ with:
+ bun-version: latest
+
+ - name: πΎ Cache Bun dependencies
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: android/.gradle
+ 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
+ run: bun run build:android:local
+
+ - name: π
Set date tag
+ run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
+
+ - name: π€ Upload APK artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
+ path: |
+ android/app/build/outputs/apk/release/*.apk
+ retention-days: 7
+
+ 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-15
+ name: π Build iOS IPA (Phone)
+ permissions:
+ contents: read
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ with:
+ bun-version: latest
+
+ - name: πΎ Cache Bun dependencies
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-cache
+
+ - name: π¦ Install dependencies and reload submodules
+ run: |
+ bun install --frozen-lockfile
+ bun run submodule-reload
+
+ - name: π οΈ Generate project files
+ run: bun run prebuild
+
+ - name: ποΈ Setup EAS
+ uses: expo/expo-github-action@main
+ with:
+ eas-version: latest
+ token: ${{ secrets.EXPO_TOKEN }}
+ eas-cache: true
+
+ - name: βοΈ Ensure iOS SDKs installed
+ run: xcodebuild -downloadPlatform iOS
+
+ - name: π Build iOS app
+ env:
+ EXPO_TV: 0
+ 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
+
+ - name: π€ Upload IPA artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
+ path: build-*.ipa
+ retention-days: 7
+
+ # Disabled for now - uncomment when ready to build iOS TV
+ # build-ios-tv:
+ # 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-15
+ # name: π Build iOS IPA (TV)
+ # permissions:
+ # contents: read
+ #
+ # steps:
+ # - name: π₯ Checkout code
+ # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ # 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
+ # with:
+ # bun-version: latest
+ #
+ # - name: πΎ Cache Bun dependencies
+ # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ # with:
+ # path: ~/.bun/install/cache
+ # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
+ # restore-keys: |
+ # ${{ runner.os }}-bun-cache
+ #
+ # - name: π¦ Install dependencies and reload submodules
+ # run: |
+ # bun install --frozen-lockfile
+ # bun run submodule-reload
+ #
+ # - name: π οΈ Generate project files
+ # run: bun run prebuild:tv
+ #
+ # - name: ποΈ Setup EAS
+ # uses: expo/expo-github-action@main
+ # with:
+ # eas-version: latest
+ # token: ${{ secrets.EXPO_TOKEN }}
+ # eas-cache: true
+ #
+ # - name: βοΈ Ensure tvOS SDKs installed
+ # run: xcodebuild -downloadPlatform tvOS
+ #
+ # - name: π Build iOS app
+ # env:
+ # EXPO_TV: 1
+ # 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
+ #
+ # - name: π€ Upload IPA artifact
+ # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ # with:
+ # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
+ # path: build-*.ipa
+ # retention-days: 7
diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml
deleted file mode 100644
index 98b587b3..00000000
--- a/.github/workflows/build-ios.yml
+++ /dev/null
@@ -1,95 +0,0 @@
-name: π€ iOS IPA Build (Phone + TV)
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-on:
- workflow_dispatch:
- pull_request:
- branches: [develop, master]
- paths-ignore:
- - '*.md'
- push:
- branches: [develop, master]
- paths-ignore:
- - '*.md'
-
-jobs:
- build-ios:
- 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-15
- name: ποΈ Build iOS IPA
- permissions:
- contents: read
-
- strategy:
- fail-fast: false
- matrix:
- target: [phone]
-# target: [phone, tv]
-
- steps:
- - name: π₯ Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- with:
- bun-version: latest
-
- - name: πΎ Cache Bun dependencies
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
- restore-keys: |
- ${{ runner.os }}-bun-cache
-
- - name: π¦ Install dependencies and reload submodules
- run: |
- bun install --frozen-lockfile
- bun run submodule-reload
-
- - name: π οΈ Generate project files
- run: |
- if [ "${{ matrix.target }}" = "tv" ]; then
- bun run prebuild:tv
- else
- bun run prebuild
- fi
-
- - name: ποΈ Setup EAS
- uses: expo/expo-github-action@main
- with:
- eas-version: latest
- token: ${{ secrets.EXPO_TOKEN }}
- eas-cache: true
-
- - name: βοΈ Ensure iOS/tvOS SDKs installed
- run: |
- if [ "${{ matrix.target }}" = "tv" ]; then
- xcodebuild -downloadPlatform tvOS
- else
- xcodebuild -downloadPlatform iOS
- fi
-
- - name: π Build iOS app
- env:
- EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
- 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
-
- - name: π€ Upload IPA artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- with:
- name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
- path: build-*.ipa
- retention-days: 7
diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml
index 024cc6e9..0e1ee2c3 100644
--- a/.github/workflows/ci-codeql.yml
+++ b/.github/workflows/ci-codeql.yml
@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: π Initialize CodeQL
- uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
+ uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: π οΈ Autobuild
- uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
+ uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
- name: π§ͺ Perform CodeQL Analysis
- uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
+ uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index 9d09d751..ec3ab9aa 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
- uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
+ uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
diff --git a/app.json b/app.json
index e32e4be6..1e2d132a 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.36.0",
+ "version": "0.38.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 69,
+ "versionCode": 70,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
index a580146f..f3b436a7 100644
--- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx
+++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.custom_links"),
- headerBlurEffect: "prominent",
+ headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx
index 9f75619f..2a0139f4 100644
--- a/app/(auth)/(tabs)/(favorites)/_layout.tsx
+++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx
@@ -11,12 +11,8 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
- headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
- headerLargeStyle: {
- backgroundColor: "black",
- },
- headerBlurEffect: "prominent",
+ headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index 5ad41f97..5e43476d 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -21,19 +21,16 @@ export default function IndexLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
- headerLargeTitle: true,
headerTitle: t("tabs.home"),
- headerBlurEffect: "prominent",
- headerLargeStyle: {
- backgroundColor: "black",
- },
+ headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
-
+
{!Platform.isTV && (
<>
-
+
+
{user?.Policy?.IsAdministrator && }
>
@@ -138,14 +135,13 @@ const SessionsButton = () => {
onPress={() => {
router.push("/(auth)/sessions");
}}
+ className='mr-4'
>
-
-
-
+
);
};
diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx
index 9038e2fd..cd5e32a0 100644
--- a/app/(auth)/(tabs)/(home)/sessions/index.tsx
+++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx
@@ -468,6 +468,7 @@ const TranscodingStreamView = ({
};
const TranscodingView = ({ session }: SessionCardProps) => {
+ const { t } = useTranslation();
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type === "Video",
@@ -501,7 +502,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
return (
{
/>
{
{subtitleStream && (
{
borderStyle: "solid",
}}
>
- Play
+ {t("common.play")}
)
diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx
index e450e3ec..89a3e847 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -1,224 +1,85 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
-import { Platform } from "react-native";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, TouchableOpacity } from "react-native";
+import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
-const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
-
-import { useTranslation } from "react-i18next";
-
export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings();
+ const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation();
if (!settings?.libraryOptions) return null;
return (
-
-
- !pluginSettings?.libraryOptions?.locked &&
- !Platform.isTV && (
-
-
+ <>
+
+
+ !pluginSettings?.libraryOptions?.locked &&
+ !Platform.isTV && (
+ setOptionsSheetOpen(true)}
+ className='flex flex-row items-center justify-center w-9 h-9'
+ >
-
-
-
- {t("library.options.display")}
-
-
-
-
- {t("library.options.display")}
-
-
-
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- display: "row",
- },
- })
- }
- >
-
-
- {t("library.options.row")}
-
-
-
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- display: "list",
- },
- })
- }
- >
-
-
- {t("library.options.list")}
-
-
-
-
-
-
- {t("library.options.image_style")}
-
-
-
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- imageStyle: "poster",
- },
- })
- }
- >
-
-
- {t("library.options.poster")}
-
-
-
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- imageStyle: "cover",
- },
- })
- }
- >
-
-
- {t("library.options.cover")}
-
-
-
-
-
-
- {
- if (settings.libraryOptions.imageStyle === "poster")
- return;
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- showTitles: newValue === "on",
- },
- });
- }}
- >
-
-
- {t("library.options.show_titles")}
-
-
- {
- updateSettings({
- libraryOptions: {
- ...settings.libraryOptions,
- showStats: newValue === "on",
- },
- });
- }}
- >
-
-
- {t("library.options.show_stats")}
-
-
-
-
-
-
-
- ),
- }}
+
+ ),
+ }}
+ />
+
+ {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
+
+ ))}
+
+
+
+ updateSettings({
+ libraryOptions: {
+ ...settings.libraryOptions,
+ ...options,
+ },
+ })
+ }
+ disabled={pluginSettings?.libraryOptions?.locked}
/>
-
- {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
-
- ))}
-
-
+ >
);
}
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index 9caf105b..4577a38a 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -14,12 +14,8 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
- headerLargeTitle: true,
headerTitle: t("tabs.search"),
- headerLargeStyle: {
- backgroundColor: "black",
- },
- headerBlurEffect: "prominent",
+ headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index c94f540e..677fc6ce 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -395,12 +395,17 @@ function Layout() {
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
- BackGroundDownloader.checkForExistingDownloads();
+ BackGroundDownloader.checkForExistingDownloads().catch(
+ (error: unknown) => {
+ writeErrorLog("Failed to resume background downloads", error);
+ },
+ );
}
});
- BackGroundDownloader.checkForExistingDownloads();
-
+ BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
+ writeErrorLog("Failed to resume background downloads", error);
+ });
return () => {
subscription.remove();
};
diff --git a/bun.lock b/bun.lock
index 16e0816f..b336bf5f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,8 +4,8 @@
"": {
"name": "streamyfin",
"dependencies": {
- "@bottom-tabs/react-navigation": "^0.9.2",
- "@expo/metro-runtime": "~5.0.4",
+ "@bottom-tabs/react-navigation": "^0.11.2",
+ "@expo/metro-runtime": "~5.0.5",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -18,7 +18,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
- "expo": "^53.0.22",
+ "expo": "^53.0.23",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-atlas": "^0.4.0",
@@ -36,7 +36,7 @@
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
- "expo-router": "~5.1.5",
+ "expo-router": "~5.1.7",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
@@ -54,7 +54,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0",
"react-native-awesome-slider": "^2.9.0",
- "react-native-bottom-tabs": "^0.9.2",
+ "react-native-bottom-tabs": "^0.11.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
@@ -312,7 +312,7 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
- "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="],
+ "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.11.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-xjRZZe3GZ/bIADBkJSe+qjRC/pQKcTEhZgtoGb4lyINq1NPzhKXhlZHwZLzNJng/Q/+F4RD3M7bQ6oCgSHV2WA=="],
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
@@ -320,7 +320,7 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
- "@expo/cli": ["@expo/cli@0.24.21", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.11", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-DT6K9vgFHqqWL/19mU1ofRcPoO1pn4qmgi76GtuiNU4tbBe/02mRHwFsQw7qRfFAT28If5e/wiwVozgSuZVL8g=="],
+ "@expo/cli": ["@expo/cli@0.24.22", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.12", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-cEg6/F8ZWjoVkEwm0rXKReWbsCUROFbLFBYht+d5RzHnDwJoTX4QWJKx4m+TGNDPamRUIGw36U4z41Fvev0XmA=="],
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
@@ -342,7 +342,7 @@
"@expo/metro-config": ["@expo/metro-config@0.20.17", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~11.0.12", "@expo/env": "~1.0.7", "@expo/json-file": "~9.1.5", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA=="],
- "@expo/metro-runtime": ["@expo/metro-runtime@5.0.4", "", { "peerDependencies": { "react-native": "*" } }, "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ=="],
+ "@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="],
"@expo/osascript": ["@expo/osascript@2.2.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-Bpp/n5rZ0UmpBOnl7Li3LtM7la0AR3H9NNesqL+ytW5UiqV/TbonYW3rDZY38u4u/lG7TnYflVIVQPD+iqZJ5w=="],
@@ -818,13 +818,13 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
- "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+ "color": ["color@5.0.2", "", { "dependencies": { "color-convert": "^3.0.1", "color-string": "^2.0.0" } }, "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA=="],
- "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+ "color-convert": ["color-convert@3.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg=="],
- "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "color-name": ["color-name@2.0.2", "", {}, "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A=="],
- "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+ "color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
@@ -990,7 +990,7 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
- "expo": ["expo@53.0.22", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.21", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA=="],
+ "expo": ["expo@53.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.22", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6TOLuNCP3AsSkXBJA5W6U/7wpZUop3Q6BxHMtRD2OOgT7CCPvnYgJdnTzqU+gD1hMfcryD8Ejq9RdHbLduXohg=="],
"expo-application": ["expo-application@6.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg=="],
@@ -1046,7 +1046,7 @@
"expo-notifications": ["expo-notifications@0.31.4", "", { "dependencies": { "@expo/image-utils": "^0.7.6", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~6.1.5", "expo-constants": "~17.1.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NnGKIFGpgZU66qfiFUyjEBYsS77VahURpSSeWEOLt+P1zOaUFlgx2XqS+dxH3/Bn1Vm7TMj04qKsK5KvzR/8Lw=="],
- "expo-router": ["expo-router@5.1.5", "", { "dependencies": { "@expo/metro-runtime": "5.0.4", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-VPhS21DPP+riJIUshs/qpb11L/nzmRK7N7mqSFCr/mjpziznYu/qS+BPeQ88akIuXv6QsXipY5UTfYINdV+P0Q=="],
+ "expo-router": ["expo-router@5.1.7", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-E7hIqTZs4Cub4sbYPeednfYPi+2cyRGMdqc5IYBJ/vC+WBKoYJ8C9eU13ZLbPz//ZybSo2Dsm7v89uFIlO2Gow=="],
"expo-screen-orientation": ["expo-screen-orientation@8.1.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA=="],
@@ -1632,7 +1632,7 @@
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
- "react-native-bottom-tabs": ["react-native-bottom-tabs@0.9.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-kwLx9OM6v5P10TdmNhlEgb8nmwBOpwy3ULIxEv1v6cYjzuRkeYtA2dqYeFhJAn1rmWMrl3MnL3xzW5Q3IQyfAg=="],
+ "react-native-bottom-tabs": ["react-native-bottom-tabs@0.11.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2zvR9DgQgqOKhxGeETkphXANDkMyUKN/i0+M+WF52JQd4q4h+uY3ctLnXNQ4pZf1cEDlWQ6aBtYWe3NJKvDIwA=="],
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
@@ -2030,6 +2030,8 @@
"@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
+ "@expo/cli/@expo/prebuild-config": ["@expo/prebuild-config@9.0.12", "", { "dependencies": { "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/config-types": "^53.0.5", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@react-native/normalize-colors": "0.79.6", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q=="],
+
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
@@ -2130,6 +2132,12 @@
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
+ "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2138,6 +2146,8 @@
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
+ "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -2364,12 +2374,30 @@
"@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
+
+ "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
+ "@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
+ "@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
+ "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
+
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
@@ -2466,6 +2494,20 @@
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+ "@react-native/community-cli-plugin/@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
+
+ "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx
index 156ed194..a00d20bb 100644
--- a/components/AddToFavorites.tsx
+++ b/components/AddToFavorites.tsx
@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
-import { View, type ViewProps } from "react-native";
+import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
@@ -11,6 +11,18 @@ interface Props extends ViewProps {
export const AddToFavorites: FC = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
+ if (Platform.OS === "ios") {
+ return (
+
+
+
+ );
+ }
+
return (
void;
+}
+
+const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
+
+// Layout Constants
+const CAROUSEL_HEIGHT = screenHeight / 1.45;
+const GRADIENT_HEIGHT_TOP = 150;
+const GRADIENT_HEIGHT_BOTTOM = 150;
+const LOGO_HEIGHT = 80;
+
+// Position Constants
+const LOGO_BOTTOM_POSITION = 210;
+const GENRES_BOTTOM_POSITION = 170;
+const CONTROLS_BOTTOM_POSITION = 100;
+const DOTS_BOTTOM_POSITION = 60;
+
+// Size Constants
+const DOT_HEIGHT = 6;
+const DOT_ACTIVE_WIDTH = 20;
+const DOT_INACTIVE_WIDTH = 12;
+const PLAY_BUTTON_SKELETON_HEIGHT = 50;
+const PLAYED_STATUS_SKELETON_SIZE = 40;
+const TEXT_SKELETON_HEIGHT = 20;
+const TEXT_SKELETON_WIDTH = 250;
+const _EMPTY_STATE_ICON_SIZE = 64;
+
+// Spacing Constants
+const HORIZONTAL_PADDING = 40;
+const DOT_PADDING = 2;
+const DOT_GAP = 4;
+const CONTROLS_GAP = 20;
+const _TEXT_MARGIN_TOP = 16;
+
+// Border Radius Constants
+const DOT_BORDER_RADIUS = 3;
+const LOGO_SKELETON_BORDER_RADIUS = 8;
+const TEXT_SKELETON_BORDER_RADIUS = 4;
+const PLAY_BUTTON_BORDER_RADIUS = 25;
+const PLAYED_STATUS_BORDER_RADIUS = 20;
+
+// Animation Constants
+const DOT_ANIMATION_DURATION = 300;
+const CAROUSEL_TRANSITION_DURATION = 250;
+const PAN_ACTIVE_OFFSET = 10;
+const TRANSLATION_THRESHOLD = 0.2;
+const VELOCITY_THRESHOLD = 400;
+
+// Text Constants
+const GENRES_FONT_SIZE = 16;
+const _EMPTY_STATE_FONT_SIZE = 18;
+const TEXT_SHADOW_RADIUS = 2;
+const MAX_GENRES_COUNT = 2;
+const MAX_BUTTON_WIDTH = 300;
+
+// Opacity Constants
+const OVERLAY_OPACITY = 0.4;
+const DOT_INACTIVE_OPACITY = 0.6;
+const TEXT_OPACITY = 0.9;
+
+// Color Constants
+const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
+const SKELETON_ELEMENT_COLOR = "#333";
+const SKELETON_ACTIVE_DOT_COLOR = "#666";
+const _EMPTY_STATE_COLOR = "#666";
+const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
+const LOGO_WIDTH_PERCENTAGE = "80%";
+
+const DotIndicator = ({
+ index,
+ currentIndex,
+ onPress,
+}: {
+ index: number;
+ currentIndex: number;
+ onPress: (index: number) => void;
+}) => {
+ const isActive = index === currentIndex;
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
+ duration: DOT_ANIMATION_DURATION,
+ easing: Easing.out(Easing.quad),
+ }),
+ opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
+ duration: DOT_ANIMATION_DURATION,
+ easing: Easing.out(Easing.quad),
+ }),
+ }));
+
+ return (
+ onPress(index)}
+ style={{
+ padding: DOT_PADDING, // Increase touch area
+ }}
+ >
+
+
+ );
+};
+
+export const AppleTVCarousel: React.FC = ({
+ initialIndex = 0,
+ onItemChange,
+}) => {
+ const { settings } = useSettings();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { isConnected, serverConnected } = useNetworkStatus();
+ const router = useRouter();
+ const [currentIndex, setCurrentIndex] = useState(initialIndex);
+ const translateX = useSharedValue(-currentIndex * screenWidth);
+
+ const isQueryEnabled =
+ !!api && !!user?.Id && isConnected && serverConnected === true;
+
+ const { data: continueWatchingData, isLoading: continueWatchingLoading } =
+ useQuery({
+ queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await getItemsApi(api).getResumeItems({
+ userId: user.Id,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Genres"],
+ limit: 2,
+ });
+ return response.data.Items || [];
+ },
+ enabled: isQueryEnabled,
+ staleTime: 60 * 1000,
+ });
+
+ const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
+ queryKey: ["appleTVCarousel", "nextUp", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await getTvShowsApi(api).getNextUp({
+ userId: user.Id,
+ fields: ["MediaSourceCount", "Genres"],
+ limit: 2,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ enableResumable: false,
+ });
+ return response.data.Items || [];
+ },
+ enabled: isQueryEnabled,
+ staleTime: 60 * 1000,
+ });
+
+ const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
+ {
+ queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await getUserLibraryApi(api).getLatestMedia({
+ userId: user.Id,
+ limit: 2,
+ fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
+ imageTypeLimit: 1,
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
+ });
+ return response.data || [];
+ },
+ enabled: isQueryEnabled,
+ staleTime: 60 * 1000,
+ },
+ );
+
+ const items = useMemo(() => {
+ const continueItems = continueWatchingData ?? [];
+ const nextItems = nextUpData ?? [];
+ const recentItems = recentlyAddedData ?? [];
+
+ return [
+ ...continueItems.slice(0, 2),
+ ...nextItems.slice(0, 2),
+ ...recentItems.slice(0, 2),
+ ];
+ }, [continueWatchingData, nextUpData, recentlyAddedData]);
+
+ const isLoading =
+ continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
+ const hasItems = items.length > 0;
+
+ // Only get play settings if we have valid items
+ const currentItem = hasItems ? items[currentIndex] : null;
+
+ // Extract colors for the current item only (for performance)
+ const currentItemColors = useImageColorsReturn({ item: currentItem });
+
+ // Create a fallback empty item for useDefaultPlaySettings when no item is available
+ const itemForPlaySettings = currentItem || { MediaSources: [] };
+ const {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultMediaSource,
+ defaultSubtitleIndex,
+ } = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
+
+ const [selectedOptions, setSelectedOptions] = useState<
+ SelectedOptions | undefined
+ >(undefined);
+
+ useEffect(() => {
+ // Only set options if we have valid current item
+ if (currentItem) {
+ setSelectedOptions({
+ bitrate: defaultBitrate,
+ mediaSource: defaultMediaSource,
+ subtitleIndex: defaultSubtitleIndex ?? -1,
+ audioIndex: defaultAudioIndex,
+ });
+ } else {
+ setSelectedOptions(undefined);
+ }
+ }, [
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ defaultMediaSource,
+ currentIndex,
+ currentItem,
+ ]);
+
+ useEffect(() => {
+ if (!hasItems) {
+ setCurrentIndex(initialIndex);
+ translateX.value = -initialIndex * screenWidth;
+ return;
+ }
+
+ setCurrentIndex((prev) => {
+ const newIndex = Math.min(prev, items.length - 1);
+ translateX.value = -newIndex * screenWidth;
+ return newIndex;
+ });
+ }, [hasItems, items, initialIndex, translateX]);
+
+ useEffect(() => {
+ if (hasItems) {
+ onItemChange?.(currentIndex);
+ }
+ }, [hasItems, currentIndex, onItemChange]);
+
+ const goToIndex = useCallback(
+ (index: number) => {
+ if (!hasItems || index < 0 || index >= items.length) return;
+
+ translateX.value = withTiming(-index * screenWidth, {
+ duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
+ easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
+ });
+
+ setCurrentIndex(index);
+ onItemChange?.(index);
+ },
+ [hasItems, items, onItemChange, translateX],
+ );
+
+ const navigateToItem = useCallback(
+ (item: BaseItemDto) => {
+ const navigation = getItemNavigation(item, "(home)");
+ router.push(navigation as any);
+ },
+ [router],
+ );
+
+ const panGesture = Gesture.Pan()
+ .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
+ .onUpdate((event) => {
+ translateX.value = -currentIndex * screenWidth + event.translationX;
+ })
+ .onEnd((event) => {
+ const velocity = event.velocityX;
+ const translation = event.translationX;
+
+ let newIndex = currentIndex;
+
+ // Improved thresholds for more responsive navigation
+ if (
+ Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
+ Math.abs(velocity) > VELOCITY_THRESHOLD
+ ) {
+ if (translation > 0 && currentIndex > 0) {
+ newIndex = currentIndex - 1;
+ } else if (
+ translation < 0 &&
+ items &&
+ currentIndex < items.length - 1
+ ) {
+ newIndex = currentIndex + 1;
+ }
+ }
+
+ runOnJS(goToIndex)(newIndex);
+ });
+
+ const containerAnimatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: translateX.value }],
+ };
+ });
+
+ const renderDots = () => {
+ if (!hasItems || items.length <= 1) return null;
+
+ return (
+
+ {items.map((_, index) => (
+
+ ))}
+
+ );
+ };
+
+ const renderSkeletonLoader = () => {
+ return (
+
+ {/* Background Skeleton */}
+
+
+ {/* Dark Overlay Skeleton */}
+
+
+ {/* Gradient Fade to Black Top Skeleton */}
+
+
+ {/* Gradient Fade to Black Bottom Skeleton */}
+
+
+ {/* Logo Skeleton */}
+
+
+
+
+ {/* Type and Genres Skeleton */}
+
+
+
+
+ {/* Controls Skeleton */}
+
+ {/* Play Button Skeleton */}
+
+
+ {/* Played Status Skeleton */}
+
+
+
+ {/* Dots Skeleton */}
+
+ {[1, 2, 3].map((_, index) => (
+
+ ))}
+
+
+ );
+ };
+
+ const renderItem = (item: BaseItemDto, _index: number) => {
+ const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
+
+ return (
+
+ {/* Background Backdrop */}
+
+
+ {/* Dark Overlay */}
+
+
+ {/* Gradient Fade to Black at Top */}
+
+
+ {/* Gradient Fade to Black at Bottom */}
+
+
+ {/* Logo Section */}
+ {itemLogoUrl && (
+ navigateToItem(item)}
+ style={{
+ position: "absolute",
+ bottom: LOGO_BOTTOM_POSITION,
+ left: 0,
+ right: 0,
+ paddingHorizontal: HORIZONTAL_PADDING,
+ alignItems: "center",
+ }}
+ >
+
+
+ )}
+
+ {/* Type and Genres Section */}
+
+ navigateToItem(item)}>
+
+ {(() => {
+ let typeLabel = "";
+
+ if (item.Type === "Episode") {
+ // For episodes, show season and episode number
+ const season = item.ParentIndexNumber;
+ const episode = item.IndexNumber;
+ if (season && episode) {
+ typeLabel = `S${season} β’ E${episode}`;
+ } else {
+ typeLabel = "Episode";
+ }
+ } else {
+ typeLabel =
+ item.Type === "Series"
+ ? "TV Show"
+ : item.Type === "Movie"
+ ? "Movie"
+ : item.Type || "";
+ }
+
+ const genres =
+ item.Genres && item.Genres.length > 0
+ ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" β’ ")
+ : "";
+
+ if (typeLabel && genres) {
+ return `${typeLabel} β’ ${genres}`;
+ } else if (typeLabel) {
+ return typeLabel;
+ } else if (genres) {
+ return genres;
+ } else {
+ return "";
+ }
+ })()}
+
+
+
+
+ {/* Controls Section */}
+
+
+ {/* Play Button */}
+
+ {selectedOptions && (
+
+ )}
+
+
+ {/* Mark as Played */}
+
+
+
+
+ );
+ };
+
+ // Handle loading state
+ if (isLoading) {
+ return (
+
+ {renderSkeletonLoader()}
+
+ );
+ }
+
+ // Handle empty items
+ if (!hasItems) {
+ return null;
+ }
+
+ return (
+
+
+
+ {items.map((item, index) => renderItem(item, index))}
+
+
+
+ {/* Animated Dots Indicator */}
+ {renderDots()}
+
+ );
+};
diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx
index 837f0d7c..e009a12d 100644
--- a/components/Chromecast.tsx
+++ b/components/Chromecast.tsx
@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
-import { Platform } from "react-native";
+import { Platform, TouchableOpacity } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -42,6 +42,22 @@ export function Chromecast({
[Platform.OS],
);
+ if (Platform.OS === "ios") {
+ return (
+ {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
+ {...props}
+ >
+
+
+
+ );
+ }
+
if (background === "transparent")
return (
= ({
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`);
toast.error(
- t("Could not get download URL for {{itemName}}", {
+ t("home.downloads.toasts.could_not_get_download_url_for_item", {
itemName: item.Name,
}),
);
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 6c91a530..070b02e2 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
-import { useImageColors } from "@/hooks/useImageColors";
+import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -61,7 +61,7 @@ export const ItemContent: React.FC = React.memo(
const [user] = useAtom(userAtom);
const { t } = useTranslation();
- useImageColors({ item });
+ const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
@@ -105,13 +105,27 @@ export const ItemContent: React.FC = React.memo(
if (!Platform.isTV) {
navigation.setOptions({
headerRight: () =>
- item && (
+ item &&
+ (Platform.OS === "ios" ? (
+
+
+ {item.Type !== "Program" && (
+
+ {!Platform.isTV && (
+
+ )}
+ {user?.Policy?.IsAdministrator && (
+
+ )}
+
+
+
+
+ )}
+
+ ) : (
-
+
{item.Type !== "Program" && (
{!Platform.isTV && (
@@ -126,7 +140,7 @@ export const ItemContent: React.FC = React.memo(
)}
- ),
+ )),
});
}
}, [item, navigation, user]);
@@ -253,6 +267,7 @@ export const ItemContent: React.FC = React.memo(
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
+ colors={itemColors}
/>
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 3f4ca141..6ac1956e 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -23,6 +23,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
+import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -39,6 +40,7 @@ interface Props extends React.ComponentProps {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
+ colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -48,6 +50,7 @@ export const PlayButton: React.FC = ({
item,
selectedOptions,
isOffline,
+ colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -55,16 +58,19 @@ export const PlayButton: React.FC = ({
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
- const [colorAtom] = useAtom(itemThemeColorAtom);
+ const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
+ // Use colors prop if provided, otherwise fallback to global atom
+ const effectiveColors = colors || globalColorAtom;
+
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
- const endColor = useSharedValue(colorAtom);
- const startColor = useSharedValue(colorAtom);
+ const endColor = useSharedValue(effectiveColors);
+ const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings();
@@ -297,7 +303,7 @@ export const PlayButton: React.FC = ({
);
useAnimatedReaction(
- () => colorAtom,
+ () => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -306,19 +312,19 @@ export const PlayButton: React.FC = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
- [colorAtom],
+ [effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
- startColor.value = colorAtom;
+ startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
- }, [colorAtom, item]);
+ }, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -367,7 +373,7 @@ export const PlayButton: React.FC = ({
className={"relative"}
{...props}
>
-
+
= ({
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
index b4fa45a9..8e3b9811 100644
--- a/components/PlayButton.tv.tsx
+++ b/components/PlayButton.tv.tsx
@@ -15,6 +15,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
+import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -24,6 +25,7 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps {
item: BaseItemDto;
selectedOptions: SelectedOptions;
+ colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -32,16 +34,20 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC = ({
item,
selectedOptions,
+ colors,
...props
}: Props) => {
- const [colorAtom] = useAtom(itemThemeColorAtom);
+ const [globalColorAtom] = useAtom(itemThemeColorAtom);
+
+ // Use colors prop if provided, otherwise fallback to global atom
+ const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
- const endColor = useSharedValue(colorAtom);
- const startColor = useSharedValue(colorAtom);
+ const endColor = useSharedValue(effectiveColors);
+ const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
@@ -101,7 +107,7 @@ export const PlayButton: React.FC = ({
);
useAnimatedReaction(
- () => colorAtom,
+ () => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -110,19 +116,19 @@ export const PlayButton: React.FC = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
- [colorAtom],
+ [effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
- startColor.value = colorAtom;
+ startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
- }, [colorAtom, item]);
+ }, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -189,7 +195,7 @@ export const PlayButton: React.FC = ({
= ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
+ if (Platform.OS === "ios") {
+ return (
+
+ {
+ await toggle(!allPlayed);
+ }}
+ size={props.size}
+ />
+
+ );
+ }
+
return (
> = ({
children,
size = "default",
fillColor,
+ color = "white",
hapticFeedback = true,
...viewProps
}) => {
@@ -34,6 +36,25 @@ export const RoundButton: React.FC> = ({
onPress?.();
};
+ if (Platform.OS === "ios") {
+ return (
+
+ {icon ? (
+
+ ) : null}
+ {children ? children : null}
+
+ );
+ }
+
if (fillColor)
return (
= ({
}) => {
const router = useRouter();
+ if (Platform.OS === "ios") {
+ return (
+ router.back()}
+ className='flex items-center justify-center w-9 h-9'
+ {...touchableOpacityProps}
+ >
+
+
+ );
+ }
+
if (background === "transparent" && Platform.OS !== "android")
return (
{
/>
)}
- {/* Action buttons in top right corner */}
-
- {process.status === "downloading" && (
+ {/* Action buttons in bottom right corner */}
+
+ {process.status === "downloading" && Platform.OS !== "ios" && (
handlePause(process.id)}
className='p-1'
@@ -119,7 +120,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
- {process.status === "paused" && (
+ {process.status === "paused" && Platform.OS !== "ios" && (
handleResume(process.id)}
className='p-1'
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 1431fce3..913e024b 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -88,22 +88,24 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => {
if (!popularItems) return null;
return (
-
+
}
+ scrollAnimationDuration={1000}
/>
= ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
+ .shouldCancelWhenOutside(true)
.onBegin(() => {
opacity.value = withTiming(0.8, { duration: 100 });
})
@@ -173,25 +176,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
-
-
+
+
-
+
{
+ const { t } = useTranslation();
const trailerLink = useMemo(() => {
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
return item.RemoteTrailers[0].Url;
@@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
const openTrailer = useCallback(async () => {
if (!trailerLink) {
- Alert.alert("No trailer available");
+ Alert.alert(t("common.no_trailer_available"));
return;
}
@@ -39,7 +41,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
} catch (err) {
console.error("Failed to open trailer link:", err);
}
- }, [trailerLink]);
+ }, [trailerLink, t]);
return (
diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx
index 489cb0a1..e1e08d09 100644
--- a/components/settings/HomeIndex.tsx
+++ b/components/settings/HomeIndex.tsx
@@ -27,7 +27,6 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
-import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
@@ -38,6 +37,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
+import { AppleTVCarousel } from "../AppleTVCarousel";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
@@ -126,7 +126,10 @@ export const HomeIndex = () => {
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
- scrollViewRef.current?.scrollTo({ y: -152, animated: true });
+ scrollViewRef.current?.scrollTo({
+ y: Platform.isTV ? -152 : -100,
+ animated: true,
+ });
});
return () => {
@@ -192,9 +195,9 @@ export const HomeIndex = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
- fields: ["PrimaryImageAspectRatio", "Path"],
+ fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
@@ -236,8 +239,9 @@ export const HomeIndex = () => {
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Genres"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -250,9 +254,9 @@ export const HomeIndex = () => {
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount"],
+ fields: ["MediaSourceCount", "Genres"],
limit: 20,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
@@ -334,9 +338,9 @@ export const HomeIndex = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount"],
+ fields: ["MediaSourceCount", "Genres"],
limit: section.nextUp?.limit || 25,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -443,44 +447,60 @@ export const HomeIndex = () => {
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
- contentInsetAdjustmentBehavior='automatic'
+ contentInsetAdjustmentBehavior='never'
refreshControl={
-
+
}
- contentContainerStyle={{
- paddingLeft: insets.left,
- paddingRight: insets.right,
- paddingBottom: 16,
- }}
+ style={{ marginTop: Platform.isTV ? 0 : -100 }}
+ contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
>
-
-
-
- {sections.map((section, index) => {
- if (section.type === "ScrollingCollectionList") {
- return (
-
- );
- }
- if (section.type === "MediaListSection") {
- return (
-
- );
- }
- return null;
- })}
+ {
+ console.log(`Now viewing carousel item ${index}`);
+ }}
+ />
+
+
+ {sections.map((section, index) => {
+ if (section.type === "ScrollingCollectionList") {
+ return (
+
+ );
+ }
+ if (section.type === "MediaListSection") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
);
};
diff --git a/components/settings/LibraryOptionsSheet.tsx b/components/settings/LibraryOptionsSheet.tsx
new file mode 100644
index 00000000..c84989b5
--- /dev/null
+++ b/components/settings/LibraryOptionsSheet.tsx
@@ -0,0 +1,254 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import { useCallback, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ StyleSheet,
+ TouchableOpacity,
+ View,
+ type ViewProps,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+
+interface LibraryOptions {
+ display: "row" | "list";
+ imageStyle: "poster" | "cover";
+ showTitles: boolean;
+ showStats: boolean;
+}
+
+interface Props extends ViewProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ settings: LibraryOptions;
+ updateSettings: (options: Partial) => void;
+ disabled?: boolean;
+}
+
+const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({
+ title,
+ children,
+}) => (
+
+ {title}
+
+ {children}
+
+
+);
+
+const OptionItem: React.FC<{
+ label: string;
+ selected: boolean;
+ onPress: () => void;
+ disabled?: boolean;
+ isLast?: boolean;
+}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => (
+ <>
+
+ {label}
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ {!isLast && (
+
+ )}
+ >
+);
+
+const ToggleItem: React.FC<{
+ label: string;
+ value: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+ isLast?: boolean;
+}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => (
+ <>
+
+ {label}
+
+
+
+
+ {!isLast && (
+
+ )}
+ >
+);
+
+/**
+ * LibraryOptionsSheet Component
+ *
+ * This component creates a bottom sheet modal for managing library display options.
+ */
+export const LibraryOptionsSheet: React.FC = ({
+ open,
+ setOpen,
+ settings,
+ updateSettings,
+ disabled = false,
+}) => {
+ const bottomSheetModalRef = useRef(null);
+ const { t } = useTranslation();
+ const insets = useSafeAreaInsets();
+
+ const handlePresentModal = useCallback(() => {
+ bottomSheetModalRef.current?.present();
+ }, []);
+
+ const handleDismissModal = useCallback(() => {
+ bottomSheetModalRef.current?.dismiss();
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ handlePresentModal();
+ } else {
+ handleDismissModal();
+ }
+ }, [open, handlePresentModal, handleDismissModal]);
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ setOpen(false);
+ }
+ },
+ [setOpen],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ if (disabled) return null;
+
+ return (
+
+
+
+
+ {t("library.options.display")}
+
+
+
+ updateSettings({ display: "row" })}
+ />
+ updateSettings({ display: "list" })}
+ isLast
+ />
+
+
+
+ updateSettings({ imageStyle: "poster" })}
+ />
+ updateSettings({ imageStyle: "cover" })}
+ isLast
+ />
+
+
+
+
+ updateSettings({ showTitles: !settings.showTitles })
+ }
+ disabled={settings.imageStyle === "poster"}
+ />
+
+ updateSettings({ showStats: !settings.showStats })
+ }
+ isLast
+ />
+
+
+
+
+ );
+};
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index e0ca1147..d4d7e598 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
- toast.success("Background downloads enabled");
+ toast.success(t("home.settings.toasts.background_downloads_enabled"));
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
- toast.info("Background downloads disabled");
+ toast.info(t("home.settings.toasts.background_downloads_disabled"));
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 6c151b0f..ec4ba1e8 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerShown: true,
headerTransparent: true,
headerShadowVisible: false,
+ headerBlurEffect: "none",
headerLeft: () => ,
};
diff --git a/eas.json b/eas.json
index 9e219849..87730e9b 100644
--- a/eas.json
+++ b/eas.json
@@ -91,7 +91,7 @@
]
},
"environment": "production",
- "channel": "0.36.0",
+ "channel": "0.38.0",
"android": {
"buildType": "app-bundle",
"image": "latest"
@@ -111,7 +111,7 @@
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production",
- "channel": "0.36.0",
+ "channel": "0.38.0",
"android": {
"buildType": "apk",
"image": "latest"
@@ -124,7 +124,7 @@
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production",
- "channel": "0.36.0",
+ "channel": "0.38.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useImageColorsReturn.ts b/hooks/useImageColorsReturn.ts
new file mode 100644
index 00000000..b9c53e61
--- /dev/null
+++ b/hooks/useImageColorsReturn.ts
@@ -0,0 +1,131 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useAtomValue } from "jotai";
+import { useEffect, useMemo, useState } from "react";
+import { Platform } from "react-native";
+import { getColors, ImageColorsResult } from "react-native-image-colors";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ adjustToNearBlack,
+ calculateTextColor,
+ isCloseToBlack,
+} from "@/utils/atoms/primaryColor";
+import { getItemImage } from "@/utils/getItemImage";
+import { storage } from "@/utils/mmkv";
+
+export interface ThemeColors {
+ primary: string;
+ text: string;
+}
+
+const DEFAULT_COLORS: ThemeColors = {
+ primary: "#FFFFFF",
+ text: "#000000",
+};
+
+/**
+ * Custom hook to extract and return image colors for a given item.
+ * Returns colors as state instead of updating global atom.
+ *
+ * @param item - The BaseItemDto object representing the item.
+ * @param disabled - A boolean flag to disable color extraction.
+ * @returns ThemeColors object with primary and text colors
+ */
+export const useImageColorsReturn = ({
+ item,
+ url,
+ disabled,
+}: {
+ item?: BaseItemDto | null;
+ url?: string | null;
+ disabled?: boolean;
+}): ThemeColors => {
+ const api = useAtomValue(apiAtom);
+ const [colors, setColors] = useState(DEFAULT_COLORS);
+
+ 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(() => {
+ // Reset to default colors when item changes
+ if (!item && !url) {
+ setColors(DEFAULT_COLORS);
+ return;
+ }
+
+ 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) {
+ setColors({
+ primary: _primary,
+ text: _text,
+ });
+ return;
+ }
+
+ // Extract colors from the image
+ getColors(source.uri, {
+ fallback: "#fff",
+ cache: false,
+ })
+ .then((colors: 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);
+
+ const newColors = {
+ primary,
+ text,
+ };
+
+ setColors(newColors);
+
+ // 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);
+ setColors(DEFAULT_COLORS);
+ });
+ }
+ }, [isTv, source?.uri, disabled, item, url]);
+
+ return colors;
+};
diff --git a/package.json b/package.json
index 5704ac4f..549421b0 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,8 @@
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
},
"dependencies": {
- "@bottom-tabs/react-navigation": "^0.9.2",
- "@expo/metro-runtime": "~5.0.4",
+ "@bottom-tabs/react-navigation": "^0.11.2",
+ "@expo/metro-runtime": "~5.0.5",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.0",
@@ -36,7 +36,7 @@
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9",
- "expo": "^53.0.22",
+ "expo": "^53.0.23",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-atlas": "^0.4.0",
@@ -54,7 +54,7 @@
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
- "expo-router": "~5.1.5",
+ "expo-router": "~5.1.7",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
@@ -72,7 +72,7 @@
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0",
"react-native-awesome-slider": "^2.9.0",
- "react-native-bottom-tabs": "^0.9.2",
+ "react-native-bottom-tabs": "^0.11.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js
index 83e1fb69..af24d642 100644
--- a/plugins/withRNBackgroundDownloader.js
+++ b/plugins/withRNBackgroundDownloader.js
@@ -50,9 +50,11 @@ function withRNBackgroundDownloader(config) {
// Expo 53's xcodeβjs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) => {
+ // Use the detected projectName to set the bridging header path instead of a hardcoded value
+ const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER",
- "Streamyfin/Streamyfin-Bridging-Header.h",
+ bridgingHeaderPath,
cfg,
);
});
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index c3180c0a..4306b8d6 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage";
-import { writeToLog } from "@/utils/log";
+import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { fetchAndParseSegments } from "@/utils/segments";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
@@ -42,37 +42,60 @@ const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
+// Cap progress at 99% to avoid showing 100% before the download is actually complete
+const MAX_PROGRESS_BEFORE_COMPLETION = 99;
+
+// Estimate the total download size in bytes for a job. If the media source
+// provides a Size, use that. Otherwise, if we have a bitrate and run time
+// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
const calculateEstimatedSize = (p: JobStatus): number => {
- let size = p.mediaSource.Size;
- const maxBitrate = p.maxBitrate.value;
- if (
- maxBitrate &&
- size &&
- p.mediaSource.Bitrate &&
- maxBitrate < p.mediaSource.Bitrate
- ) {
- size = (size / p.mediaSource.Bitrate) * maxBitrate;
- }
- // This function is for estimated size, so just return the adjusted size
- return size ?? 0;
-};
+ const size = p.mediaSource?.Size || 0;
+ const maxBitrate = p.maxBitrate?.value;
+ const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
-// Helper to calculate download speed
-const calculateSpeed = (
- process: JobStatus,
- newBytesDownloaded: number,
-): number | undefined => {
- const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
- const deltaBytes = newBytesDownloaded - oldBytes;
-
- if (lastProgressUpdateTime && deltaBytes > 0) {
- const deltaTimeInSeconds =
- (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
- if (deltaTimeInSeconds > 0) {
- return deltaBytes / deltaTimeInSeconds;
+ if (!size && maxBitrate && runTimeTicks > 0) {
+ // Jellyfin RunTimeTicks are in 10,000,000 ticks per second
+ const seconds = runTimeTicks / 10000000;
+ if (seconds > 0) {
+ // maxBitrate is in bits per second; convert to bytes
+ return Math.round((maxBitrate / 8) * seconds);
}
}
- return undefined;
+
+ return size || 0;
+};
+
+// Calculate download speed in bytes/sec based on a job's last update time
+// and previously recorded bytesDownloaded.
+const calculateSpeed = (
+ p: JobStatus,
+ currentBytesDownloaded?: number,
+): number | undefined => {
+ // Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
+ const now = Date.now();
+
+ if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
+ const last = new Date(p.lastSessionUpdateTime).getTime();
+ const deltaTime = (now - last) / 1000;
+ if (deltaTime > 0) {
+ const current =
+ currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
+ const deltaBytes = current - p.lastSessionBytes;
+ if (deltaBytes > 0) return deltaBytes / deltaTime;
+ }
+ }
+
+ // Fallback to total-based deltas for compatibility
+ if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
+ return undefined;
+ const last = new Date(p.lastProgressUpdateTime).getTime();
+ const deltaTime = (now - last) / 1000;
+ if (deltaTime <= 0) return undefined;
+ const prev = p.bytesDownloaded || 0;
+ const current = currentBytesDownloaded ?? prev;
+ const deltaBytes = current - prev;
+ if (deltaBytes <= 0) return undefined;
+ return deltaBytes / deltaTime;
};
export const processesAtom = atom([]);
@@ -170,27 +193,96 @@ function useDownloadProvider() {
const currentProcesses = [...processes, ...missingProcesses];
const updatedProcesses = currentProcesses.map((p) => {
- // fallback. Doesn't really work for transcodes as they may be a lot smaller.
- // We make an wild guess by comparing bitrates
+ // Enhanced filtering to prevent iOS zombie task interference
+ // Only update progress for downloads that are actively downloading
+ if (p.status !== "downloading") {
+ return p;
+ }
+
+ // Find task for this process
const task = tasks.find((s: any) => s.id === p.id);
+ if (!task) {
+ return p; // No task found, keep current state
+ }
+
+ /*
+ // TODO: Uncomment this block to re-enable iOS zombie task detection
+ // iOS: Extra validation to prevent zombie task interference
+ if (Platform.OS === "ios") {
+ // Check if we have multiple tasks for same ID (zombie detection)
+ const tasksForId = tasks.filter((t: any) => t.id === p.id);
+ if (tasksForId.length > 1) {
+ console.warn(
+ `[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
+ );
+ return p; // Don't update progress from potentially conflicting tasks
+ }
+
+ // If task state looks suspicious (e.g., iOS task stuck in background), be conservative
+ if (
+ task.state &&
+ ["SUSPENDED", "PAUSED"].includes(task.state) &&
+ p.status === "downloading"
+ ) {
+ console.warn(
+ `[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
+ );
+ return p;
+ }
+ }
+ */
+
if (task && p.status === "downloading") {
const estimatedSize = calculateEstimatedSize(p);
let progress = p.progress;
- if (estimatedSize > 0) {
- progress = (100 / estimatedSize) * task.bytesDownloaded;
+
+ // If we have a pausedProgress snapshot then merge current session
+ // progress into it. We accept pausedProgress === 0 as valid because
+ // users can pause immediately after starting.
+ if (p.pausedProgress !== undefined) {
+ const totalBytesDownloaded =
+ (p.pausedBytes ?? 0) + task.bytesDownloaded;
+
+ // Calculate progress based on total bytes downloaded vs estimated size
+ progress =
+ estimatedSize > 0
+ ? (totalBytesDownloaded / estimatedSize) * 100
+ : 0;
+
+ // Use the total accounted bytes when computing speed so the
+ // displayed speed and progress remain consistent after resume.
+ const speed = calculateSpeed(p, totalBytesDownloaded);
+
+ return {
+ ...p,
+ progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
+ speed,
+ bytesDownloaded: totalBytesDownloaded,
+ lastProgressUpdateTime: new Date(),
+ estimatedTotalSizeBytes: estimatedSize,
+ // Set session bytes to total bytes downloaded
+ lastSessionBytes: totalBytesDownloaded,
+ lastSessionUpdateTime: new Date(),
+ };
+ } else {
+ if (estimatedSize > 0) {
+ progress = (100 / estimatedSize) * task.bytesDownloaded;
+ }
+ if (progress >= 100) {
+ progress = MAX_PROGRESS_BEFORE_COMPLETION;
+ }
+ const speed = calculateSpeed(p, task.bytesDownloaded);
+ return {
+ ...p,
+ progress,
+ speed,
+ bytesDownloaded: task.bytesDownloaded,
+ lastProgressUpdateTime: new Date(),
+ estimatedTotalSizeBytes: estimatedSize,
+ lastSessionBytes: task.bytesDownloaded,
+ lastSessionUpdateTime: new Date(),
+ };
}
- if (progress >= 100) {
- progress = 99;
- }
- const speed = calculateSpeed(p, task.bytesDownloaded);
- return {
- ...p,
- progress,
- speed,
- bytesDownloaded: task.bytesDownloaded,
- lastProgressUpdateTime: new Date(),
- estimatedTotalSizeBytes: estimatedSize,
- };
}
return p;
});
@@ -372,10 +464,76 @@ function useDownloadProvider() {
async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id");
+ // Enhanced cleanup for existing tasks to prevent duplicates
+ try {
+ const allTasks = await BackGroundDownloader.checkForExistingDownloads();
+ const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
+
+ if (existingTasks && existingTasks.length > 0) {
+ console.log(
+ `[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
+ );
+
+ for (let i = 0; i < existingTasks.length; i++) {
+ const existingTask = existingTasks[i];
+ console.log(
+ `[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
+ );
+
+ try {
+ /*
+ // TODO: Uncomment this block to re-enable iOS-specific cleanup
+ // iOS: More aggressive cleanup sequence
+ if (Platform.OS === "ios") {
+ try {
+ await existingTask.pause();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ } catch (_pauseErr) {
+ // Ignore pause errors
+ }
+
+ await existingTask.stop();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Multiple complete handler calls to ensure cleanup
+ BackGroundDownloader.completeHandler(process.id);
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ } else {
+ */
+
+ // Simple cleanup for all platforms (currently Android only)
+ await existingTask.stop();
+ BackGroundDownloader.completeHandler(process.id);
+
+ /* } // End of iOS block - uncomment when re-enabling iOS functionality */
+
+ console.log(
+ `[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
+ );
+ } catch (taskError) {
+ console.warn(
+ `[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
+ taskError,
+ );
+ }
+ }
+
+ // Cleanup delay (simplified for Android)
+ const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
+ await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
+ console.log(`[START] Cleanup completed for ${process.id}`);
+ }
+ } catch (error) {
+ console.warn(
+ `[START] Failed to check/cleanup existing tasks for ${process.id}:`,
+ error,
+ );
+ }
+
updateProcess(process.id, {
speed: undefined,
status: "downloading",
- progress: 0,
+ progress: process.progress || 0, // Preserve existing progress for resume
});
BackGroundDownloader?.setConfig({
@@ -396,21 +554,42 @@ function useDownloadProvider() {
.begin(() => {
updateProcess(process.id, {
status: "downloading",
- progress: 0,
- bytesDownloaded: 0,
+ progress: process.progress || 0,
+ bytesDownloaded: process.bytesDownloaded || 0,
lastProgressUpdateTime: new Date(),
+ lastSessionBytes: process.lastSessionBytes || 0,
+ lastSessionUpdateTime: new Date(),
});
})
.progress(
throttle((data) => {
updateProcess(process.id, (currentProcess) => {
- const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
+ // If this is a resumed download, add the paused bytes to current session bytes
+ const resumedBytes = currentProcess.pausedBytes || 0;
+ const totalBytes = data.bytesDownloaded + resumedBytes;
+
+ // Calculate progress based on total bytes if we have resumed bytes
+ let percent: number;
+ if (resumedBytes > 0 && data.bytesTotal > 0) {
+ // For resumed downloads, calculate based on estimated total size
+ const estimatedTotal =
+ currentProcess.estimatedTotalSizeBytes ||
+ data.bytesTotal + resumedBytes;
+ percent = (totalBytes / estimatedTotal) * 100;
+ } else {
+ // For fresh downloads, use normal calculation
+ percent = (data.bytesDownloaded / data.bytesTotal) * 100;
+ }
+
return {
- speed: calculateSpeed(currentProcess, data.bytesDownloaded),
+ speed: calculateSpeed(currentProcess, totalBytes),
status: "downloading",
- progress: percent,
- bytesDownloaded: data.bytesDownloaded,
+ progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
+ bytesDownloaded: totalBytes,
lastProgressUpdateTime: new Date(),
+ // update session-only counters - use current session bytes only for speed calc
+ lastSessionBytes: data.bytesDownloaded,
+ lastSessionUpdateTime: new Date(),
};
});
}, 500),
@@ -542,7 +721,17 @@ function useDownloadProvider() {
if (activeDownloads < concurrentLimit) {
const queuedDownload = processes.find((p) => p.status === "queued");
if (queuedDownload) {
- startDownload(queuedDownload);
+ // Reserve the slot immediately to avoid race where startDownload's
+ // asynchronous begin callback hasn't executed yet and multiple
+ // downloads are started, bypassing the concurrent limit.
+ updateProcess(queuedDownload.id, { status: "downloading" });
+ startDownload(queuedDownload).catch((error) => {
+ console.error("Failed to start download:", error);
+ updateProcess(queuedDownload.id, { status: "error" });
+ toast.error(t("home.downloads.toasts.failed_to_start_download"), {
+ description: error.message || "Unknown error",
+ });
+ });
}
}
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
@@ -551,8 +740,38 @@ function useDownloadProvider() {
async (id: string) => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
- task?.stop();
- BackGroundDownloader.completeHandler(id);
+ if (task) {
+ // On iOS, suspended tasks need to be cancelled properly
+ if (Platform.OS === "ios") {
+ const state = task.state || task.state?.();
+ if (
+ state === "PAUSED" ||
+ state === "paused" ||
+ state === "SUSPENDED" ||
+ state === "suspended"
+ ) {
+ // For suspended tasks, we need to resume first, then stop
+ try {
+ await task.resume();
+ // Small delay to allow resume to take effect
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } catch (_resumeError) {
+ // Resume might fail, continue with stop
+ }
+ }
+ }
+
+ try {
+ task.stop();
+ } catch (_err) {
+ // ignore stop errors
+ }
+ try {
+ BackGroundDownloader.completeHandler(id);
+ } catch (_err) {
+ // ignore
+ }
+ }
setProcesses((prev) => prev.filter((process) => process.id !== id));
manageDownloadQueue();
},
@@ -575,7 +794,7 @@ function useDownloadProvider() {
intermediates: true,
});
} catch (_error) {
- toast.error(t("Failed to clean cache directory."));
+ toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
}
};
@@ -611,9 +830,13 @@ function useDownloadProvider() {
status: "queued",
timestamp: new Date(),
};
- setProcesses((prev) => [...prev, job]);
+ setProcesses((prev) => {
+ // Remove any existing processes for this item to prevent duplicates
+ const filtered = prev.filter((p) => p.id !== item.Id);
+ return [...filtered, job];
+ });
toast.success(
- t("home.downloads.toasts.download_stated_for_item", {
+ t("home.downloads.toasts.download_started_for_item", {
item: item.Name,
}),
{
@@ -791,12 +1014,99 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
+ // TODO: iOS pause functionality temporarily disabled due to background task issues
+ // Remove this check to re-enable iOS pause functionality in the future
+ if (Platform.OS === "ios") {
+ console.warn(
+ `[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
+ );
+ throw new Error("Pause functionality is currently disabled on iOS");
+ }
+
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
if (!task) throw new Error("No task found");
- task.pause();
- updateProcess(id, { status: "paused" });
+ // Get current progress before stopping
+ const currentProgress = process.progress;
+ const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
+
+ console.log(
+ `[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
+ );
+
+ try {
+ /*
+ // TODO: Uncomment this block to re-enable iOS pause functionality
+ // iOS-specific aggressive cleanup approach based on GitHub issue #26
+ if (Platform.OS === "ios") {
+ // Get ALL tasks for this ID - there might be multiple zombie tasks
+ const allTasks =
+ await BackGroundDownloader.checkForExistingDownloads();
+ const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
+
+ console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
+
+ // Stop ALL tasks for this ID to prevent zombie processes
+ for (let i = 0; i < tasksForId.length; i++) {
+ const taskToStop = tasksForId[i];
+ console.log(
+ `[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
+ );
+
+ try {
+ // iOS: pause β stop sequence with delays (based on issue research)
+ await taskToStop.pause();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ await taskToStop.stop();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ console.log(
+ `[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
+ );
+ } catch (taskError) {
+ console.warn(
+ `[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
+ taskError,
+ );
+ }
+ }
+
+ // Extra cleanup delay for iOS NSURLSession to fully stop
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ } else {
+ */
+
+ // Android: simpler approach (currently the only active platform)
+ await task.stop();
+
+ /* } // End of iOS block - uncomment when re-enabling iOS functionality */
+
+ // Clean up the native task handler
+ try {
+ BackGroundDownloader.completeHandler(id);
+ } catch (_err) {
+ console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
+ }
+
+ // Update process state to paused
+ updateProcess(id, {
+ status: "paused",
+ progress: currentProgress,
+ bytesDownloaded: currentBytes,
+ pausedAt: new Date(),
+ pausedProgress: currentProgress,
+ pausedBytes: currentBytes,
+ lastSessionBytes: process.lastSessionBytes ?? currentBytes,
+ lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
+ });
+
+ console.log(`Download paused successfully: ${id}`);
+ } catch (error) {
+ console.error("Error pausing task:", error);
+ throw error;
+ }
},
[processes, updateProcess],
);
@@ -806,38 +1116,79 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
- const tasks = await BackGroundDownloader.checkForExistingDownloads();
- const task = tasks?.find((t: any) => t.id === id);
- if (!task) throw new Error("No task found");
-
- // Check if task state allows resuming
- if (task.state === "FAILED") {
+ // TODO: iOS resume functionality temporarily disabled due to background task issues
+ // Remove this check to re-enable iOS resume functionality in the future
+ if (Platform.OS === "ios") {
console.warn(
- "Download task failed, cannot resume. Restarting download.",
+ `[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
);
- // For failed tasks, we need to restart rather than resume
- await startDownload(process);
- return;
+ throw new Error("Resume functionality is currently disabled on iOS");
}
- try {
- task.resume();
- updateProcess(id, { status: "downloading" });
- } catch (error: any) {
- // Handle specific ERROR_CANNOT_RESUME error
- if (
- error?.error === "ERROR_CANNOT_RESUME" ||
- error?.errorCode === 1008
- ) {
- console.warn("Cannot resume download, attempting to restart instead");
- await startDownload(process);
- return; // Return early to prevent error from bubbling up
- } else {
- // Only log error for non-handled cases
- console.error("Error resuming download:", error);
- throw error; // Re-throw other errors
+ console.log(
+ `[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
+ );
+
+ /*
+ // TODO: Uncomment this block to re-enable iOS resume functionality
+ // Enhanced cleanup for iOS based on GitHub issue research
+ if (Platform.OS === "ios") {
+ try {
+ // Clean up any lingering zombie tasks first (critical for iOS)
+ const allTasks =
+ await BackGroundDownloader.checkForExistingDownloads();
+ const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
+
+ if (existingTasks.length > 0) {
+ console.log(
+ `[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
+ );
+
+ for (const task of existingTasks) {
+ try {
+ await task.stop();
+ BackGroundDownloader.completeHandler(id);
+ } catch (cleanupError) {
+ console.warn(`[RESUME] Cleanup error:`, cleanupError);
+ }
+ }
+
+ // Wait for iOS cleanup to complete
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ } catch (error) {
+ console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
}
}
+ */
+
+ // Simple approach: always restart the download from where we left off
+ // This works consistently across all platforms (currently Android only)
+ if (
+ process.pausedProgress !== undefined &&
+ process.pausedBytes !== undefined
+ ) {
+ // We have saved pause state - restore it and restart
+ updateProcess(id, {
+ progress: process.pausedProgress,
+ bytesDownloaded: process.pausedBytes,
+ status: "downloading",
+ // Reset session counters for proper speed calculation
+ lastSessionBytes: process.pausedBytes,
+ lastSessionUpdateTime: new Date(),
+ });
+
+ // Small delay to ensure any cleanup in startDownload completes
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const updatedProcess = processes.find((p) => p.id === id);
+ await startDownload(updatedProcess || process);
+
+ console.log(`Download resumed successfully: ${id}`);
+ } else {
+ // No pause state - start from beginning
+ await startDownload(process);
+ }
},
[processes, updateProcess, startDownload],
);
@@ -861,6 +1212,21 @@ function useDownloadProvider() {
cleanCacheDirectory,
updateDownloadedItem,
appSizeUsage,
+ dumpDownloadDiagnostics: async (id?: string) => {
+ // Collect JS-side processes and native task info (best-effort)
+ const tasks = BackGroundDownloader
+ ? await BackGroundDownloader.checkForExistingDownloads()
+ : [];
+ const extra: any = {
+ processes,
+ nativeTasks: tasks || [],
+ };
+ if (id) {
+ const p = processes.find((x) => x.id === id);
+ extra.focusedProcess = p || null;
+ }
+ return dumpDownloadDiagnostics(extra);
+ },
};
}
diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts
index ee74b25d..cff87ddf 100644
--- a/providers/Downloads/types.ts
+++ b/providers/Downloads/types.ts
@@ -129,4 +129,14 @@ export type JobStatus = {
/** Estimated total size of the download in bytes (optional) this is used when we
* download transcoded content because we don't know the size of the file until it's downloaded */
estimatedTotalSizeBytes?: number;
+ /** Timestamp when the download was paused (optional) */
+ pausedAt?: Date;
+ /** Progress percentage when download was paused (optional) */
+ pausedProgress?: number;
+ /** Bytes downloaded when download was paused (optional) */
+ pausedBytes?: number;
+ /** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
+ lastSessionBytes?: number;
+ /** Timestamp when the session-only bytes were last updated. */
+ lastSessionUpdateTime?: Date;
};
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 6340bcce..3c07dd48 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.36.0" },
+ clientInfo: { name: "Streamyfin", version: "0.38.0" },
deviceInfo: {
name: deviceName,
id,
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
- }, DeviceId="${deviceId}", Version="0.36.0"`,
+ }, DeviceId="${deviceId}", Version="0.38.0"`,
};
}, [deviceId]);
diff --git a/translations/en.json b/translations/en.json
index 834fd183..8578567a 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -223,7 +223,9 @@
"system": "System"
},
"toasts": {
- "error_deleting_files": "Error Deleting Files"
+ "error_deleting_files": "Error Deleting Files",
+ "background_downloads_enabled": "Background downloads enabled",
+ "background_downloads_disabled": "Background downloads disabled"
}
},
"sessions": {
@@ -266,11 +268,23 @@
"download_completed": "Download Completed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download Completed for {{item}}",
+ "download_started_for_item": "Download Started for {{item}}",
+ "failed_to_start_download": "Failed to start download",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
+ "failed_to_clean_cache_directory": "Failed to clean cache directory",
+ "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads"
}
}
},
+ "common": {
+ "select": "Select",
+ "no_trailer_available": "No trailer available",
+ "video": "Video",
+ "audio": "Audio",
+ "subtitle": "Subtitle",
+ "play": "Play"
+ },
"search": {
"search": "Search...",
"x_items": "{{count}} Items",
diff --git a/utils/log.tsx b/utils/log.tsx
index 88ca475e..956f1fb0 100644
--- a/utils/log.tsx
+++ b/utils/log.tsx
@@ -77,6 +77,17 @@ export const clearLogs = () => {
storage.delete("logs");
};
+export const dumpDownloadDiagnostics = (extra: any = {}) => {
+ const diagnostics = {
+ timestamp: new Date().toISOString(),
+ processes: extra?.processes || [],
+ nativeTasks: extra?.nativeTasks || [],
+ focusedProcess: extra?.focusedProcess || null,
+ };
+ writeDebugLog("Download diagnostics", diagnostics);
+ return diagnostics;
+};
+
export function useLog() {
const context = useContext(LogContext);
if (context === null) {