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,