mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 18:54:16 +01:00
Compare commits
5 Commits
renovate/l
...
sync-subti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4664a39d | ||
|
|
be63d6cc55 | ||
|
|
ccdd7770c9 | ||
|
|
24cb679c0b | ||
|
|
af50b023ef |
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,13 +75,10 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
description: What version of Streamyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.54.1
|
||||||
- 0.51.0
|
- 0.51.0
|
||||||
- 0.47.1
|
|
||||||
- 0.30.2
|
|
||||||
- 0.28.0
|
|
||||||
- Older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
21
.github/actions/refresh-pr-comment/action.yml
vendored
21
.github/actions/refresh-pr-comment/action.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
name: Refresh PR build comment
|
|
||||||
description: >-
|
|
||||||
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
|
|
||||||
comment reflects live per-platform progress as each build job finishes.
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
|
|
||||||
# artifact-comment's concurrency group collapses simultaneous nudges, so
|
|
||||||
# this can't spam the comment. Skipped on forks (their read-only token
|
|
||||||
# cannot dispatch). github.token is used because composite actions cannot
|
|
||||||
# read the secrets context.
|
|
||||||
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
continue-on-error: true
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
HEAD_REF: ${{ github.head_ref }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"
|
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
10
.github/renovate.json
vendored
10
.github/renovate.json
vendored
@@ -30,17 +30,9 @@
|
|||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||||
"matchStrings": [
|
"matchStrings": [
|
||||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||||
],
|
],
|
||||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||||
},
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
|
|
||||||
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
|
|
||||||
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
|
|
||||||
"datasourceTemplate": "npm",
|
|
||||||
"depNameTemplate": "bun"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customDatasources": {
|
"customDatasources": {
|
||||||
|
|||||||
125
.github/workflows/artifact-comment.yml
vendored
125
.github/workflows/artifact-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
comment-artifacts:
|
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')
|
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
|
name: 📦 Post Build Artifacts
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
|
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||||
|
|
||||||
// Log current status of each build for debugging
|
// Log current status of each build for debugging
|
||||||
buildRuns.forEach(run => {
|
buildRuns.forEach(run => {
|
||||||
@@ -184,35 +184,21 @@ jobs:
|
|||||||
const latestAndroidRun = findBestRun('Android APK Build');
|
const latestAndroidRun = findBestRun('Android APK Build');
|
||||||
const latestIOSRun = findBestRun('iOS IPA Build');
|
const latestIOSRun = findBestRun('iOS IPA Build');
|
||||||
|
|
||||||
// Map our build targets to their job display names. Exact name is
|
|
||||||
// tried first so a signed target never collides with its
|
|
||||||
// "(Unsigned)" sibling (whose name contains the signed name).
|
|
||||||
const jobMappings = {
|
|
||||||
'Android Phone': ['🤖 Build Android APK (Phone)'],
|
|
||||||
'Android TV': ['🤖 Build Android APK (TV)'],
|
|
||||||
'iOS': ['🍎 Build iOS IPA (Phone)'],
|
|
||||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
|
|
||||||
'tvOS': ['🍎 Build tvOS IPA'],
|
|
||||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prefer an exact name match over a substring match so
|
|
||||||
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
|
|
||||||
const findJobForTarget = (jobs, jobNames) =>
|
|
||||||
jobs.find(j => jobNames.some(name => j.name === name)) ||
|
|
||||||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
|
|
||||||
|
|
||||||
// Format a millisecond duration as "Xm Ys".
|
|
||||||
const fmtDuration = (ms) => {
|
|
||||||
const min = Math.floor(ms / 60000);
|
|
||||||
const sec = Math.floor((ms % 60000) / 1000);
|
|
||||||
return `${min}m ${sec}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// For the consolidated workflow, get individual job statuses
|
// For the consolidated workflow, get individual job statuses
|
||||||
if (latestAppsRun) {
|
if (latestAppsRun) {
|
||||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||||
|
|
||||||
|
// Map job names to our build targets. Declared outside the try so
|
||||||
|
// the catch fallback can reuse the same keys.
|
||||||
|
const jobMappings = {
|
||||||
|
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||||
|
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||||
|
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
||||||
|
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
||||||
|
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
||||||
|
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all jobs for this workflow run
|
// Get all jobs for this workflow run
|
||||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
@@ -243,7 +229,9 @@ jobs:
|
|||||||
|
|
||||||
// Create individual status for each job
|
// Create individual status for each job
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||||
const job = findJobForTarget(jobs.jobs, jobNames);
|
const job = jobs.jobs.find(j =>
|
||||||
|
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||||
|
);
|
||||||
|
|
||||||
if (job) {
|
if (job) {
|
||||||
buildStatuses[platform] = {
|
buildStatuses[platform] = {
|
||||||
@@ -370,43 +358,6 @@ jobs:
|
|||||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pull per-job durations from the latest successful develop build so
|
|
||||||
// in-progress / queued targets can show a realistic ETA instead of
|
|
||||||
// an open-ended spinner. Best-effort: any failure just drops the ETA.
|
|
||||||
let referenceDurations = {};
|
|
||||||
try {
|
|
||||||
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
workflow_id: 'build-apps.yml',
|
|
||||||
branch: 'develop',
|
|
||||||
status: 'success',
|
|
||||||
per_page: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (devRuns.workflow_runs.length > 0) {
|
|
||||||
const refRun = devRuns.workflow_runs[0];
|
|
||||||
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
run_id: refRun.id
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
|
||||||
const job = findJobForTarget(refJobs.jobs, jobNames);
|
|
||||||
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
|
|
||||||
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`Reference durations from develop run ${refRun.id}:`,
|
|
||||||
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
|
|
||||||
} else {
|
|
||||||
console.log('No successful develop build found for ETA reference');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to fetch develop reference durations:', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build comment body with progressive status for individual builds
|
// Build comment body with progressive status for individual builds
|
||||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
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 += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
||||||
@@ -418,9 +369,9 @@ jobs:
|
|||||||
const buildTargets = [
|
const buildTargets = [
|
||||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
|
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
|
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||||
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -436,12 +387,11 @@ jobs:
|
|||||||
let status = '⏳ Pending';
|
let status = '⏳ Pending';
|
||||||
let downloadLink = '*Waiting for build...*';
|
let downloadLink = '*Waiting for build...*';
|
||||||
|
|
||||||
// Signed tvOS stays disabled until EAS has tvOS provisioning
|
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||||
// profiles (app + TopShelf targets); non-interactive builds can't
|
// is merged - show them as disabled instead of stuck pending.
|
||||||
// create them. Unsigned tvOS builds, so it flows through normally.
|
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||||
if (target.name === 'tvOS') {
|
|
||||||
status = '💤 Disabled';
|
status = '💤 Disabled';
|
||||||
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
|
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||||
} else if (matchingStatus) {
|
} else if (matchingStatus) {
|
||||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||||
status = '✅ Complete';
|
status = '✅ Complete';
|
||||||
@@ -456,7 +406,9 @@ jobs:
|
|||||||
let durationInfo = '';
|
let durationInfo = '';
|
||||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||||
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
const durationMin = Math.floor(durationMs / 60000);
|
||||||
|
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||||
@@ -468,16 +420,10 @@ jobs:
|
|||||||
downloadLink = '*Build cancelled*';
|
downloadLink = '*Build cancelled*';
|
||||||
} else if (matchingStatus.status === 'in_progress') {
|
} else if (matchingStatus.status === 'in_progress') {
|
||||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||||
const ref = referenceDurations[target.statusKey];
|
downloadLink = '*Build in progress...*';
|
||||||
downloadLink = ref
|
|
||||||
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
|
|
||||||
: '*Build in progress...*';
|
|
||||||
} else if (matchingStatus.status === 'queued') {
|
} else if (matchingStatus.status === 'queued') {
|
||||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||||
const ref = referenceDurations[target.statusKey];
|
downloadLink = '*Waiting to start...*';
|
||||||
downloadLink = ref
|
|
||||||
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
|
|
||||||
: '*Waiting to start...*';
|
|
||||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||||
// Workflow completed but conclusion not yet available (rare edge case)
|
// Workflow completed but conclusion not yet available (rare edge case)
|
||||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||||
@@ -498,22 +444,7 @@ jobs:
|
|||||||
|
|
||||||
commentBody += `\n`;
|
commentBody += `\n`;
|
||||||
|
|
||||||
// Static rundown of the build optimisations + what each artifact
|
// Show installation instructions if we have any artifacts
|
||||||
// installs on. Always shown (even mid-build) so testers know what
|
|
||||||
// to expect before downloads are ready.
|
|
||||||
commentBody += `<details>\n`;
|
|
||||||
commentBody += `<summary>📦 Build details & device compatibility</summary>\n\n`;
|
|
||||||
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
|
|
||||||
commentBody += `| Artifact | Architectures | Installs on |\n`;
|
|
||||||
commentBody += `|---|---|---|\n`;
|
|
||||||
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
|
|
||||||
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
|
|
||||||
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
|
|
||||||
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
|
|
||||||
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
|
|
||||||
commentBody += `</details>\n\n`;
|
|
||||||
|
|
||||||
// Installation instructions only matter once something is downloadable.
|
|
||||||
if (allArtifacts.length > 0) {
|
if (allArtifacts.length > 0) {
|
||||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||||
|
|||||||
430
.github/workflows/build-apps.yml
vendored
430
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
# run (artifacts + logs) without needing Expo access.
|
# run (artifacts + logs) without needing Expo access.
|
||||||
@@ -23,90 +23,74 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (Phone)
|
name: 🤖 Build Android APK (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 🗑️ Free Disk Space
|
||||||
- name: 🗑️ Free Disk Space
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
with:
|
||||||
with:
|
tool-cache: false
|
||||||
tool-cache: false
|
mandb: true
|
||||||
mandb: true
|
android: false
|
||||||
android: false
|
dotnet: true
|
||||||
dotnet: true
|
haskell: true
|
||||||
haskell: true
|
large-packages: true
|
||||||
large-packages: false
|
docker-images: true
|
||||||
docker-images: true
|
swap-storage: false
|
||||||
swap-storage: false
|
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
- name: 💾 Cache Bun dependencies
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
with:
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
path: ~/.bun/install/cache
|
||||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
with:
|
restore-keys: |
|
||||||
distribution: temurin
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
java-version: "17"
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
- parallel:
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches/modules-2
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
- name: 🚀 Build APK
|
- name: 🚀 Build APK
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
|
|
||||||
# sideload target). Overrides app.json buildArchs for this build only,
|
|
||||||
# so local `bun run android` (x86_64 emulator) is unaffected.
|
|
||||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
|
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
@@ -120,94 +104,76 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-android-tv:
|
build-android-tv:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (TV)
|
name: 🤖 Build Android APK (TV)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 🗑️ Free Disk Space
|
||||||
- name: 🗑️ Free Disk Space
|
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
with:
|
||||||
with:
|
tool-cache: false
|
||||||
tool-cache: false
|
mandb: true
|
||||||
mandb: true
|
android: false
|
||||||
android: false
|
dotnet: true
|
||||||
dotnet: true
|
haskell: true
|
||||||
haskell: true
|
large-packages: true
|
||||||
large-packages: false
|
docker-images: true
|
||||||
docker-images: true
|
swap-storage: false
|
||||||
swap-storage: false
|
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: ☕ Set up JDK 17
|
- name: 💾 Cache Bun dependencies
|
||||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
with:
|
||||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
path: ~/.bun/install/cache
|
||||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
with:
|
restore-keys: |
|
||||||
distribution: temurin
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
java-version: "17"
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
- parallel:
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches/modules-2
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
- name: 🚀 Build APK
|
- name: 🚀 Build APK
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
|
|
||||||
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
|
|
||||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
|
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
@@ -221,53 +187,34 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-phone:
|
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'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
fetch-depth: 0
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
|
||||||
with:
|
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
|
||||||
xcode-version: "26.6"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
|
||||||
with:
|
|
||||||
eas-version: latest
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
eas-cache: true
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -277,6 +224,19 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.5"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
@@ -294,46 +254,34 @@ jobs:
|
|||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-phone-unsigned:
|
build-ios-phone-unsigned:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
fetch-depth: 0
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
|
||||||
with:
|
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
|
||||||
xcode-version: "26.6"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -343,6 +291,12 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
@@ -358,57 +312,36 @@ jobs:
|
|||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-tv:
|
build-ios-tv:
|
||||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
# Re-enable by removing the `false &&` prefix below.
|
||||||
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
|
|
||||||
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
|
|
||||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build tvOS IPA
|
name: 🍎 Build tvOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
fetch-depth: 0
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
|
||||||
with:
|
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
|
||||||
xcode-version: "26.6"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
|
||||||
with:
|
|
||||||
eas-version: latest
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
eas-cache: true
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -418,6 +351,19 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.5"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
@@ -442,37 +388,28 @@ jobs:
|
|||||||
name: 🍎 Build tvOS IPA (Unsigned)
|
name: 🍎 Build tvOS IPA (Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
fetch-depth: 0
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
|
||||||
with:
|
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
|
||||||
xcode-version: "26.6"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -482,6 +419,12 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
|
with:
|
||||||
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
@@ -496,6 +439,3 @@ jobs:
|
|||||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|||||||
31
.github/workflows/check-lockfile.yml
vendored
31
.github/workflows/check-lockfile.yml
vendored
@@ -13,33 +13,30 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
check-lockfile:
|
check-lockfile:
|
||||||
name: 🔍 Check bun.lock and package.json consistency
|
name: 🔍 Check bun.lock and package.json consistency
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout repository
|
||||||
- name: 📥 Checkout repository
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
show-progress: false
|
||||||
show-progress: false
|
submodules: recursive
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 🛡️ Verify lockfile consistency
|
- name: 🛡️ Verify lockfile consistency
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
7
.github/workflows/ci-codeql.yml
vendored
7
.github/workflows/ci-codeql.yml
vendored
@@ -8,14 +8,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '24 2 * * *'
|
- cron: '24 2 * * *'
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: codeql-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: 🔎 Analyze with CodeQL
|
name: 🔎 Analyze with CodeQL
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
@@ -27,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|||||||
2
.github/workflows/conflict.yml
vendored
2
.github/workflows/conflict.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
name: 🏷️ Labeling Merge Conflicts
|
name: 🏷️ Labeling Merge Conflicts
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
6
.github/workflows/crowdin.yml
vendored
6
.github/workflows/crowdin.yml
vendored
@@ -19,16 +19,16 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-translations:
|
sync-translations:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
18
.github/workflows/detect-duplicate.yml
vendored
18
.github/workflows/detect-duplicate.yml
vendored
@@ -15,23 +15,21 @@ jobs:
|
|||||||
detect:
|
detect:
|
||||||
name: 🔍 Find similar issues
|
name: 🔍 Find similar issues
|
||||||
if: github.actor != 'github-actions[bot]'
|
if: github.actor != 'github-actions[bot]'
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout repository
|
||||||
- name: 📥 Checkout repository
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.ts
|
run: bun scripts/detect-duplicate-issue.mjs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
113
.github/workflows/linting.yml
vendored
113
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, edited, synchronize, reopened]
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -11,19 +11,47 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
validate_pr_title:
|
||||||
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||||
|
|
||||||
|
**Error details:**
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: 🔍 Vulnerable Dependencies
|
name: 🔍 Vulnerable Dependencies
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -37,41 +65,30 @@ jobs:
|
|||||||
|
|
||||||
expo-doctor:
|
expo-doctor:
|
||||||
name: 🚑 Expo Doctor Check
|
name: 🚑 Expo Doctor Check
|
||||||
runs-on: ubuntu-26.04
|
if: false
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 🛒 Checkout repository
|
||||||
- name: 🛒 Checkout repository
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
# renovate: datasource=npm depName=bun
|
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
submodules: recursive
|
||||||
restore-keys: |
|
fetch-depth: 0
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
- name: 📦 Install dependencies (bun)
|
- name: 📦 Install dependencies (bun)
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: 🚑 Run Expo Doctor
|
- name: 🚑 Run Expo Doctor
|
||||||
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
|
|
||||||
# without failing the gate (some checks are known-noisy for this setup).
|
|
||||||
continue-on-error: true
|
|
||||||
run: bun expo-doctor
|
run: bun expo-doctor
|
||||||
|
|
||||||
code_quality:
|
code_quality:
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -83,26 +100,22 @@ jobs:
|
|||||||
- "i18n:check"
|
- "i18n:check"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: "📥 Checkout PR code"
|
||||||
- name: "📥 Checkout PR code"
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
# renovate: datasource=npm depName=bun
|
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
submodules: recursive
|
||||||
restore-keys: |
|
fetch-depth: 0
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: '24.x'
|
||||||
|
|
||||||
|
- name: "🍞 Setup Bun"
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
4
.github/workflows/notification.yml
vendored
4
.github/workflows/notification.yml
vendored
@@ -12,7 +12,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: 🛎️ Notify Discord
|
- name: 🛎️ Notify Discord
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
🔗 ${{ github.event.pull_request.html_url }}
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
|
|
||||||
notify-on-failure:
|
notify-on-failure:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||||
steps:
|
steps:
|
||||||
- name: 🚨 Notify Discord on Failure
|
- name: 🚨 Notify Discord on Failure
|
||||||
|
|||||||
81
.github/workflows/pr-title-comment.yml
vendored
81
.github/workflows/pr-title-comment.yml
vendored
@@ -1,81 +0,0 @@
|
|||||||
name: 📝 PR Title Comment
|
|
||||||
|
|
||||||
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
|
|
||||||
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
|
|
||||||
# read-only token on forks). workflow_run runs from the base branch with the base
|
|
||||||
# repo's token. This is comment-only — the required check stays pr-title.yml.
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["📝 PR Title"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read # download the artifact from the triggering run
|
|
||||||
pull-requests: write # post/update/delete the sticky comment
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-title-comment-${{ github.event.workflow_run.id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
# Only run when the lint run actually produced a result artifact: a run
|
|
||||||
# cancelled by concurrency (rapid pushes to the same PR) completes with
|
|
||||||
# conclusion "cancelled" before uploading it, and would fail the download.
|
|
||||||
if: >
|
|
||||||
github.event.workflow_run.event == 'pull_request' &&
|
|
||||||
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
steps:
|
|
||||||
- name: ⬇️ Download lint result
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh run download "${{ github.event.workflow_run.id }}" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--name pr-title-result \
|
|
||||||
--dir result
|
|
||||||
|
|
||||||
- name: 🔎 Read result
|
|
||||||
id: result
|
|
||||||
run: |
|
|
||||||
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
|
|
||||||
if [ -s result/error ]; then
|
|
||||||
echo "has_error=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "has_error=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 📥 Load error message
|
|
||||||
if: steps.result.outputs.has_error == 'true'
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
|
|
||||||
cat result/error
|
|
||||||
echo
|
|
||||||
echo "PR_TITLE_ERR_EOF"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: 💬 Post title error
|
|
||||||
if: steps.result.outputs.has_error == 'true'
|
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
|
||||||
with:
|
|
||||||
number: ${{ steps.result.outputs.number }}
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
||||||
|
|
||||||
**Error details:**
|
|
||||||
```
|
|
||||||
${{ env.LINT_ERROR }}
|
|
||||||
```
|
|
||||||
|
|
||||||
- name: 🧹 Clear title error
|
|
||||||
if: steps.result.outputs.has_error == 'false'
|
|
||||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
|
||||||
with:
|
|
||||||
number: ${{ steps.result.outputs.number }}
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
42
.github/workflows/pr-title.yml
vendored
42
.github/workflows/pr-title.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
name: 📝 PR Title
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, edited, synchronize, reopened]
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate_pr_title:
|
|
||||||
name: "📝 Validate PR Title"
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
|
|
||||||
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
|
|
||||||
# off the result (PR number + lint error) as an artifact for it to read.
|
|
||||||
- if: always()
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
run: |
|
|
||||||
mkdir -p pr-title-result
|
|
||||||
printf '%s' "$PR_NUMBER" > pr-title-result/number
|
|
||||||
printf '%s' "$LINT_ERROR" > pr-title-result/error
|
|
||||||
|
|
||||||
- if: always()
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: pr-title-result
|
|
||||||
path: pr-title-result/
|
|
||||||
retention-days: 1
|
|
||||||
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
@@ -13,8 +13,6 @@ name: 🚀 Release (EAS build + submit)
|
|||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
# Queue successive releases in order instead of dropping the extra pending run.
|
|
||||||
queue: max
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -24,9 +22,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve:
|
approve:
|
||||||
name: 🔐 Approve release
|
name: 🔐 Approve release
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
environment: production
|
environment: production
|
||||||
permissions: {}
|
|
||||||
steps:
|
steps:
|
||||||
- name: ✅ Release approved
|
- name: ✅ Release approved
|
||||||
run: echo "Release approved for ${{ github.sha }}"
|
run: echo "Release approved for ${{ github.sha }}"
|
||||||
@@ -34,7 +31,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: 🚀 ${{ matrix.name }}
|
name: 🚀 ${{ matrix.name }}
|
||||||
needs: approve
|
needs: approve
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
@@ -65,40 +62,38 @@ jobs:
|
|||||||
artifact_name: streamyfin-android-tv-apk
|
artifact_name: streamyfin-android-tv-apk
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout code
|
||||||
- name: 📥 Checkout code
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
with:
|
||||||
with:
|
fetch-depth: 0
|
||||||
fetch-depth: 0
|
submodules: recursive
|
||||||
submodules: recursive
|
show-progress: false
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
|
||||||
with:
|
|
||||||
eas-version: latest
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
eas-cache: true
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
# tvOS uses credentialsSource: local — restore the gitignored
|
# tvOS uses credentialsSource: local — restore the gitignored
|
||||||
# credentials.json + cert + provisioning profiles from secrets.
|
# credentials.json + cert + provisioning profiles from secrets.
|
||||||
- name: 🔐 Restore tvOS signing credentials
|
- name: 🔐 Restore tvOS signing credentials
|
||||||
@@ -181,13 +176,13 @@ jobs:
|
|||||||
name: 📦 Draft GitHub Release
|
name: 📦 Draft GitHub Release
|
||||||
needs: build
|
needs: build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
20
.github/workflows/trivy-scan.yml
vendored
20
.github/workflows/trivy-scan.yml
vendored
@@ -21,17 +21,27 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
trivy:
|
trivy:
|
||||||
name: 🔎 Filesystem scan
|
name: 🔎 Filesystem scan
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write # upload SARIF to code scanning
|
security-events: write # upload SARIF to code scanning
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
||||||
|
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||||
|
- name: 🗓️ Compute weekly Trivy cache key
|
||||||
|
id: trivy-cache-key
|
||||||
|
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 💾 Cache Trivy vulnerability DB
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/trivy
|
||||||
|
key: ${{ steps.trivy-cache-key.outputs.value }}
|
||||||
|
restore-keys: trivy-db-${{ runner.os }}-
|
||||||
|
|
||||||
# Trivy's own action caches the vulnerability DB + binary internally
|
|
||||||
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
|
||||||
# step is needed — it only duplicated the cache.
|
|
||||||
- name: 🔎 Run Trivy filesystem scan
|
- name: 🔎 Run Trivy filesystem scan
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
131
.github/workflows/update-issue-form.yml
vendored
131
.github/workflows/update-issue-form.yml
vendored
@@ -1,104 +1,67 @@
|
|||||||
name: 🐛 Update Issue Form Versions
|
name: 🐛 Update Bug Report Template
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
types: [published] # Run on every published release on any branch
|
||||||
types: [released]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Fixed group so a release event and the weekly cron can't race on the same
|
|
||||||
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-issue-form
|
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-issue-form:
|
update-bug-report:
|
||||||
name: 🔢 Populate version dropdown
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: 📥 Checkout repository
|
||||||
- name: 📥 Checkout repository
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
|
||||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
|
||||||
# PR would revert any form edits made on develop since that release.
|
|
||||||
ref: develop
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: "🟢 Setup Node.js"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
node-version: '24.x'
|
||||||
bun-version: "1.3.14"
|
cache: 'npm'
|
||||||
|
|
||||||
- name: 🔢 Populate version dropdown from GitHub releases
|
- name: 🔍 Extract minor version from app.json
|
||||||
id: populate
|
id: minor
|
||||||
run: bun scripts/update-issue-form.mjs
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||||
env:
|
with:
|
||||||
GH_TOKEN: ${{ github.token }}
|
result-encoding: string
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
script: |
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const semver = require('semver');
|
||||||
|
const content = fs.readJsonSync('./app.json');
|
||||||
|
const version = content.expo.version;
|
||||||
|
const minorVersion = semver.minor(version);
|
||||||
|
return minorVersion.toString();
|
||||||
|
|
||||||
- name: 📬 Create pull request
|
- name: 📝 Update bug report version
|
||||||
id: cpr
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
|
with:
|
||||||
|
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||||
|
dry_run: no-push
|
||||||
|
|
||||||
|
- name: ⚙️ Update bug report node version dropdown
|
||||||
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
|
with:
|
||||||
|
dropdown: _node_version
|
||||||
|
package: node
|
||||||
|
semver: '>=24.0.0'
|
||||||
|
dry_run: no-push
|
||||||
|
|
||||||
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci/update-issue-form
|
branch: ci-update-bug-report
|
||||||
base: develop
|
base: develop
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
labels: ⚙️ ci, 🤖 github-actions
|
labels: ⚙️ ci, 🤖 github-actions
|
||||||
commit-message: "chore: update issue form version dropdown"
|
title: 'chore(): Update bug report template to match release version'
|
||||||
title: "chore: update issue form version dropdown"
|
|
||||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
|
||||||
body: |
|
body: |
|
||||||
# 📦 Pull Request
|
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||||
|
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
## 📝 Description
|
|
||||||
|
|
||||||
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
|
||||||
|
|
||||||
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
|
||||||
|
|
||||||
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
|
||||||
|
|
||||||
N/A — automated maintenance.
|
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
|
||||||
|
|
||||||
N/A — issue-template metadata only, no app UI.
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
|
||||||
- [x] Verified that changes behave as expected for all platforms
|
|
||||||
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
|
||||||
- [x] No secrets, hardcoded credentials, or private config files are included
|
|
||||||
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
|
||||||
|
|
||||||
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
|
||||||
|
|
||||||
- name: 🔀 Enable auto-merge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
|
||||||
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
|
||||||
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
|
||||||
# Auto-merge is still worth enabling: once checks run and reviews land,
|
|
||||||
# the PR merges itself.
|
|
||||||
run: |
|
|
||||||
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
|
||||||
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
- Use TypeScript for all files (no .js)
|
||||||
- Use functional React components with hooks
|
- Use functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
const { execFileSync } = require("node:child_process");
|
||||||
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
|
||||||
import "tsx/cjs";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
const git = (args: string[]): string | null => {
|
const git = (args) => {
|
||||||
try {
|
try {
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
@@ -46,16 +42,16 @@ const buildMeta = {
|
|||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins?.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins?.push([
|
config.plugins.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.plugins?.push([
|
config.plugins.push([
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
cameraPermission:
|
cameraPermission:
|
||||||
@@ -65,7 +61,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
const androidConfig: { googleServicesFile?: string } = {};
|
const androidConfig = {};
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
@@ -75,5 +71,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
} as ExpoConfig;
|
};
|
||||||
};
|
};
|
||||||
27
app.json
27
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.ts",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
"./plugins/withTVUserManagement.ts",
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -107,9 +107,6 @@
|
|||||||
],
|
],
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
"expo-audio",
|
|
||||||
"expo-image",
|
|
||||||
"expo-sharing",
|
|
||||||
[
|
[
|
||||||
"react-native-edge-to-edge",
|
"react-native-edge-to-edge",
|
||||||
{
|
{
|
||||||
@@ -134,17 +131,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.ts"],
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidAlertColors.ts"],
|
["./plugins/withAndroidAlertColors.js"],
|
||||||
["./plugins/withAndroidManifest.ts"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.ts"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.ts"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withTVOSAppIcon.ts"],
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
["./plugins/withTVOSTopShelf.ts"],
|
["./plugins/withTVOSTopShelf.js"],
|
||||||
["./plugins/withTVXcodeEnv.ts"],
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.ts",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -157,6 +158,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -324,44 +324,32 @@ export default function SettingsTV() {
|
|||||||
// MPV alignment options
|
// MPV alignment options
|
||||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||||
{
|
{
|
||||||
label: t("home.settings.subtitles.align.left"),
|
label: "Center",
|
||||||
value: "left",
|
|
||||||
selected: currentAlignX === "left",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.subtitles.align.center"),
|
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignX === "center",
|
selected: currentAlignX === "center",
|
||||||
},
|
},
|
||||||
{
|
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||||
label: t("home.settings.subtitles.align.right"),
|
|
||||||
value: "right",
|
|
||||||
selected: currentAlignX === "right",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[currentAlignX, t],
|
[currentAlignX],
|
||||||
);
|
);
|
||||||
|
|
||||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||||
{
|
{
|
||||||
label: t("home.settings.subtitles.align.top"),
|
label: "Center",
|
||||||
value: "top",
|
|
||||||
selected: currentAlignY === "top",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.subtitles.align.center"),
|
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignY === "center",
|
selected: currentAlignY === "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("home.settings.subtitles.align.bottom"),
|
label: "Bottom",
|
||||||
value: "bottom",
|
value: "bottom",
|
||||||
selected: currentAlignY === "bottom",
|
selected: currentAlignY === "bottom",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentAlignY, t],
|
[currentAlignY],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache mode options
|
// Cache mode options
|
||||||
@@ -511,13 +499,13 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
const alignXLabel = useMemo(() => {
|
const alignXLabel = useMemo(() => {
|
||||||
const option = alignXOptions.find((o) => o.selected);
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
return option?.label || t("home.settings.subtitles.align.center");
|
return option?.label || "Center";
|
||||||
}, [alignXOptions, t]);
|
}, [alignXOptions]);
|
||||||
|
|
||||||
const alignYLabel = useMemo(() => {
|
const alignYLabel = useMemo(() => {
|
||||||
const option = alignYOptions.find((o) => o.selected);
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
return option?.label || t("home.settings.subtitles.align.bottom");
|
return option?.label || "Bottom";
|
||||||
}, [alignYOptions, t]);
|
}, [alignYOptions]);
|
||||||
|
|
||||||
const typographyScaleLabel = useMemo(() => {
|
const typographyScaleLabel = useMemo(() => {
|
||||||
const option = typographyScaleOptions.find((o) => o.selected);
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
@@ -657,7 +645,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
label='Vertical Margin'
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -675,11 +663,11 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
label='Horizontal Alignment'
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
title: "Horizontal Alignment",
|
||||||
options: alignXOptions,
|
options: alignXOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -689,11 +677,11 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
title: "Vertical Alignment",
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -866,13 +854,6 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsToggle
|
|
||||||
label={t("home.settings.appearance.use_episode_images_next_up")}
|
|
||||||
value={settings.useEpisodeImagesForNextUp}
|
|
||||||
onToggle={(value) =>
|
|
||||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, Switch, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -51,12 +50,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4 pt-4'
|
className='px-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -72,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -47,7 +46,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -61,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -8,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -75,25 +72,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
const copyLog = useCallback(
|
|
||||||
async (log: NonNullable<typeof logs>[number]) => {
|
|
||||||
// Skip on builds that don't ship the expo-clipboard native module
|
|
||||||
// (probe returns null instead of throwing); same guard as Quick Connect.
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
const text = [
|
|
||||||
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
|
||||||
log.message,
|
|
||||||
log.data ? JSON.stringify(log.data, null, 2) : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
await Clipboard.setStringAsync(text);
|
|
||||||
toast.success(t("home.settings.logs.copied"));
|
|
||||||
},
|
|
||||||
[logs, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -110,15 +88,8 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View className='flex-1'>
|
||||||
// Like the sibling settings pages, let iOS auto-inset the content below the
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
// transparent header (no manual header-height math). The filter bar is a
|
|
||||||
// sticky header so it stays pinned just under the header while logs scroll.
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
stickyHeaderIndices={[0]}
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -141,77 +112,67 @@ export default function Page() {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 px-4'>
|
<ScrollView
|
||||||
{filteredLogs?.map((log, index) => (
|
className='pb-4 px-4'
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
<TouchableOpacity
|
>
|
||||||
disabled={!log.data}
|
<View className='flex flex-col space-y-2'>
|
||||||
onPress={() =>
|
{filteredLogs?.map((log, index) => (
|
||||||
setState((v) => ({
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
...v,
|
<TouchableOpacity
|
||||||
[log.timestamp]: !v[log.timestamp],
|
disabled={!log.data}
|
||||||
}))
|
onPress={() =>
|
||||||
}
|
setState((v) => ({
|
||||||
>
|
...v,
|
||||||
<View className='flex flex-row justify-between'>
|
[log.timestamp]: !v[log.timestamp],
|
||||||
<Text
|
}))
|
||||||
className={`mb-1
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<Text
|
||||||
|
className={`mb-1
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
${log.level === "DEBUG" && "text-purple-500"}
|
${log.level === "DEBUG" && "text-purple-500"}
|
||||||
`}
|
`}
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className='text-xs'>{log.message}</Text>
|
|
||||||
{/* Keep the whole collapsed row tappable: the hint lives inside
|
|
||||||
the toggle so tapping it expands too. */}
|
|
||||||
{log.data && !state[log.timestamp] && (
|
|
||||||
<Text className='text-xs mt-0.5'>
|
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
|
||||||
<ScrollView
|
|
||||||
className='rounded-xl'
|
|
||||||
style={codeBlockStyle}
|
|
||||||
nestedScrollEnabled
|
|
||||||
>
|
>
|
||||||
{/* Only the raw payload is selectable (per request); the
|
{log.level}
|
||||||
header/message stay tap-to-toggle. */}
|
</Text>
|
||||||
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
|
||||||
</ScrollView>
|
<Text className='text-xs'>
|
||||||
{!Platform.isTV && (
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
<TouchableOpacity
|
</Text>
|
||||||
onPress={() => copyLog(log)}
|
|
||||||
className='flex flex-row items-center self-end px-2 py-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='copy-outline' size={16} color='white' />
|
|
||||||
<Text className='text-xs ml-1'>
|
|
||||||
{t("home.settings.logs.copy")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Collapsible>
|
<Text selectable className='text-xs'>
|
||||||
)}
|
{log.message}
|
||||||
</View>
|
</Text>
|
||||||
))}
|
</TouchableOpacity>
|
||||||
{filteredLogs?.length === 0 && (
|
|
||||||
<Text className='opacity-50'>
|
{log.data && (
|
||||||
{t("home.settings.logs.no_logs_available")}
|
<>
|
||||||
</Text>
|
{!state[log.timestamp] && (
|
||||||
)}
|
<Text className='text-xs mt-0.5'>
|
||||||
</View>
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
)}
|
||||||
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{filteredLogs?.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.settings.logs.no_logs_available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup className='mt-4'>
|
<ListGroup>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View>
|
<View className='mb-4'>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
|
||||||
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
|
||||||
// Effective (user/admin merged) URL, same source the search screen uses —
|
|
||||||
// the raw plugin value misses a user-configured Streamystats.
|
|
||||||
const hasStreamystats = !!settings?.streamyStatsServerUrl;
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -43,8 +41,15 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!marlinUrlLocked) {
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -55,7 +60,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, marlinUrlLocked, t]);
|
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -67,42 +72,52 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4 pt-4'>
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
<DisabledSetting
|
||||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
disabled={
|
||||||
<ListItem
|
pluginSettings?.searchEngine?.locked === true ||
|
||||||
title={t(
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
}
|
||||||
)}
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
disabledByAdmin={searchEngineLocked}
|
|
||||||
// Streamystats owns the search engine while configured — block the
|
|
||||||
// row tap too, not just the Switch, so it can't force "Jellyfin".
|
|
||||||
disabled={hasStreamystats}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<ListItem
|
||||||
value={settings.searchEngine === "Marlin"}
|
title={t(
|
||||||
disabled={searchEngineLocked || hasStreamystats}
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
onValueChange={(val) => {
|
)}
|
||||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</ListItem>
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<ListGroup className='mt-2'>
|
<DisabledSetting
|
||||||
<ListItem
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
title={t("home.settings.plugins.marlin_search.url")}
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
disabledByAdmin={marlinUrlLocked}
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||||
>
|
>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
editable={settings.searchEngine === "Marlin"}
|
||||||
className='text-white text-right flex-1'
|
className='text-white'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -113,16 +128,15 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</DisabledSetting>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
// Returns undefined when the API call fails (handled internally).
|
|
||||||
const refreshed = await refreshStreamyfinPluginSettings();
|
|
||||||
if (refreshed) {
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -37,17 +18,6 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
|
||||||
so it lives on the plugins index rather than inside Streamystats. */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
const isStreamystatsEnabled = !!url;
|
||||||
const movieRecsLocked =
|
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
|
||||||
const seriesRecsLocked =
|
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
|
||||||
const promotedWatchlistsLocked =
|
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
|
||||||
const hideWatchlistsTabLocked =
|
|
||||||
pluginSettings?.hideWatchlistsTab?.locked === true;
|
|
||||||
// The input renders the locked admin URL; enablement must follow the same
|
|
||||||
// effective value or every toggle stays disabled until local state syncs.
|
|
||||||
const effectiveUrl = isUrlLocked
|
|
||||||
? (settings?.streamyStatsServerUrl ?? "")
|
|
||||||
: url;
|
|
||||||
const isStreamystatsEnabled = !!effectiveUrl;
|
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +134,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4 pt-4'>
|
<View className='px-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={effectiveUrl}
|
value={url}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={searchLocked}
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
>
|
>
|
||||||
{/* Locked controls show the live admin value and can't be toggled —
|
<Switch
|
||||||
local form state would let the switch flip while the write guard
|
value={useForSearch}
|
||||||
drops the change. */}
|
disabled={!isStreamystatsEnabled}
|
||||||
<SettingSwitch
|
|
||||||
value={
|
|
||||||
searchLocked
|
|
||||||
? settings?.searchEngine === "Streamystats"
|
|
||||||
: useForSearch
|
|
||||||
}
|
|
||||||
disabled={!isStreamystatsEnabled || searchLocked}
|
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={movieRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={
|
value={movieRecs}
|
||||||
movieRecsLocked
|
|
||||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
|
||||||
: movieRecs
|
|
||||||
}
|
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={seriesRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={
|
value={seriesRecs}
|
||||||
seriesRecsLocked
|
|
||||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
|
||||||
: seriesRecs
|
|
||||||
}
|
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={promotedWatchlistsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={
|
value={promotedWatchlists}
|
||||||
promotedWatchlistsLocked
|
|
||||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
|
||||||
: promotedWatchlists
|
|
||||||
}
|
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={hideWatchlistsTabLocked}
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={
|
value={hideWatchlistsTab}
|
||||||
hideWatchlistsTabLocked
|
|
||||||
? (settings?.hideWatchlistsTab ?? false)
|
|
||||||
: hideWatchlistsTab
|
|
||||||
}
|
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -12,16 +12,11 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
useFocusEffect,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BackHandler,
|
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -85,9 +80,8 @@ const Page = () => {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
filterBy?: string;
|
filterBy?: string;
|
||||||
fromSeeAll?: string;
|
|
||||||
};
|
};
|
||||||
const { libraryId, fromSeeAll } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -118,22 +112,6 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
// When this library detail was opened from the home "See All" button, its
|
|
||||||
// libraries stack is just [detail], so the default TV Back would exit to home.
|
|
||||||
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
|
||||||
// route to the library list instead, so the user can switch libraries. Normal
|
|
||||||
// entries from the list keep their native pop-to-list behavior.
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!Platform.isTV || fromSeeAll !== "true") return;
|
|
||||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
||||||
router.replace("/(auth)/(tabs)/(libraries)");
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return () => sub.remove();
|
|
||||||
}, [fromSeeAll, router]),
|
|
||||||
);
|
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
@@ -291,23 +269,6 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
|
||||||
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
|
||||||
// which the native bottom tab reliably auto-pops back to the index (the detail
|
|
||||||
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
|
||||||
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
|
||||||
const didCollapseRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
|
||||||
return;
|
|
||||||
const state = navigation.getState();
|
|
||||||
if (state?.routes && state.routes.length > 1) {
|
|
||||||
didCollapseRef.current = true;
|
|
||||||
const top = state.routes[state.routes.length - 1];
|
|
||||||
navigation.reset({ index: 0, routes: [top] } as any);
|
|
||||||
}
|
|
||||||
}, [navigation, fromSeeAll]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -305,8 +305,6 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
|
||||||
textColor: "#fff",
|
|
||||||
// Android: placeholder and icon color
|
// Android: placeholder and icon color
|
||||||
hintTextColor: "#fff",
|
hintTextColor: "#fff",
|
||||||
headerIconColor: "#fff",
|
headerIconColor: "#fff",
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
|
||||||
import { TVNavBar } from "@/components/tv/TVNavBar";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import {
|
|
||||||
isTabRoute,
|
|
||||||
useTVHomeBackHandler,
|
|
||||||
useTVTabRootBackHandler,
|
|
||||||
} from "@/hooks/useTVBackHandler";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
|
||||||
|
|
||||||
function TVTabLayout() {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = useSegments();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const currentTab = segments.find(isTabRoute);
|
|
||||||
const lastSegment = segments[segments.length - 1] ?? "";
|
|
||||||
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
|
||||||
|
|
||||||
const tabs: TVNavBarTab[] = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{ key: "(home)", label: t("tabs.home") },
|
|
||||||
{ key: "(search)", label: t("tabs.search") },
|
|
||||||
{ key: "(favorites)", label: t("tabs.favorites") },
|
|
||||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
|
||||||
? null
|
|
||||||
: { key: "(watchlists)", label: t("watchlists.title") },
|
|
||||||
{ key: "(libraries)", label: t("tabs.library") },
|
|
||||||
!settings?.showCustomMenuLinks
|
|
||||||
? null
|
|
||||||
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
|
||||||
{ key: "(settings)", label: t("tabs.settings") },
|
|
||||||
].filter((tab): tab is TVNavBarTab => tab !== null),
|
|
||||||
[
|
|
||||||
settings?.streamyStatsServerUrl,
|
|
||||||
settings?.hideWatchlistsTab,
|
|
||||||
settings?.showCustomMenuLinks,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTabKey = currentTab ?? "(home)";
|
|
||||||
|
|
||||||
const visibleKeys = useMemo(
|
|
||||||
() => new Set(tabs.map((tab) => tab.key)),
|
|
||||||
[tabs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
if (key === currentTab) return;
|
|
||||||
|
|
||||||
if (key === "(home)") eventBus.emit("scrollToTop");
|
|
||||||
if (key === "(search)") eventBus.emit("searchTabPressed");
|
|
||||||
|
|
||||||
router.replace(`/(auth)/(tabs)/${key}`);
|
|
||||||
},
|
|
||||||
[currentTab, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateHome = useCallback(() => {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)");
|
|
||||||
}, [router]);
|
|
||||||
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
|
||||||
|
|
||||||
// If current tab is no longer visible (setting changed), navigate to home
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)");
|
|
||||||
}
|
|
||||||
}, [visibleKeys, activeTabKey, router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<SystemBars hidden={false} style='light' />
|
|
||||||
<Stack
|
|
||||||
screenOptions={{ headerShown: false, animation: "none" }}
|
|
||||||
initialRouteName='(home)'
|
|
||||||
>
|
|
||||||
<Stack.Screen name='index' redirect />
|
|
||||||
</Stack>
|
|
||||||
<TVNavBar
|
|
||||||
tabs={tabs}
|
|
||||||
activeTabKey={activeTabKey}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Must be called before any conditional return (rules of hooks)
|
// Handle TV back button - prevent app exit when at root
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
if (IS_ANDROID_TV) {
|
|
||||||
return <TVTabLayout />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
{t("music.no_track_playing")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
? t("music.playing_from_queue")
|
|
||||||
: t("music.up_next")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
getMediaInfoApi,
|
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -148,9 +147,9 @@ export default function DirectPlayerPage() {
|
|||||||
const audioIndexFromUrl = audioIndexStr
|
const audioIndexFromUrl = audioIndexStr
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
const subtitleIndex = subtitleIndexStr
|
const subtitleIndexFromUrl = subtitleIndexStr
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
? Number.parseInt(subtitleIndexStr, 10)
|
||||||
: -1;
|
: undefined;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
? Number.parseInt(bitrateValueStr, 10)
|
? Number.parseInt(bitrateValueStr, 10)
|
||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
@@ -186,6 +185,23 @@ export default function DirectPlayerPage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
|
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
|
const subtitleIndex = useMemo(() => {
|
||||||
|
if (subtitleIndexFromUrl !== undefined) {
|
||||||
|
return subtitleIndexFromUrl;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
offline &&
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex !== undefined
|
||||||
|
) {
|
||||||
|
return downloadedItem.userData.subtitleStreamIndex;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}, [
|
||||||
|
subtitleIndexFromUrl,
|
||||||
|
offline,
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex,
|
||||||
|
]);
|
||||||
// Initialize TV audio/subtitle indices from URL params.
|
// Initialize TV audio/subtitle indices from URL params.
|
||||||
// No undefined guard: when a new episode's URL omits audioIndex, reset to
|
// No undefined guard: when a new episode's URL omits audioIndex, reset to
|
||||||
// undefined (media default) rather than leaking the previous episode's track.
|
// undefined (media default) rather than leaking the previous episode's track.
|
||||||
@@ -311,37 +327,6 @@ export default function DirectPlayerPage() {
|
|||||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
||||||
|
|
||||||
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
|
|
||||||
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
|
|
||||||
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
|
|
||||||
// handles a clean stop, but a channel switch (the player re-fetches in place)
|
|
||||||
// or an unmount after an error bypass it, so track the open live stream and
|
|
||||||
// release it on those paths too.
|
|
||||||
const apiRef = useRef(api);
|
|
||||||
useEffect(() => {
|
|
||||||
apiRef.current = api;
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const releaseLiveStream = useCallback(
|
|
||||||
(liveStreamId: string | null) => {
|
|
||||||
if (!liveStreamId || !apiRef.current || offline) return;
|
|
||||||
// Best effort: a failed close must not break teardown, and the slot is
|
|
||||||
// also freed by the server's reap as a backstop.
|
|
||||||
getMediaInfoApi(apiRef.current)
|
|
||||||
.closeLiveStream({ liveStreamId })
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
|
||||||
[offline],
|
|
||||||
);
|
|
||||||
|
|
||||||
// The effect cleanup releases the live stream both when it changes (channel
|
|
||||||
// switch, which re-runs the effect) and when the player unmounts, so no
|
|
||||||
// manual previous-id tracking is needed.
|
|
||||||
useEffect(() => {
|
|
||||||
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
|
|
||||||
return () => releaseLiveStream(liveStreamId);
|
|
||||||
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
@@ -477,12 +462,6 @@ export default function DirectPlayerPage() {
|
|||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: currentTimeInTicks,
|
PositionTicks: currentTimeInTicks,
|
||||||
PlaySessionId: stream.sessionId,
|
PlaySessionId: stream.sessionId,
|
||||||
// Release the server-side live stream (and its tuner slot) on stop.
|
|
||||||
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
|
|
||||||
// the stop report carries its LiveStreamId; without it the stream leaks
|
|
||||||
// and Live TV eventually fails for everyone with "M3U simultaneous
|
|
||||||
// stream limit has been reached". Undefined for non-live items (no-op).
|
|
||||||
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
@@ -494,23 +473,10 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
videoRef.current?.pause();
|
||||||
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
|
||||||
// episode's player) mounts while the old 4K decoder is still alive,
|
|
||||||
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
|
||||||
// later React unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
// Pre-libmpv-1.0 used `stop()`:
|
|
||||||
// videoRef.current?.stop();
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
// Release the keep-awake wakelock acquired during playback so it
|
|
||||||
// doesn't follow us back to the home screen and block the TV
|
|
||||||
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
|
||||||
// and only released on the "paused" event; without this, navigating
|
|
||||||
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
|
||||||
deactivateKeepAwake();
|
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -931,27 +897,6 @@ export default function DirectPlayerPage() {
|
|||||||
// Check if we're transcoding
|
// Check if we're transcoding
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// A transcoded stream only carries the audio track the server encoded
|
|
||||||
// into it — switching requires re-negotiating the stream with the new
|
|
||||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
|
||||||
if (isTranscoding) {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item?.Id ?? "",
|
|
||||||
audioIndex: String(index),
|
|
||||||
subtitleIndex: String(currentSubtitleIndex),
|
|
||||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition: msToTicks(progress.get()).toString(),
|
|
||||||
}).toString();
|
|
||||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
|
||||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
|
||||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
|
||||||
// devices. Resume is preserved via the playbackPosition param.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Jellyfin index to MPV track ID
|
// Convert Jellyfin index to MPV track ID
|
||||||
const mpvTrackId = getMpvAudioId(
|
const mpvTrackId = getMpvAudioId(
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
@@ -963,14 +908,7 @@ export default function DirectPlayerPage() {
|
|||||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[stream?.mediaSource],
|
||||||
stream?.mediaSource,
|
|
||||||
item?.Id,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
progress,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
@@ -1184,15 +1122,6 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
|
||||||
// decoder + surface buffers are freed before the new player screen
|
|
||||||
// mounts. Without this, Expo Router briefly holds two simultaneous
|
|
||||||
// mpv instances during the transition (~768 MB of surface buffers
|
|
||||||
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
|
||||||
// devices. Native stop() is idempotent so the subsequent React
|
|
||||||
// unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1203,7 +1132,6 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
videoRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1356,7 +1284,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
InteractionManager,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TVFocusGuideView,
|
TVFocusGuideView,
|
||||||
@@ -76,20 +75,6 @@ export default function TVOptionModal() {
|
|||||||
}, [isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
const handleSelect = (value: any) => {
|
const handleSelect = (value: any) => {
|
||||||
if (modalState?.deferApplyUntilDismissed) {
|
|
||||||
// onSelect navigates (the transcode audio switch replacing the player);
|
|
||||||
// a router.replace fired while this modal is the active route would be
|
|
||||||
// swallowed. Close FIRST, apply after dismissal.
|
|
||||||
const onSelect = modalState.onSelect;
|
|
||||||
store.set(tvOptionModalAtom, null);
|
|
||||||
router.back();
|
|
||||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// State-only callers (detail page, library filters, settings): run before
|
|
||||||
// closing so the re-render happens while the modal is up. Deferring it until
|
|
||||||
// after dismissal re-renders the page after focus returns and yanks TV
|
|
||||||
// focus, leaving navigation stuck.
|
|
||||||
modalState?.onSelect(value);
|
modalState?.onSelect(value);
|
||||||
store.set(tvOptionModalAtom, null);
|
store.set(tvOptionModalAtom, null);
|
||||||
router.back();
|
router.back();
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
{t("player.hash_match")}
|
Hash Match
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
305
app/_layout.tsx
305
app/_layout.tsx
@@ -7,11 +7,9 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -28,7 +26,6 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
|||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
@@ -87,8 +84,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -104,22 +100,6 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
|
||||||
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
|
||||||
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
|
||||||
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
|
||||||
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
|
||||||
try {
|
|
||||||
Image.configureCache({
|
|
||||||
maxMemoryCost: Platform.isTV
|
|
||||||
? 8 * 1024 * 1024 // ~8 MB on TV
|
|
||||||
: 128 * 1024 * 1024, // ~128 MB on mobile
|
|
||||||
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -353,12 +333,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -427,149 +404,143 @@ function Layout() {
|
|||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<InactivityProvider>
|
<InactivityProvider>
|
||||||
<WifiSsidProvider>
|
<ServerUrlProvider>
|
||||||
<ServerUrlProvider>
|
<NetworkStatusProvider>
|
||||||
<NetworkStatusProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<DownloadProvider>
|
||||||
<DownloadProvider>
|
<MusicPlayerProvider>
|
||||||
<MusicPlayerProvider>
|
<GlobalModalProvider>
|
||||||
<GlobalModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<IntroSheetProvider>
|
||||||
<IntroSheetProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style='light' hidden={false} />
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name='(auth)/(tabs)'
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/player'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/now-playing'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent:
|
|
||||||
Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-action-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-user-switch-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
<Stack.Screen
|
||||||
{!Platform.isTV && (
|
name='(auth)/player'
|
||||||
<PendingAccountSaveModal />
|
options={{
|
||||||
)}
|
headerShown: false,
|
||||||
</ThemeProvider>
|
title: "",
|
||||||
</IntroSheetProvider>
|
header: () => null,
|
||||||
</BottomSheetModalProvider>
|
}}
|
||||||
</GlobalModalProvider>
|
/>
|
||||||
</MusicPlayerProvider>
|
<Stack.Screen
|
||||||
</DownloadProvider>
|
name='(auth)/now-playing'
|
||||||
</WebSocketProvider>
|
options={{
|
||||||
</LogProvider>
|
headerShown: false,
|
||||||
</PlaySettingsProvider>
|
presentation: "modal",
|
||||||
</NetworkStatusProvider>
|
gestureEnabled: true,
|
||||||
</ServerUrlProvider>
|
}}
|
||||||
</WifiSsidProvider>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
</ThemeProvider>
|
||||||
|
</IntroSheetProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</MusicPlayerProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
|
</ServerUrlProvider>
|
||||||
</InactivityProvider>
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this.replaceAll("_", " ").replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -35,10 +35,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
@@ -63,8 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
}, [item]);
|
||||||
}, [api, item, useEpisodePoster]);
|
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -59,6 +60,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
const downloadedItem =
|
||||||
|
isOffline && item?.Id ? getDownloadedItemById(item.Id) : null;
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -105,17 +109,30 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// When offline, use the indices stored in userData (the last-used tracks for this file)
|
||||||
|
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
|
||||||
|
const offlineUserData = downloadedItem?.userData;
|
||||||
|
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex:
|
||||||
audioIndex: defaultAudioIndex,
|
offlineUserData && !offlineUserData.isTranscoded
|
||||||
|
? offlineUserData.subtitleStreamIndex
|
||||||
|
: (defaultSubtitleIndex ?? -1),
|
||||||
|
audioIndex:
|
||||||
|
offlineUserData && !offlineUserData.isTranscoded
|
||||||
|
? offlineUserData.audioStreamIndex
|
||||||
|
: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
|
downloadedItem?.userData?.audioStreamIndex,
|
||||||
|
downloadedItem?.userData?.subtitleStreamIndex,
|
||||||
|
downloadedItem?.userData?.isTranscoded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -28,6 +30,14 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
|
||||||
|
// For transcoded downloads there's only one burned-in track — nothing to pick
|
||||||
|
const isTranscodedDownload = useMemo(() => {
|
||||||
|
if (!isOffline || !item?.Id) return false;
|
||||||
|
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
|
||||||
|
}, [isOffline, item?.Id, getDownloadedItemById]);
|
||||||
|
|
||||||
const effectiveColors = colors || {
|
const effectiveColors = colors || {
|
||||||
primary: "#7c3aed",
|
primary: "#7c3aed",
|
||||||
@@ -72,34 +82,36 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
const groups: OptionGroup[] = [];
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
// Bitrate group
|
if (!isOffline) {
|
||||||
groups.push({
|
// Bitrate group
|
||||||
title: t("item_card.quality"),
|
|
||||||
options: BITRATES.map((bitrate) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: bitrate.key,
|
|
||||||
value: bitrate,
|
|
||||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Media Source group (only if multiple sources)
|
|
||||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("item_card.video"),
|
title: t("item_card.quality"),
|
||||||
options: item.MediaSources.map((source) => ({
|
options: BITRATES.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: getMediaSourceDisplayName(source),
|
label: bitrate.key,
|
||||||
value: source,
|
value: bitrate,
|
||||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
onPress: () =>
|
onPress: () =>
|
||||||
setSelectedOptions(
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
(prev) => prev && { ...prev, mediaSource: source },
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio track group
|
// Audio track group
|
||||||
@@ -150,6 +162,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
|
isOffline,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
audioStreams,
|
audioStreams,
|
||||||
subtitleStreams,
|
subtitleStreams,
|
||||||
@@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isTranscodedDownload) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import {
|
|
||||||
pendingAccountSaveAtom,
|
|
||||||
useJellyfin,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { writeErrorLog } from "@/utils/log";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
|
||||||
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
|
||||||
* shows here, AFTER the session is authorized — the login screen itself
|
|
||||||
* unmounts as soon as the user is set, so it can't host the modal.
|
|
||||||
*/
|
|
||||||
export const PendingAccountSaveModal: React.FC = () => {
|
|
||||||
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { saveCurrentAccount } = useJellyfin();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// A logout before answering drops the intent — it must not resurface on
|
|
||||||
// the next (possibly different) login.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user && pending) setPending(null);
|
|
||||||
}, [user, pending, setPending]);
|
|
||||||
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SaveAccountModal
|
|
||||||
visible={!!pending && !!user}
|
|
||||||
username={user?.Name ?? ""}
|
|
||||||
onClose={() => setPending(null)}
|
|
||||||
onSave={(securityType, pinCode) => {
|
|
||||||
const serverName = pending?.serverName;
|
|
||||||
setPending(null);
|
|
||||||
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
|
||||||
(error) => {
|
|
||||||
writeErrorLog(`Failed to save account: ${error?.message ?? error}`);
|
|
||||||
toast.error(t("save_account.not_saved"));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,14 +96,23 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
offline: isOffline ? "true" : "false",
|
offline: isOffline ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selectedOptions.audioIndex !== undefined) {
|
||||||
|
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOptions.subtitleIndex !== undefined) {
|
||||||
|
queryParams.set(
|
||||||
|
"subtitleIndex",
|
||||||
|
selectedOptions.subtitleIndex.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -292,6 +301,29 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const buildOfflineQueryParams = useCallback(
|
||||||
|
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
|
||||||
|
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
|
||||||
|
const audioIdx = isTranscoded
|
||||||
|
? downloadedItem.userData?.audioStreamIndex
|
||||||
|
: selectedOptions.audioIndex;
|
||||||
|
const subtitleIdx = isTranscoded
|
||||||
|
? downloadedItem.userData?.subtitleStreamIndex
|
||||||
|
: selectedOptions.subtitleIndex;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
|
||||||
|
if (subtitleIdx !== undefined)
|
||||||
|
params.set("subtitleIndex", subtitleIdx.toString());
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
[item, selectedOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
@@ -302,13 +334,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
// If already in offline mode, play downloaded file directly
|
||||||
if (isOffline && downloadedItem) {
|
if (isOffline && downloadedItem) {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +357,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(
|
||||||
itemId: item.Id!,
|
buildOfflineQueryParams(downloadedItem).toString(),
|
||||||
offline: "true",
|
);
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
}}
|
||||||
color='purple'
|
color='purple'
|
||||||
>
|
>
|
||||||
@@ -374,13 +396,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
text: t("player.downloaded_file_yes"),
|
text: t("player.downloaded_file_yes"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer(buildOfflineQueryParams(downloadedItem).toString());
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
},
|
||||||
isPreferred: true,
|
isPreferred: true,
|
||||||
},
|
},
|
||||||
@@ -502,8 +518,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
{t("home.sessions.select_session")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
{t("home.sessions.no_active_sessions")}
|
No active sessions found
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
{t("home.sessions.now_playing")}{" "}
|
Now playing:{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
99
components/TrackSheet.tsx
Normal file
99
components/TrackSheet.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { FilterSheet } from "./filters/FilterSheet";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
source?: MediaSourceInfo;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
selected?: number | undefined;
|
||||||
|
streamType?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackSheet: React.FC<Props> = ({
|
||||||
|
source,
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
streamType,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const streams = useMemo(
|
||||||
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
|
[source],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSteam = useMemo(
|
||||||
|
() => streams?.find((x) => x.Index === selected),
|
||||||
|
[streams, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const noneOption = useMemo(
|
||||||
|
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Creates a modified data array that includes a "None" option for subtitles
|
||||||
|
// We might want to possibly do this for other places, like audio?
|
||||||
|
const addNoneToSubtitles = useMemo(() => {
|
||||||
|
if (streamType === "Subtitle") {
|
||||||
|
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}, [streams, streamType, noneOption]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isTv || (streams && streams.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{selected === -1 && streamType === "Subtitle"
|
||||||
|
? t("common.none")
|
||||||
|
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={title}
|
||||||
|
data={addNoneToSubtitles || []}
|
||||||
|
values={
|
||||||
|
selected === -1 && streamType === "Subtitle"
|
||||||
|
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||||
|
: selectedSteam
|
||||||
|
? [selectedSteam]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const label = (item as any).DisplayTitle || "";
|
||||||
|
return label.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}}
|
||||||
|
renderItemLabel={(item) => (
|
||||||
|
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||||
|
)}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as any;
|
||||||
|
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||||
|
onChange(chosen.Index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,119 +1,43 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewStyle } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
|
|
||||||
const isAggregateType = (item: BaseItemDto) =>
|
|
||||||
item.Type === "Series" || item.Type === "BoxSet";
|
|
||||||
|
|
||||||
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
|
||||||
const tvBadgeBase: ViewStyle = {
|
|
||||||
position: "absolute",
|
|
||||||
top: scaleSize(8),
|
|
||||||
right: scaleSize(8),
|
|
||||||
height: scaleSize(28),
|
|
||||||
borderRadius: scaleSize(14),
|
|
||||||
backgroundColor: "rgba(255,255,255,0.92)",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mobile uses raw dp — no scaling.
|
|
||||||
const mobileBadgeBase: ViewStyle = {
|
|
||||||
position: "absolute",
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: "#9333ea",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
|
||||||
* have episodes left to watch. Returns null for non-aggregate types, fully
|
|
||||||
* watched items, or items with no unplayed count, so it is safe to mount
|
|
||||||
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
|
||||||
* the watched checkmark is drawn natively and only the count needs RN).
|
|
||||||
*/
|
|
||||||
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
|
||||||
({ item }) => {
|
|
||||||
if (!isAggregateType(item)) return null;
|
|
||||||
if (item.UserData?.Played) return null;
|
|
||||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
|
||||||
if (unplayed <= 0) return null;
|
|
||||||
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
|
||||||
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
|
||||||
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
tvBadgeBase,
|
|
||||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: scaleSize(15),
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "black",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
|
||||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
|
||||||
const isAggregate = isAggregateType(item);
|
|
||||||
const isPlayed = item.UserData?.Played === true;
|
|
||||||
|
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// Fully watched → white checkmark badge (top-right)
|
// TV: Show white checkmark when watched
|
||||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
if (
|
||||||
|
item.UserData?.Played &&
|
||||||
|
(item.Type === "Movie" || item.Type === "Episode")
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
<View
|
||||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
|
borderRadius: 14,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={18} color='black' />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Series/BoxSet with remaining episodes → count badge
|
return null;
|
||||||
return <UnplayedCountBadge item={item} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
// Mobile: Show purple triangle for unwatched
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
{item.UserData?.Played === false &&
|
||||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
|
||||||
{isAggregate && isPlayed && (
|
|
||||||
<View style={[mobileBadgeBase, { width: 20 }]}>
|
|
||||||
<Ionicons name='checkmark' size={13} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Series/BoxSet with remaining episodes → count badge */}
|
|
||||||
<UnplayedCountBadge item={item} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
@@ -23,7 +23,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Ionicons name='arrow-back' size={24} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -49,18 +49,13 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
// Match the Settings page back button: chevron flush to the edge with a
|
className=' rounded-full p-2'
|
||||||
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
|
||||||
// too far right). drop-shadow keeps it readable over images. hitSlop
|
|
||||||
// keeps the touch area at ~44dp now that the padding is gone.
|
|
||||||
style={{ marginRight: 16 }}
|
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
hitSlop={8}
|
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
|
||||||
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
|
||||||
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
|
||||||
* FIXED-SIZE box (overflow hidden) and center it:
|
|
||||||
* - the fixed height caps the row height (compact, uniform rows),
|
|
||||||
* - the fixed width + centering keep the switch in the exact same spot in the
|
|
||||||
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
|
||||||
* which shifted the switch sideways on toggle).
|
|
||||||
* iOS renders the switch untouched.
|
|
||||||
*
|
|
||||||
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
|
||||||
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
|
||||||
*/
|
|
||||||
const BOX_W = 40;
|
|
||||||
const BOX_H = 30;
|
|
||||||
const SCALE = 0.9;
|
|
||||||
|
|
||||||
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
|
||||||
if (Platform.OS !== "android") return <Switch {...props} />;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: BOX_W,
|
|
||||||
height: BOX_H,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
{...props}
|
|
||||||
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
{t("home.downloads.transcoding")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import {
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
|
||||||
pendingAccountSaveAtom,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -93,9 +89,6 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
// Gate the intro so it can't steal presentation from the post-login
|
|
||||||
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
|
||||||
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -104,9 +97,7 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
if (!hasShownIntro) {
|
||||||
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
|
||||||
if (!hasShownIntro && !pendingAccountSave) {
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -115,7 +106,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro, pendingAccountSave]);
|
}, [showIntro]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -230,33 +221,22 @@ const HomeMobile = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// Use getItems (not getLatestMedia) so we get item-level results
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
// filtered by type from a specific library. getLatestMedia is
|
const allData =
|
||||||
// episode-oriented and groups results, which drops Series when
|
(
|
||||||
// combined with a parentId + includeItemTypes filter.
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
//
|
userId: user?.Id,
|
||||||
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
limit: 10,
|
||||||
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
//
|
imageTypeLimit: 1,
|
||||||
// For Series we sort by DateLastContentAdded so shows bubble up when
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
// a new episode is added (series cards for new episodes, matching how
|
includeItemTypes,
|
||||||
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
parentId,
|
||||||
const response = await getItemsApi(api).getItems({
|
})
|
||||||
userId: user?.Id,
|
).data || [];
|
||||||
parentId,
|
|
||||||
includeItemTypes,
|
// Simulate pagination by slicing
|
||||||
recursive: true,
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
sortBy: includeItemTypes.includes("Series")
|
|
||||||
? ["DateLastContentAdded"]
|
|
||||||
: ["DateCreated"],
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -270,7 +250,9 @@ const HomeMobile = () => {
|
|||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey: string[] = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
|
|||||||
@@ -140,11 +140,9 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
// Prefetch the image before starting the crossfade
|
||||||
// decoded ARGB) is too large to pin in the memory cache on every
|
|
||||||
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -86,7 +85,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -188,10 +186,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster item={item} />
|
||||||
item={item}
|
|
||||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -166,7 +165,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -203,18 +201,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleSeeAllPress = useCallback(() => {
|
const handleSeeAllPress = useCallback(() => {
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
|
||||||
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
|
||||||
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
|
||||||
// (b) intercept Back to route to the library list so the user can switch
|
|
||||||
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/[libraryId]",
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
params: {
|
params: {
|
||||||
libraryId: parentId,
|
libraryId: parentId,
|
||||||
sortBy: SortByOption.DateCreated,
|
sortBy: SortByOption.DateCreated,
|
||||||
sortOrder: SortOrderOption.Descending,
|
sortOrder: SortOrderOption.Descending,
|
||||||
fromSeeAll: "true",
|
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
@@ -233,7 +225,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -246,7 +237,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
settings?.useEpisodeImagesForNextUp,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={4}
|
initialNumToRender={5}
|
||||||
maxToRenderPerBatch={2}
|
maxToRenderPerBatch={3}
|
||||||
windowSize={3}
|
windowSize={5}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
@@ -358,14 +348,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
// No fixed width: the footer must size to the "See All" card so the
|
|
||||||
// FlatList's scrollable content extends to fully reveal it. A fixed
|
|
||||||
// (narrow) width clipped the card at the right edge. Trailing space is
|
|
||||||
// provided by contentContainerStyle.paddingRight.
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
width: sizes.padding.horizontal,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -51,7 +50,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -110,10 +108,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster item={item} />
|
||||||
item={item}
|
|
||||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -65,11 +65,10 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
// For episodes, always use series thumb.
|
// For episodes, always use series thumb
|
||||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
if (item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
@@ -257,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
|
||||||
// out of the memory cache avoids bloat when the user cycles through
|
|
||||||
// hero items quickly.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
@@ -383,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = scaleSize(145);
|
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[api, library],
|
[library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
@@ -23,11 +23,9 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
{title ? (
|
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
{title}
|
||||||
{title}
|
</Text>
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -33,22 +32,14 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
disabledByAdmin = false,
|
disabledByAdmin = false,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const effectiveSubtitle = disabledByAdmin
|
|
||||||
? t("home.settings.disabled_by_admin")
|
|
||||||
: subtitle;
|
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
// Keep the row floor uniform; Android trims padding slightly (its native
|
|
||||||
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
|
||||||
// rows match non-toggle rows.
|
|
||||||
const rowSizing =
|
|
||||||
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -67,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.live")}
|
LIVE
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("live_tv.title")}
|
Live TV
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,16 +20,14 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import {
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
import type {
|
||||||
pendingAccountSaveAtom,
|
AccountSecurityType,
|
||||||
useJellyfin,
|
SavedServer,
|
||||||
userAtom,
|
} from "@/utils/secureCredentials";
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import type { SavedServer } from "@/utils/secureCredentials";
|
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -37,7 +35,6 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -45,11 +42,9 @@ export const Login: React.FC = () => {
|
|||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -69,50 +64,13 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
// Save account state
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Close the code sheet as soon as the session is authorized — the native
|
|
||||||
// Alert used before had no programmatic dismiss and stayed open after login.
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) setQuickConnectCode(null);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stopQuickConnectPolling();
|
|
||||||
};
|
|
||||||
}, [stopQuickConnectPolling]);
|
|
||||||
|
|
||||||
// Going back to server selection keeps this component mounted (same screen,
|
|
||||||
// different state), so the unmount cleanup above doesn't run. Without this a
|
|
||||||
// code authorized after leaving would silently log the user in later.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api?.basePath) {
|
|
||||||
stopQuickConnectPolling();
|
|
||||||
setQuickConnectCode(null);
|
|
||||||
}
|
|
||||||
}, [api?.basePath, stopQuickConnectPolling]);
|
|
||||||
|
|
||||||
// Save account state — only the intent lives here; the protection picker is
|
|
||||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
const [pendingLogin, setPendingLogin] = useState<{
|
||||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
username: string;
|
||||||
|
password: string;
|
||||||
// A Quick Connect login with "save account" on flags the post-login save:
|
} | null>(null);
|
||||||
// the protection picker shows globally once the session exists (this screen
|
|
||||||
// unmounts on login, so it can't host the modal).
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
if (quickConnectActive && saveAccount) {
|
|
||||||
setPendingAccountSave({ serverName });
|
|
||||||
}
|
|
||||||
setQuickConnectActive(false);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,34 +117,55 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
const ok = await performLogin(credentials.username, credentials.password);
|
if (saveAccount) {
|
||||||
// The protection picker shows AFTER a successful login (global modal) —
|
setPendingLogin({
|
||||||
// never for a failed one.
|
username: credentials.username,
|
||||||
if (ok && saveAccount) {
|
password: credentials.password,
|
||||||
setPendingAccountSave({ serverName });
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<boolean> => {
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName);
|
await login(username, password, serverName, options);
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAccountConfirm = async (
|
||||||
|
securityType: AccountSecurityType,
|
||||||
|
pinCode?: string,
|
||||||
|
) => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
if (pendingLogin) {
|
||||||
|
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||||
|
saveAccount: true,
|
||||||
|
securityType,
|
||||||
|
pinCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,8 +259,15 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
setQuickConnectActive(true);
|
Alert.alert(
|
||||||
setQuickConnectCode(code);
|
t("login.quick_connect"),
|
||||||
|
t("login.enter_code_to_login", { code: code }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -416,7 +402,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label={t("server.server_url")}
|
aria-label='Server URL'
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -458,11 +444,14 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Dismissing only hides the code — polling continues so the login still
|
<SaveAccountModal
|
||||||
completes if the code is authorized from another device afterwards. */}
|
visible={showSaveModal}
|
||||||
<QuickConnectCodeModal
|
onClose={() => {
|
||||||
code={quickConnectCode}
|
setShowSaveModal(false);
|
||||||
onClose={() => setQuickConnectCode(null)}
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The Quick Connect code to display, or null when hidden. */
|
|
||||||
code: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the Quick Connect code while the app polls for authorization.
|
|
||||||
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
|
||||||
* session is authorized — a native alert has no programmatic dismiss and
|
|
||||||
* lingers over the app after login completes.
|
|
||||||
*/
|
|
||||||
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const snapPoints = useMemo(() => ["50%"], []);
|
|
||||||
const isPresentedRef = useRef(false);
|
|
||||||
|
|
||||||
// Keep the last code around so the dismiss animation doesn't flash empty
|
|
||||||
// when the parent clears the code to close the sheet.
|
|
||||||
const lastCodeRef = useRef<string | null>(null);
|
|
||||||
if (code) lastCodeRef.current = code;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (code) {
|
|
||||||
bottomSheetModalRef.current?.present();
|
|
||||||
} else if (isPresentedRef.current) {
|
|
||||||
bottomSheetModalRef.current?.dismiss();
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index >= 0) {
|
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyCode = useCallback(async () => {
|
|
||||||
const value = code ?? lastCodeRef.current;
|
|
||||||
if (!value) return;
|
|
||||||
// Builds that don't ship the expo-clipboard native module yet: probe with
|
|
||||||
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
|
||||||
// and skip — importing the JS wrapper there would error out.
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
await Clipboard.setStringAsync(value);
|
|
||||||
toast.success(t("login.code_copied"));
|
|
||||||
}, [code, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
paddingBottom: Math.max(16, insets.bottom),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex-1'>
|
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
|
||||||
{t("login.quick_connect")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
|
||||||
onPress={copyCode}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className='text-center font-bold text-5xl text-neutral-100'
|
|
||||||
style={{ letterSpacing: 10 }}
|
|
||||||
>
|
|
||||||
{code ?? lastCodeRef.current}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='copy-outline'
|
|
||||||
size={22}
|
|
||||||
color='white'
|
|
||||||
style={{ opacity: 0.4, marginLeft: 16 }}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
|
||||||
{t("login.tap_code_to_copy")}
|
|
||||||
</Text>
|
|
||||||
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
|
||||||
{t("login.quick_connect_instructions")}
|
|
||||||
</Text>
|
|
||||||
<Button className='mt-auto' color='purple' onPress={onClose}>
|
|
||||||
{t("login.got_it")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
// Prefetch the image before starting the crossfade
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
|
||||||
import { ItemImage } from "../common/ItemImage";
|
import { ItemImage } from "../common/ItemImage";
|
||||||
|
import { WatchedIndicator } from "../WatchedIndicator";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
@@ -3,7 +3,6 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WatchedIndicator item={item} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.library")}
|
label='Library'
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.discover")}
|
label='Discover'
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking } from "react-native";
|
import { Linking, Switch } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -28,7 +27,6 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -36,7 +34,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -46,51 +44,31 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.use_episode_images_next_up")}
|
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.use_episode_images_next_up_hint",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingSwitch
|
|
||||||
value={settings.useEpisodeImagesForNextUp}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.hide_remote_session_button_hint",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingSwitch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -10,7 +9,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch } from "react-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
// Only the server URL is admin-lockable — the password stays editable so
|
|
||||||
// the user can still sign in to the admin-pinned Jellyseerr server.
|
|
||||||
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -33,25 +30,22 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(settings?.jellyseerrServerUrl || undefined);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
// When the URL is admin-pinned, ignore any locally-typed value so the login
|
|
||||||
// targets the same server the (read-only) input displays.
|
|
||||||
const effectiveServerUrl = urlLocked
|
|
||||||
? settings?.jellyseerrServerUrl
|
|
||||||
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
|
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!effectiveServerUrl) throw new Error("Missing server url");
|
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||||
|
throw new Error("Missing server url");
|
||||||
if (!user?.Name)
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
const jellyseerrTempApi = new JellyseerrApi(
|
||||||
|
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||||
|
);
|
||||||
const testResult = await jellyseerrTempApi.test();
|
const testResult = await jellyseerrTempApi.test();
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
updateSettings({ jellyseerrServerUrl });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
@@ -121,41 +115,33 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
<Text className='font-bold mb-1'>
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
</Text>
|
||||||
|
<Text className='font-bold mb-1'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex flex-col shrink mb-2'>
|
||||||
|
<Text className='text-xs text-gray-600'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-col shrink mb-2'>
|
|
||||||
<Text className='text-xs text-gray-600'>
|
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Input
|
|
||||||
className='border border-neutral-800 mb-2'
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
value={
|
|
||||||
urlLocked
|
|
||||||
? settings?.jellyseerrServerUrl
|
|
||||||
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
|
||||||
}
|
|
||||||
defaultValue={
|
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
|
||||||
}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={setjellyseerrServerUrl}
|
|
||||||
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
|
||||||
/>
|
|
||||||
{urlLocked && (
|
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
|
||||||
{t("home.settings.disabled_by_admin")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
<Input
|
||||||
|
className='border border-neutral-800 mb-2'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
|
/>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch, Text, View } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup>
|
<View className=''>
|
||||||
<ListItem
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
disabledByAdmin={locked}
|
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
>
|
</Text>
|
||||||
<SettingSwitch
|
|
||||||
value={isEnabled}
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
disabled={locked}
|
<Text className='text-white'>
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||||
/>
|
</Text>
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
<Switch
|
||||||
|
value={isEnabled}
|
||||||
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
|
trackColor={{ false: "#555", true: "purple" }}
|
||||||
|
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user