diff --git a/.github/workflows/artifact-comment.yml b/.github/workflows/artifact-comment.yml new file mode 100644 index 00000000..949cb8a9 --- /dev/null +++ b/.github/workflows/artifact-comment.yml @@ -0,0 +1,478 @@ +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 (more precise detection) + const targetRepo = context.repo.owner + '/' + context.repo.repo; + const prHeadRepo = context.payload.pull_request?.head?.repo?.full_name; + const workflowHeadRepo = context.payload.workflow_run?.head_repository?.full_name; + + // For debugging + console.log('🔍 Repository detection:'); + console.log('- Target repository:', targetRepo); + console.log('- PR head repository:', prHeadRepo || 'N/A'); + console.log('- Workflow head repository:', workflowHeadRepo || 'N/A'); + console.log('- Event name:', context.eventName); + + // Only skip if it's actually a different repository (fork) + const isFromFork = prHeadRepo && prHeadRepo !== targetRepo; + const workflowFromFork = workflowHeadRepo && workflowHeadRepo !== targetRepo; + + if (isFromFork || workflowFromFork) { + console.log('🚫 Workflow running from fork - skipping comment creation to avoid permission errors'); + console.log('Fork repository:', prHeadRepo || workflowHeadRepo); + console.log('Target repository:', targetRepo); + return; + } + + console.log('✅ Same repository - proceeding with comment creation'); // 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/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 00000000..b181d42a --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,34 @@ +name: Crowdin Action + +on: + push: + branches: [ main ] + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: true + download_translations: true + localization_branch_name: l10n_crowdin_translations + create_pull_request: true + pull_request_title: 'feat: New Crowdin Translations' + pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' + pull_request_base_branch_name: 'develop' + env: + # A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository). + GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} + + # A numeric ID, found at https://crowdin.com/project//tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + + # Visit https://crowdin.com/settings#api-key to create this token + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/app.json b/app.json index 3918041c..ad8d110b 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.38.0", + "version": "0.39.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -38,7 +38,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 70, + "versionCode": 71, "adaptiveIcon": { "foregroundImage": "./assets/images/icon-android-plain.png", "monochromeImage": "./assets/images/icon-android-themed.png", diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 14de64f9..75562a3b 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -93,6 +93,19 @@ export default function page() { } }, [downloadedFiles]); + const otherMedia = useMemo(() => { + try { + return ( + downloadedFiles?.filter( + (f) => f.item.Type !== "Movie" && f.item.Type !== "Episode", + ) || [] + ); + } catch { + setShowMigration(true); + return []; + } + }, [downloadedFiles]); + useEffect(() => { navigation.setOptions({ headerRight: () => ( @@ -131,8 +144,30 @@ export default function page() { writeToLog("ERROR", reason); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); }); + const deleteOtherMedia = () => + Promise.all( + otherMedia.map((item) => + deleteFileByType(item.item.Type) + .then(() => + toast.success( + t("home.downloads.toasts.deleted_media_successfully", { + type: item.item.Type, + }), + ), + ) + .catch((reason) => { + writeToLog("ERROR", reason); + toast.error( + t("home.downloads.toasts.failed_to_delete_media", { + type: item.item.Type, + }), + ); + }), + ), + ); + const deleteAllMedia = async () => - await Promise.all([deleteMovies(), deleteShows()]); + await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]); return ( <> @@ -241,6 +276,34 @@ export default function page() { )} + + {otherMedia.length > 0 && ( + + + + {t("home.downloads.other_media")} + + + + {otherMedia?.length} + + + + + + {otherMedia?.map((item) => ( + + + + ))} + + + + )} {downloadedFiles?.length === 0 && ( @@ -276,6 +339,11 @@ export default function page() { + {otherMedia.length > 0 && ( + + )} diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 60dd9e1e..86276e73 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -23,112 +23,117 @@ export default function IndexLayout() { headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, - headerRight: () => ( - - } - title={t("library.options.display")} - groups={[ - { - title: t("library.options.display"), - options: [ - { - type: "radio", - label: t("library.options.row"), - value: "row", - selected: settings.libraryOptions.display === "row", - onPress: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "row", - }, - }), - }, - { - type: "radio", - label: t("library.options.list"), - value: "list", - selected: settings.libraryOptions.display === "list", - onPress: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - display: "list", - }, - }), - }, - ], - }, - { - title: t("library.options.image_style"), - options: [ - { - type: "radio", - label: t("library.options.poster"), - value: "poster", - selected: settings.libraryOptions.imageStyle === "poster", - onPress: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "poster", - }, - }), - }, - { - type: "radio", - label: t("library.options.cover"), - value: "cover", - selected: settings.libraryOptions.imageStyle === "cover", - onPress: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - imageStyle: "cover", - }, - }), - }, - ], - }, - { - title: "Options", - options: [ - { - type: "toggle", - label: t("library.options.show_titles"), - value: settings.libraryOptions.showTitles, - onToggle: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showTitles: !settings.libraryOptions.showTitles, - }, - }), - disabled: settings.libraryOptions.imageStyle === "poster", - }, - { - type: "toggle", - label: t("library.options.show_stats"), - value: settings.libraryOptions.showStats, - onToggle: () => - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showStats: !settings.libraryOptions.showStats, - }, - }), - }, - ], - }, - ]} - /> - ), + headerRight: () => + !pluginSettings?.libraryOptions?.locked && + !Platform.isTV && ( + + } + title={t("library.options.display")} + groups={[ + { + title: t("library.options.display"), + options: [ + { + type: "radio", + label: t("library.options.row"), + value: "row", + selected: settings.libraryOptions.display === "row", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }), + }, + { + type: "radio", + label: t("library.options.list"), + value: "list", + selected: settings.libraryOptions.display === "list", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }), + }, + ], + }, + { + title: t("library.options.image_style"), + options: [ + { + type: "radio", + label: t("library.options.poster"), + value: "poster", + selected: + settings.libraryOptions.imageStyle === "poster", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }), + }, + { + type: "radio", + label: t("library.options.cover"), + value: "cover", + selected: + settings.libraryOptions.imageStyle === "cover", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }), + }, + ], + }, + { + title: "Options", + options: [ + { + type: "toggle", + label: t("library.options.show_titles"), + value: settings.libraryOptions.showTitles, + onToggle: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: !settings.libraryOptions.showTitles, + }, + }), + disabled: + settings.libraryOptions.imageStyle === "poster", + }, + { + type: "toggle", + label: t("library.options.show_stats"), + value: settings.libraryOptions.showStats, + onToggle: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: !settings.libraryOptions.showStats, + }, + }), + }, + ], + }, + ]} + /> + ), }} /> (); - useSettings(); + const { settings } = useSettings(); const offline = offlineStr === "true"; const playbackManager = usePlaybackManager(); @@ -560,8 +566,34 @@ export default function page() { ? allSubs.indexOf(chosenSubtitleTrack) : [...textSubs].reverse().indexOf(chosenSubtitleTrack); initOptions.push(`--sub-track=${finalIndex}`); - } + // Add VLC subtitle styling options from settings + const textColor = (settings.vlcTextColor ?? "White") as VLCColor; + const backgroundColor = (settings.vlcBackgroundColor ?? + "Black") as VLCColor; + const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor; + const outlineThickness = (settings.vlcOutlineThickness ?? + "Normal") as OutlineThickness; + const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128; + const outlineOpacity = settings.vlcOutlineOpacity ?? 255; + const isBold = settings.vlcIsBold ?? false; + // Add subtitle styling options + initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`); + initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`); + initOptions.push( + `--freetype-background-color=${VLC_COLORS[backgroundColor]}`, + ); + initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`); + initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`); + initOptions.push( + `--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`, + ); + initOptions.push(`--sub-text-scale=${settings.subtitleSize}`); + initOptions.push("--sub-margin=40"); + if (isBold) { + initOptions.push("--freetype-bold"); + } + } if (notTranscoding && chosenAudioTrack) { initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); } diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index e9c8ab97..b0a5d555 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -37,7 +37,7 @@ export const MovieCard: React.FC = ({ item }) => { */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id, "Movie"); + deleteFile(item.Id, item.Type); } }, [deleteFile, item.Id]); diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 89f6a2e9..117152fc 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -40,7 +40,6 @@ export const StorageSettings = () => { }; const calculatePercentage = (value: number, total: number) => { - console.log("usage", value, total); return ((value / total) * 100).toFixed(2); }; diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 53fb2a99..5389d15e 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -5,6 +5,12 @@ import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; import { Stepper } from "@/components/inputs/Stepper"; +import { + OUTLINE_THICKNESS, + type OutlineThickness, + VLC_COLORS, + type VLCColor, +} from "@/constants/SubtitleConstants"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; @@ -86,6 +92,84 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { ]; }, [settings?.subtitleMode, t, updateSettings]); + const textColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcTextColor || "White") === color, + onPress: () => updateSettings({ vlcTextColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcTextColor, t, updateSettings]); + + const backgroundColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcBackgroundColor || "Black") === color, + onPress: () => updateSettings({ vlcBackgroundColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcBackgroundColor, t, updateSettings]); + + const outlineColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcOutlineColor || "Black") === color, + onPress: () => updateSettings({ vlcOutlineColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineColor, t, updateSettings]); + + const outlineThicknessOptionGroups = useMemo(() => { + const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[]; + const options = thicknesses.map((thickness) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.thickness.${thickness}`), + value: thickness, + selected: (settings?.vlcOutlineThickness || "Normal") === thickness, + onPress: () => updateSettings({ vlcOutlineThickness: thickness }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineThickness, t, updateSettings]); + + const backgroundOpacityOptionGroups = useMemo(() => { + const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; + const options = opacities.map((opacity) => ({ + type: "radio" as const, + label: `${Math.round((opacity / 255) * 100)}%`, + value: opacity, + selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity, + onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }), + })); + + return [{ options }]; + }, [settings?.vlcBackgroundOpacity, updateSettings]); + + const outlineOpacityOptionGroups = useMemo(() => { + const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; + const options = opacities.map((opacity) => ({ + type: "radio" as const, + label: `${Math.round((opacity / 255) * 100)}%`, + value: opacity, + selected: (settings?.vlcOutlineOpacity ?? 255) === opacity, + onPress: () => updateSettings({ vlcOutlineOpacity: opacity }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineOpacity, updateSettings]); + if (isTv) return null; if (!settings) return null; @@ -168,6 +252,124 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { onUpdate={(subtitleSize) => updateSettings({ subtitleSize })} /> + + + + {t( + `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`, + )} + + + + } + title={t("home.settings.subtitles.text_color")} + /> + + + + + {t( + `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`, + )} + + + + } + title={t("home.settings.subtitles.background_color")} + /> + + + + + {t( + `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`, + )} + + + + } + title={t("home.settings.subtitles.outline_color")} + /> + + + + + {t( + `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`, + )} + + + + } + title={t("home.settings.subtitles.outline_thickness")} + /> + + + + {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`} + + + } + title={t("home.settings.subtitles.background_opacity")} + /> + + + + {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`} + + + } + title={t("home.settings.subtitles.outline_opacity")} + /> + + + updateSettings({ vlcIsBold: value })} + /> + ); diff --git a/constants/SubtitleConstants.ts b/constants/SubtitleConstants.ts new file mode 100644 index 00000000..7fc7a8e6 --- /dev/null +++ b/constants/SubtitleConstants.ts @@ -0,0 +1,45 @@ +export type VLCColor = + | "Black" + | "Gray" + | "Silver" + | "White" + | "Maroon" + | "Red" + | "Fuchsia" + | "Yellow" + | "Olive" + | "Green" + | "Teal" + | "Lime" + | "Purple" + | "Navy" + | "Blue" + | "Aqua"; + +export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick"; + +export const VLC_COLORS: Record = { + Black: 0, + Gray: 8421504, + Silver: 12632256, + White: 16777215, + Maroon: 8388608, + Red: 16711680, + Fuchsia: 16711935, + Yellow: 16776960, + Olive: 8421376, + Green: 32768, + Teal: 32896, + Lime: 65280, + Purple: 8388736, + Navy: 128, + Blue: 255, + Aqua: 65535, +}; + +export const OUTLINE_THICKNESS: Record = { + None: 0, + Thin: 2, + Normal: 4, + Thick: 6, +}; diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..38b86bcd --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,12 @@ +"project_id_env": "CROWDIN_PROJECT_ID" +"api_token_env": "CROWDIN_PERSONAL_TOKEN" +"base_path": "." + +"preserve_hierarchy": true + +"files": [ + { + "source": "translations/en.json", + "translation": "translations/%two_letters_code%.json" + } +] \ No newline at end of file diff --git a/eas.json b/eas.json index f7918986..a6c4cf52 100644 --- a/eas.json +++ b/eas.json @@ -45,14 +45,14 @@ }, "production": { "environment": "production", - "channel": "0.38.0", + "channel": "0.39.0", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.38.0", + "channel": "0.39.0", "android": { "buildType": "apk", "image": "latest" @@ -60,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.38.0", + "channel": "0.39.0", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 74065768..8942a596 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -395,7 +395,7 @@ function useDownloadProvider() { return db.movies[id]; } - // If not in movies, check episodes + // Check episodes for (const series of Object.values(db.series)) { for (const season of Object.values(series.seasons)) { for (const episode of Object.values(season.episodes)) { @@ -408,6 +408,11 @@ function useDownloadProvider() { } console.log(`[DB] No item found with ID: ${id}`); + // Check other media types + if (db.other[id]) { + return db.other[id]; + } + return undefined; }; @@ -448,7 +453,7 @@ function useDownloadProvider() { const db = JSON.parse(file) as DownloadsDatabase; return db; } - return { movies: {}, series: {} }; + return { movies: {}, series: {}, other: {} }; // Initialize other media types storage }; const getDownloadedItems = useCallback(() => { @@ -459,23 +464,8 @@ function useDownloadProvider() { Object.values(season.episodes), ), ); - const allItems = [...movies, ...episodes]; - - // Only log when there are items to avoid spam - if (allItems.length > 0) { - console.log( - `[DB] Retrieved ${movies.length} movies and ${episodes.length} episodes from database`, - ); - console.log(`[DB] Total downloaded items: ${allItems.length}`); - - // Log details of each item for debugging - allItems.forEach((item, index) => { - console.log( - `[DB] Item ${index + 1}: ${item.item.Name} - Path: ${item.videoFilePath}, Size: ${item.videoFileSize}`, - ); - }); - } - + const otherItems = Object.values(db.other); + const allItems = [...movies, ...episodes, ...otherItems]; return allItems; }, []); @@ -777,6 +767,9 @@ function useDownloadProvider() { db.series[item.SeriesId].seasons[seasonNumber].episodes[ episodeNumber ] = downloadedItem; + } else if (item.Id) { + // Handle other media types + db.other[item.Id] = downloadedItem; } await saveDownloadsDatabase(db); @@ -947,16 +940,16 @@ function useDownloadProvider() { [authHeader, startDownload], ); - const deleteFile = async (id: string, type: "Movie" | "Episode") => { + const deleteFile = async (id: string, type: BaseItemDto["Type"]) => { const db = getDownloadsDatabase(); let downloadedItem: DownloadedItem | undefined; - if (type === "Movie") { + if (type === "Movie" && Object.entries(db.movies).length !== 0) { downloadedItem = db.movies[id]; if (downloadedItem) { delete db.movies[id]; } - } else if (type === "Episode") { + } else if (type === "Episode" && Object.entries(db.series).length !== 0) { const cleanUpEmptyParents = ( series: any, seasonNumber: string, @@ -986,6 +979,12 @@ function useDownloadProvider() { } if (downloadedItem) break; } + } else { + // Handle other media types + downloadedItem = db.other[id]; + if (downloadedItem) { + delete db.other[id]; + } } if (downloadedItem?.videoFilePath) { @@ -1091,7 +1090,7 @@ function useDownloadProvider() { const deleteItems = async (items: BaseItemDto[]) => { for (const item of items) { - if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) { + if (item.Id) { await deleteFile(item.Id, item.Type); } } @@ -1134,6 +1133,8 @@ function useDownloadProvider() { const db = getDownloadsDatabase(); if (db.movies[itemId]) { db.movies[itemId] = updatedItem; + } else if (db.other[itemId]) { + db.other[itemId] = updatedItem; } else { for (const series of Object.values(db.series)) { for (const season of Object.values(series.seasons)) { diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index 3fa1b4e6..409c8291 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -90,6 +90,8 @@ export interface DownloadsDatabase { movies: Record; /** A map of series IDs to their downloaded series data. */ series: Record; + /** A map of IDs to downloaded items that are neither movies nor episodes */ + other: Record; } /** diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index a446d1cf..61f518b7 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.38.0" }, + clientInfo: { name: "Streamyfin", version: "0.39.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.38.0"`, + }, DeviceId="${deviceId}", Version="0.39.0"`, }; }, [deviceId]); diff --git a/translations/en.json b/translations/en.json index 8578567a..98d1daa7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -111,11 +111,11 @@ }, "subtitles": { "subtitle_title": "Subtitles", - "subtitle_language": "Subtitle Language", + "subtitle_hint": "Configure how subtitles look and behave.", + "subtitle_language": "Subtitle language", "subtitle_mode": "Subtitle Mode", "set_subtitle_track": "Set Subtitle Track From Previous Item", "subtitle_size": "Subtitle Size", - "subtitle_hint": "Configure Subtitle Preference.", "none": "None", "language": "Language", "loading": "Loading", @@ -124,7 +124,38 @@ "Smart": "Smart", "Always": "Always", "None": "None", - "OnlyForced": "Only Forced" + "OnlyForced": "OnlyForced" + }, + "text_color": "Text Color", + "background_color": "Background Color", + "outline_color": "Outline Color", + "outline_thickness": "Outline Thickness", + "background_opacity": "Background Opacity", + "outline_opacity": "Outline Opacity", + "bold_text": "Bold Text", + "colors": { + "Black": "Black", + "Gray": "Gray", + "Silver": "Silver", + "White": "White", + "Maroon": "Maroon", + "Red": "Red", + "Fuchsia": "Fuchsia", + "Yellow": "Yellow", + "Olive": "Olive", + "Green": "Green", + "Teal": "Teal", + "Lime": "Lime", + "Purple": "Purple", + "Navy": "Navy", + "Blue": "Blue", + "Aqua": "Aqua" + }, + "thickness": { + "None": "None", + "Thin": "Thin", + "Normal": "Normal", + "Thick": "Thick" } }, "other": { @@ -237,12 +268,14 @@ "tvseries": "TV-Series", "movies": "Movies", "queue": "Queue", + "other_media": "Other media", "queue_hint": "Queue and downloads will be lost on app restart", "no_items_in_queue": "No Items in Queue", "no_downloaded_items": "No Downloaded Items", "delete_all_movies_button": "Delete All Movies", "delete_all_tvseries_button": "Delete All TV-Series", "delete_all_button": "Delete All", + "delete_all_other_media_button": "Delete other media", "active_download": "Active Download", "no_active_downloads": "No Active Downloads", "active_downloads": "Active Downloads", @@ -259,6 +292,8 @@ "failed_to_delete_all_movies": "Failed to Delete All Movies", "deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!", "failed_to_delete_all_tvseries": "Failed to Delete All TV-Series", + "deleted_media_successfully": "Deleted other media Successfully!", + "failed_to_delete_media": "Failed to Delete other media", "download_deleted": "Download Deleted", "could_not_delete_download": "Could Not Delete Download", "download_paused": "Download Paused", diff --git a/translations/hu.json b/translations/hu.json new file mode 100644 index 00000000..9306d339 --- /dev/null +++ b/translations/hu.json @@ -0,0 +1,464 @@ +{ + "login": { + "username_required": "A felhasználónév megadása kötelező", + "error_title": "Hiba", + "login_title": "Bejelentkezés", + "login_to_title": "Bejelentkezés ide", + "username_placeholder": "Felhasználónév", + "password_placeholder": "Jelszó", + "login_button": "Bejelentkezés", + "quick_connect": "Gyorscsatlakozás", + "enter_code_to_login": "Írd be a {{code}} kódot a bejelentkezéshez", + "failed_to_initiate_quick_connect": "A Gyorscsatlakozás kezdeményezése sikertelen.", + "got_it": "Értettem", + "connection_failed": "Kapcsolódás Sikertelen", + "could_not_connect_to_server": "Nem sikerült csatlakozni a szerverhez. Kérjük, ellenőrizd az URL-t és a hálózati kapcsolatot.", + "an_unexpected_error_occured": "Váratlan Hiba Történt", + "change_server": "Szerverváltás", + "invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó", + "user_does_not_have_permission_to_log_in": "A felhasználónak nincs jogosultsága a bejelentkezéshez", + "server_is_taking_too_long_to_respond_try_again_later": "A szerver túl sokáig válaszol, próbáld újra később", + "server_received_too_many_requests_try_again_later": "A szerver túl sok kérést kapott, próbáld újra később.", + "there_is_a_server_error": "Szerverhiba történt", + "an_unexpected_error_occured_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?", + "too_old_server_text": "Nem Támogatott Jellyfin-szerver", + "too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra" + }, + "server": { + "enter_url_to_jellyfin_server": "Add meg a Jellyfin szerver URL-jét", + "server_url_placeholder": "http(s)://a-te-szervered.hu", + "connect_button": "Csatlakozás", + "previous_servers": "Előző Szerverek", + "clear_button": "Törlés", + "search_for_local_servers": "Helyi Szerverek Keresése", + "searching": "Keresés...", + "servers": "Szerverek" + }, + "home": { + "checking_server_connection": "Szerverkapcsolat ellenőrzése...", + "no_internet": "Nincs Internet", + "no_items": "Nincsenek elemek", + "no_internet_message": "Semmi gond, továbbra is nézheted\na letöltött tartalmakat.", + "checking_server_connection_message": "Kapcsolat ellenőrzése a szerverrel", + "go_to_downloads": "Ugrás a Letöltésekhez", + "retry": "Újra", + "server_unreachable": "Szerver Elérhetetlen", + "server_unreachable_message": "Nem sikerült elérni a szervert.\nKérjük, ellenőrizd a hálózati kapcsolatot.", + "oops": "Hoppá!", + "error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.", + "continue_watching": "Nézd Tovább", + "next_up": "Következő", + "recently_added_in": "Új a(z) {{libraryName}} könyvtárban", + "suggested_movies": "Javasolt Filmek", + "suggested_episodes": "Javasolt Epizódok", + "intro": { + "welcome_to_streamyfin": "Üdvözöljük a Streamyfinben", + "a_free_and_open_source_client_for_jellyfin": "Egy Ingyenes és Nyílt Forráskódú Jellyfin Kliens.", + "features_title": "Funkciók", + "features_description": "A Streamyfin számos funkcióval rendelkezik és sokféle szoftverrel integrálható, melyeket a beállítások menüben találhatsz:", + "jellyseerr_feature_description": "Csatlakozz a Jellyseerrhez és kérj filmeket közvetlenül az alkalmazásból.", + "downloads_feature_title": "Letöltések", + "downloads_feature_description": "Töltsd le a filmeket és sorozatokat az offline megtekintéshez. Használhatod az alapértelmezett módszert, vagy telepítheted az 'optimise server'-t a háttérben történő letöltéshez.", + "chromecast_feature_description": "Játszd le a filmeket és sorozatokat a Chromecast eszközeiden.", + "centralised_settings_plugin_title": "Központosított Beállítások Bővítmény", + "centralised_settings_plugin_description": "Konfiguráld a beállításaidat központilag a Jellyfin szerveren. Minden felhasználói kliensbeállítás automatikusan szinkronizálódik.", + "done_button": "Kész", + "go_to_settings_button": "Ugrás a Beállításokhoz", + "read_more": "Bővebben" + }, + "settings": { + "settings_title": "Beállítások", + "log_out_button": "Kijelentkezés", + "user_info": { + "user_info_title": "Felhasználói Információk", + "user": "Felhasználó", + "server": "Szerver", + "token": "Token", + "app_version": "Alkalmazásverzió" + }, + "quick_connect": { + "quick_connect_title": "Gyorscsatlakozás", + "authorize_button": "Gyorscsatlakozás Engedélyezése", + "enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...", + "success": "Siker", + "quick_connect_autorized": "Gyorscsatlakozás Engedélyezve", + "error": "Hiba", + "invalid_code": "Érvénytelen Kód", + "authorize": "Engedélyezés" + }, + "media_controls": { + "media_controls_title": "Médiavezérlés", + "forward_skip_length": "Előre Ugrás Hossza", + "rewind_length": "Visszatekerés Hossza", + "seconds_unit": "mp" + }, + "gesture_controls": { + "gesture_controls_title": "Gesztusvezérlés", + "horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz", + "horizontal_swipe_skip_description": "Ha a vezérlők el vannak rejtve, húzd balra vagy jobbra az ugráshoz.", + "left_side_brightness": "Fényerő a Bal Oldalon", + "left_side_brightness_description": "Húzd felfelé vagy lefelé a bal oldalon a fényerő állításához", + "right_side_volume": "Fényerő a Jobb Oldalon", + "right_side_volume_description": "Húzd felfelé vagy lefelé a jobb oldalon a hangerő állításához" + }, + "audio": { + "audio_title": "Hang", + "set_audio_track": "Hangsáv Beállítása az Előző Elemből", + "audio_language": "Hangsáv Nyelve", + "audio_hint": "Válassz Alapértelmezett Hangsávnyelvet.", + "none": "Nincs", + "language": "Nyelv" + }, + "subtitles": { + "subtitle_title": "Feliratok", + "subtitle_language": "Felirat Nyelve", + "subtitle_mode": "Felirat Módja", + "set_subtitle_track": "Feliratsáv Beállítása az Előző Elemből", + "subtitle_size": "Felirat Mérete", + "subtitle_hint": "Feliratbeállítások Megadása", + "none": "Nincs", + "language": "Nyelv", + "loading": "Betöltés", + "modes": { + "Default": "Alapértelmezett", + "Smart": "Intelligens", + "Always": "Mindig", + "None": "Nincs", + "OnlyForced": "Csak Kényszerített" + } + }, + "other": { + "other_title": "Egyéb", + "follow_device_orientation": "Automatikus Forgatás", + "video_orientation": "Videó Tájolás", + "orientation": "Tájolás", + "orientations": { + "DEFAULT": "Alapértelmezett", + "ALL": "Összes", + "PORTRAIT": "Álló", + "PORTRAIT_UP": "Álló Felfelé", + "PORTRAIT_DOWN": "Álló Lefelé", + "LANDSCAPE": "Fekvő", + "LANDSCAPE_LEFT": "Fekvő Balra", + "LANDSCAPE_RIGHT": "Fekvő Jobbra", + "OTHER": "Egyéb", + "UNKNOWN": "Ismeretlen" + }, + "safe_area_in_controls": "Biztonsági Sáv a Vezérlőkben", + "video_player": "Videólejátszó", + "video_players": { + "VLC_3": "VLC 3", + "VLC_4": "VLC 4 (Kísérleti + PiP)" + }, + "show_custom_menu_links": "Egyéni Menülinkek Megjelenítése", + "hide_libraries": "Könyvtárak Elrejtése", + "select_liraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.", + "disable_haptic_feedback": "Haptikus Visszajelzés Letiltása", + "default_quality": "Alapértelmezett Minőség", + "max_auto_play_episode_count": "Max. Auto. Epizódlejátszás", + "disabled": "Letiltva" + }, + "downloads": { + "downloads_title": "Letöltések", + "remux_max_download": "Remux Maximális Letöltés" + }, + "plugins": { + "plugins_title": "Bővítmények", + "jellyseerr": { + "jellyseerr_warning": "Ez az integráció még korai stádiumban van. Számíts a változásokra.", + "server_url": "Szerver URL", + "server_url_hint": "Példa: http(s)://a-te-szolgáltatód.url\n(adj meg portot, ha szükséges)", + "server_url_placeholder": "Jellyseerr URL...", + "password": "Jelszó", + "password_placeholder": "Add meg a {{username}} Jellyfin felhasználó jelszavát", + "login_button": "Bejelentkezés", + "total_media_requests": "Összes Média Kérés", + "movie_quota_limit": "Film Kvóta Limit", + "movie_quota_days": "Film Kvóta Napok", + "tv_quota_limit": "Sorozat Kvóta Limit", + "tv_quota_days": "Sorozat Kvóta Napok", + "reset_jellyseerr_config_button": "Jellyseerr Beállítások Visszaállítása", + "unlimited": "Korlátlan", + "plus_n_more": "+{{n}} További", + "order_by": { + "DEFAULT": "Alapértelmezett", + "VOTE_COUNT_AND_AVERAGE": "Szavazatok Száma és Átlag", + "POPULARITY": "Népszerűség" + } + }, + "marlin_search": { + "enable_marlin_search": "Marlin Keresés Engedélyezése", + "url": "URL", + "server_url_placeholder": "http(s)://domain.org:port", + "marlin_search_hint": "Add meg a Marlin szerver URL-jét. Az URL-nek tartalmaznia kell a http vagy https-t, és opcionálisan a portot.", + "read_more_about_marlin": "Tudj Meg Többet a Marlinról", + "save_button": "Mentés", + "toasts": { + "saved": "Mentve" + } + } + }, + "storage": { + "storage_title": "Tárhely", + "app_usage": "Alkalmazás {{usedSpace}}%", + "device_usage": "Eszköz {{availableSpace}}%", + "size_used": "{{used}} / {{total}} Használatban", + "delete_all_downloaded_files": "Minden Letöltött Fájl Törlése" + }, + "intro": { + "show_intro": "Bemutató Megjelenítése", + "reset_intro": "Bemutató Visszaállítása" + }, + "logs": { + "logs_title": "Naplók", + "export_logs": "Naplók Exportálása", + "click_for_more_info": "Kattints a Részletekért", + "level": "Szint", + "no_logs_available": "Nincsenek Naplók", + "delete_all_logs": "Összes Napló Törlése" + }, + "languages": { + "title": "Nyelvek", + "app_language": "Alkalmazás Nyelve", + "system": "Rendszer" + }, + "toasts": { + "error_deleting_files": "Hiba a Fájlok Törlésekor" + } + }, + "sessions": { + "title": "Munkamenetek", + "no_active_sessions": "Nincsenek Aktív Munkamenetek" + }, + "downloads": { + "downloads_title": "Letöltések", + "tvseries": "Sorozatok", + "movies": "Filmek", + "queue": "Sor", + "queue_hint": "A sor és a letöltések az alkalmazás újraindításakor elvesznek", + "no_items_in_queue": "Nincs Elem a Sorban", + "no_downloaded_items": "Nincsenek Letöltött Elemek", + "delete_all_movies_button": "Összes Film Törlése", + "delete_all_tvseries_button": "Összes Sorozat Törlése", + "delete_all_button": "Összes Törlése", + "active_download": "Aktív Letöltés", + "no_active_downloads": "Nincs Aktív Letöltés", + "active_downloads": "Aktív Letöltések", + "new_app_version_requires_re_download": "Az Új Alkalmazásverzió Újra Letöltést Igényel", + "new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.", + "back": "Vissza", + "delete": "Törlés", + "something_went_wrong": "Hiba Történt", + "could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből", + "eta": "Várható Idő: {{eta}}", + "toasts": { + "you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.", + "deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!", + "failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet", + "deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!", + "failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot", + "download_deleted": "Letöltés Törölve", + "could_not_delete_download": "Nem Sikerült Törölni a Letöltést", + "download_paused": "Letöltés Szüneteltetve", + "could_not_pause_download": "Nem Sikerült Szüneteltetni a Letöltést", + "download_resumed": "Letöltés Folytatva", + "could_not_resume_download": "Nem Sikerült Folytatni a Letöltést", + "download_completed": "Letöltés Befejezve", + "download_failed_for_item": "A(z) {{item}} letöltése sikertelen - {{error}}", + "download_completed_for_item": "A(z) {{item}} letöltése befejezve", + "all_files_folders_and_jobs_deleted_successfully": "Minden fájl, mappa és feladat sikeresen törölve", + "go_to_downloads": "Ugrás a Letöltésekhez" + } + } + }, + "search": { + "search": "Keresés...", + "x_items": "{{count}} Elem", + "library": "Könyvtár", + "discover": "Felfedezés", + "no_results": "Nincs Eredmény", + "no_results_found_for": "Nincs Eredmény a Kereséshez", + "movies": "Filmek", + "series": "Sorozatok", + "episodes": "Epizódok", + "collections": "Gyűjtemények", + "actors": "Színészek", + "request_movies": "Filmek Kérése", + "request_series": "Sorozatok Kérése", + "recently_added": "Legutóbb Hozzáadva", + "recent_requests": "Legutóbbi Kérések", + "plex_watchlist": "Plex Watchlist", + "trending": "Népszerű", + "popular_movies": "Népszerű Filmek", + "movie_genres": "Film Műfajok", + "upcoming_movies": "Hamarosan Megjelenő Filmek", + "studios": "Stúdiók", + "popular_tv": "Népszerű Sorozatok", + "tv_genres": "Sorozat Műfajok", + "upcoming_tv": "Hamarosan Megjelenő Sorozatok", + "networks": "Csatornák", + "tmdb_movie_keyword": "TMDB Film Kulcsszó", + "tmdb_movie_genre": "TMDB Film Műfaj", + "tmdb_tv_keyword": "TMDB Sorozat Kulcsszó", + "tmdb_tv_genre": "TMDB Sorozat Műfaj", + "tmdb_search": "TMDB Keresés", + "tmdb_studio": "TMDB Stúdió", + "tmdb_network": "TMDB Csatorna", + "tmdb_movie_streaming_services": "TMDB Film Streaming Szolgáltatások", + "tmdb_tv_streaming_services": "TMDB Sorozat Streaming Szolgáltatások" + }, + "library": { + "no_results": "Nincs Eredmény", + "no_libraries_found": "Nem Található Könyvtár", + "item_types": { + "movies": "Filmek", + "series": "Sorozatok", + "boxsets": "Gyűjtemények", + "items": "Elemek" + }, + "options": { + "display": "Megjelenítés", + "row": "Sor", + "list": "Lista", + "image_style": "Kép Stílusa", + "poster": "Poszter", + "cover": "Borító", + "show_titles": "Címek Megjelenítése", + "show_stats": "Statisztikák Megjelenítése" + }, + "filters": { + "genres": "Műfajok", + "years": "Évek", + "sort_by": "Rendezés", + "sort_order": "Rendezés Iránya", + "tags": "Címkék" + } + }, + "favorites": { + "series": "Sorozatok", + "movies": "Filmek", + "episodes": "Epizódok", + "videos": "Videók", + "boxsets": "Gyűjtemények", + "playlists": "Lejátszási Listák", + "noDataTitle": "Még Nincsenek Kedvencek", + "noData": "Jelölj meg elemeket kedvencként, hogy itt gyorsan elérd őket." + }, + "custom_links": { + "no_links": "Nincsenek Linkek" + }, + "player": { + "error": "Hiba", + "failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t", + "an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.", + "client_error": "Kliens Hiba", + "could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt", + "message_from_server": "Üzenet a szervertől: {{message}}", + "next_episode": "Következő Epizód", + "refresh_tracks": "Sávok Frissítése", + "audio_tracks": "Hangsávok:", + "playback_state": "Lejátszás Állapota:", + "index": "Index:", + "continue_watching": "Folytatás", + "go_back": "Vissza" + }, + "item_card": { + "next_up": "Következő", + "no_items_to_display": "Nincs Megjeleníthető Elem", + "cast_and_crew": "Szereplők & Stáb", + "series": "Sorozat", + "seasons": "Évadok", + "season": "Évad", + "no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód", + "overview": "Áttekintés", + "more_with": "További {{name}} Alkotások", + "similar_items": "Hasonló Elemek", + "no_similar_items_found": "Nincs Hasonló Elem", + "video": "Videó", + "more_details": "További Részletek", + "quality": "Minőség", + "audio": "Hang", + "subtitles": "Felirat", + "show_more": "Több Megjelenítése", + "show_less": "Kevesebb Megjelenítése", + "appeared_in": "Megjelent:", + "could_not_load_item": "Nem Sikerült Betölteni az Elemet", + "none": "Nincs", + "download": { + "download_season": "Évad Letöltése", + "download_series": "Sorozat Letöltése", + "download_episode": "Epizód Letöltése", + "download_movie": "Film Letöltése", + "download_x_item": "{{item_count}} Elem Letöltése", + "download_unwatched_only": "Csak Nem Megtekintett", + "download_button": "Letöltés" + } + }, + "live_tv": { + "next": "Következő", + "previous": "Előző", + "coming_soon": "Hamarosan", + "on_now": "Most Műsoron", + "shows": "Sorozatok", + "movies": "Filmek", + "sports": "Sport", + "for_kids": "Gyerekeknek", + "news": "Hírek" + }, + "jellyseerr": { + "confirm": "Megerősítés", + "cancel": "Mégse", + "yes": "Igen", + "whats_wrong": "Mi a Probléma?", + "issue_type": "Probléma Típusa", + "select_an_issue": "Válassz Problémát", + "types": "Típusok", + "describe_the_issue": "(Opcionális) Fejtsd ki a problémát...", + "submit_button": "Beküldés", + "report_issue_button": "Probléma Jelentése", + "request_button": "Kérés", + "are_you_sure_you_want_to_request_all_seasons": "Biztosan az összes évadot kéred?", + "failed_to_login": "Sikertelen Bejelentkezés", + "cast": "Szereplők", + "details": "Részletek", + "status": "Állapot", + "original_title": "Eredeti Cím", + "series_type": "Sorozat Típusa", + "release_dates": "Megjelenési Dátumok", + "first_air_date": "Első Vetítés Dátuma", + "next_air_date": "Következő Adás Dátuma", + "revenue": "Bevétel", + "budget": "Költségvetés", + "original_language": "Eredeti Nyelv", + "production_country": "Gyártási Ország", + "studios": "Stúdiók", + "network": "Csatorna", + "currently_streaming_on": "Jelenleg Elérhető:", + "advanced": "Haladó", + "request_as": "Kérés Más Felhasználóként", + "tags": "Címkék", + "quality_profile": "Minőségi Profil", + "root_folder": "Gyökérmappa", + "season_all": "Évad (Összes)", + "season_number": "Évad {{season_number}}", + "number_episodes": "{{episode_number}} Epizód", + "born": "Született", + "appearances": "Megjelenések", + "toasts": { + "jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.", + "jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.", + "failed_to_test_jellyseerr_server_url": "Nem sikerült tesztelni a Jellyseerr szerver URL-jét", + "issue_submitted": "Probléma Beküldve!", + "requested_item": "{{item}} Kérése Sikeres!", + "you_dont_have_permission_to_request": "Nincs jogosultságod a kéréshez!", + "something_went_wrong_requesting_media": "Hiba történt a média kérés közben!" + } + }, + "tabs": { + "home": "Kezdőlap", + "search": "Keresés", + "library": "Könyvtár", + "custom_links": "Egyéni Linkek", + "favorites": "Kedvencek" + } +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index c0780462..d7edeb20 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -168,6 +168,13 @@ export type Settings = { defaultPlayer: VideoPlayer; maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount; autoPlayEpisodeCount: number; + vlcTextColor?: string; + vlcBackgroundColor?: string; + vlcOutlineColor?: string; + vlcOutlineThickness?: string; + vlcBackgroundOpacity?: number; + vlcOutlineOpacity?: number; + vlcIsBold?: boolean; // Gesture controls enableHorizontalSwipeSkip: boolean; enableLeftSideBrightnessSwipe: boolean; @@ -229,6 +236,13 @@ export const defaultValues: Settings = { defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android maxAutoPlayEpisodeCount: { key: "3", value: 3 }, autoPlayEpisodeCount: 0, + vlcTextColor: undefined, + vlcBackgroundColor: undefined, + vlcOutlineColor: undefined, + vlcOutlineThickness: undefined, + vlcBackgroundOpacity: undefined, + vlcOutlineOpacity: undefined, + vlcIsBold: undefined, // Gesture controls enableHorizontalSwipeSkip: true, enableLeftSideBrightnessSwipe: true,