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) {