mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 18:54:16 +01:00
Compare commits
8 Commits
fix/skip-b
...
fix/tv-see
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1d3e4f66 | ||
|
|
151a39c7fc | ||
|
|
a4bc67bc23 | ||
|
|
ac41fa7863 | ||
|
|
cd5300e4ba | ||
|
|
36d18e2bec | ||
|
|
326956dfda | ||
|
|
7528274249 |
@@ -13,7 +13,7 @@ build:
|
|||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android, bun)
|
name: Prebuild (Android, bun)
|
||||||
command: bun x expo prebuild --platform android --no-install
|
command: bunx expo prebuild --platform android --no-install
|
||||||
|
|
||||||
- eas/configure_android_version
|
- eas/configure_android_version
|
||||||
- eas/inject_android_credentials
|
- eas/inject_android_credentials
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ build:
|
|||||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android TV, bun)
|
name: Prebuild (Android TV, bun)
|
||||||
command: bun x expo prebuild --platform android --no-install
|
command: bunx expo prebuild --platform android --no-install
|
||||||
|
|
||||||
- eas/configure_android_version
|
- eas/configure_android_version
|
||||||
- eas/inject_android_credentials
|
- eas/inject_android_credentials
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ build:
|
|||||||
command: bun install --frozen-lockfile
|
command: bun install --frozen-lockfile
|
||||||
|
|
||||||
# android/ is gitignored, so generate native code fresh. --no-install
|
# android/ is gitignored, so generate native code fresh. --no-install
|
||||||
# because deps are already installed above; bun x keeps it on bun.
|
# because deps are already installed above; bunx keeps it on bun.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (Android, bun)
|
name: Prebuild (Android, bun)
|
||||||
command: bun x expo prebuild --platform android --no-install
|
command: bunx expo prebuild --platform android --no-install
|
||||||
|
|
||||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
||||||
# in eas.json) into the freshly prebuilt android/ project.
|
# in eas.json) into the freshly prebuilt android/ project.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ build:
|
|||||||
# skips JS + pod install; we install pods explicitly below with bun deps.
|
# skips JS + pod install; we install pods explicitly below with bun deps.
|
||||||
- run:
|
- run:
|
||||||
name: Prebuild (iOS/tvOS, bun)
|
name: Prebuild (iOS/tvOS, bun)
|
||||||
command: bun x expo prebuild --platform ios --no-install
|
command: bunx expo prebuild --platform ios --no-install
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install CocoaPods
|
name: Install CocoaPods
|
||||||
|
|||||||
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
|
||||||
|
|||||||
64
.github/renovate.json
vendored
64
.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": {
|
||||||
@@ -52,42 +44,22 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vulnerabilityAlerts": {
|
"lockFileMaintenance": {
|
||||||
"enabled": true,
|
"vulnerabilityAlerts": {
|
||||||
"addLabels": ["security", "vulnerability"],
|
"enabled": true,
|
||||||
"assigneesFromCodeOwners": true,
|
"addLabels": ["security", "vulnerability"],
|
||||||
"commitMessageSuffix": " [SECURITY]"
|
"assigneesFromCodeOwners": true,
|
||||||
},
|
"commitMessageSuffix": " [SECURITY]"
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
|
||||||
"matchPackageNames": [
|
|
||||||
"expo",
|
|
||||||
"react",
|
|
||||||
"react-dom",
|
|
||||||
"react-native",
|
|
||||||
"react-native-web",
|
|
||||||
"expo-*",
|
|
||||||
"@expo/*"
|
|
||||||
],
|
|
||||||
"groupName": "Expo SDK",
|
|
||||||
"dependencyDashboardApproval": true
|
|
||||||
},
|
},
|
||||||
{
|
"packageRules": [
|
||||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
{
|
||||||
"matchManagers": ["github-actions"],
|
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||||
"groupName": "CI dependencies",
|
"matchManagers": ["github-actions"],
|
||||||
"groupSlug": "ci-deps",
|
"groupName": "CI dependencies",
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
"groupSlug": "ci-deps",
|
||||||
"automerge": true
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
},
|
"automerge": true
|
||||||
{
|
}
|
||||||
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
]
|
||||||
"matchDatasources": ["maven"],
|
}
|
||||||
"registryUrls": [
|
|
||||||
"https://dl.google.com/dl/android/maven2/",
|
|
||||||
"https://repo.maven.apache.org/maven2/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
129
.github/workflows/artifact-comment.yml
vendored
129
.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,8 +229,10 @@ 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] = {
|
||||||
name: job.name,
|
name: job.name,
|
||||||
@@ -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,9 +406,11 @@ 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}`;
|
||||||
} else if (matchingStatus.conclusion === 'failure') {
|
} else if (matchingStatus.conclusion === 'failure') {
|
||||||
status = `❌ [Failed](${matchingStatus.url})`;
|
status = `❌ [Failed](${matchingStatus.url})`;
|
||||||
@@ -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`;
|
||||||
|
|||||||
443
.github/workflows/build-apps.yml
vendored
443
.github/workflows/build-apps.yml
vendored
@@ -11,102 +11,77 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
|
||||||
# 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
|
|
||||||
# run (artifacts + logs) without needing Expo access.
|
|
||||||
env:
|
|
||||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
|
||||||
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
|
||||||
|
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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 +95,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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 +178,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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,12 +215,23 @@ 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.4"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # 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
|
||||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
run: eas build -p ios --local --non-interactive
|
||||||
# the production version tier and stops them inflating the store build counter.
|
|
||||||
run: eas build -p ios --local --non-interactive --profile ci
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -294,46 +243,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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 +280,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.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
@@ -358,57 +301,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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,10 +340,23 @@ 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.4"
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # 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
|
||||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -442,37 +377,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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 +408,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.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
@@ -496,6 +428,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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: |
|
||||||
|
|||||||
13
.github/workflows/ci-codeql.yml
vendored
13
.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,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|||||||
4
.github/workflows/conflict.yml
vendored
4
.github/workflows/conflict.yml
vendored
@@ -10,14 +10,14 @@ 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
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚩 Apply merge conflict label
|
- name: 🚩 Apply merge conflict label
|
||||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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 }}
|
||||||
|
|||||||
114
.github/workflows/linting.yml
vendored
114
.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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:
|
||||||
@@ -80,29 +97,24 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
- "i18n:check"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- parallel:
|
- name: "📥 Checkout PR code"
|
||||||
- name: "📥 Checkout PR code"
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@b184ff86a3c926240f1b6db41764c83a01c02eef # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
50
.github/workflows/trivy-scan.yml
vendored
50
.github/workflows/trivy-scan.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
name: 🛡️ Trivy Security Scan
|
|
||||||
|
|
||||||
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
|
||||||
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
|
||||||
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
|
||||||
# upload needs a write token that fork PRs don't get).
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
schedule:
|
|
||||||
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: trivy-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
trivy:
|
|
||||||
name: 🔎 Filesystem scan
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write # upload SARIF to code scanning
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
# 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
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
|
||||||
scan-type: fs
|
|
||||||
scan-ref: .
|
|
||||||
scanners: vuln,secret,misconfig
|
|
||||||
ignore-unfixed: true
|
|
||||||
severity: CRITICAL,HIGH
|
|
||||||
format: sarif
|
|
||||||
output: trivy-results.sarif
|
|
||||||
|
|
||||||
- name: 📤 Upload results to code scanning
|
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
sarif_file: trivy-results.sarif
|
|
||||||
category: trivy-fs
|
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
29
app.config.js
Normal file
29
app.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV !== "1") {
|
||||||
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
config.plugins.push([
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
cameraPermission:
|
||||||
|
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only override googleServicesFile if env var is set
|
||||||
|
const androidConfig = {};
|
||||||
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
|
||||||
// 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
|
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
|
||||||
const git = (args: string[]): string | null => {
|
|
||||||
try {
|
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
|
||||||
.toString()
|
|
||||||
.trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMeta = {
|
|
||||||
commit:
|
|
||||||
(
|
|
||||||
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
|
||||||
process.env.GITHUB_SHA ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
|
||||||
git(["rev-parse", "HEAD"]) ||
|
|
||||||
""
|
|
||||||
).slice(0, 7) || null,
|
|
||||||
branch:
|
|
||||||
process.env.EAS_BUILD_GIT_BRANCH ||
|
|
||||||
process.env.GITHUB_HEAD_REF ||
|
|
||||||
process.env.GITHUB_REF_NAME ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
|
||||||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
|
||||||
null,
|
|
||||||
profile:
|
|
||||||
process.env.EAS_BUILD_PROFILE ||
|
|
||||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
|
||||||
null,
|
|
||||||
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
|
||||||
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
|
||||||
runNumber:
|
|
||||||
process.env.GITHUB_RUN_NUMBER ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
|
||||||
null,
|
|
||||||
builtAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
|
||||||
if (process.env.EXPO_TV !== "1") {
|
|
||||||
config.plugins?.push("expo-background-task");
|
|
||||||
|
|
||||||
config.plugins?.push([
|
|
||||||
"react-native-google-cast",
|
|
||||||
{ useDefaultExpandedMediaControls: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
config.plugins?.push([
|
|
||||||
"expo-camera",
|
|
||||||
{
|
|
||||||
cameraPermission:
|
|
||||||
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
|
||||||
const androidConfig: { googleServicesFile?: string } = {};
|
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.extra = { ...config.extra, build: buildMeta };
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
|
||||||
...config,
|
|
||||||
} as ExpoConfig;
|
|
||||||
};
|
|
||||||
29
app.json
29
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.55.0",
|
"version": "0.54.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -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' />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Directory, Paths } from "expo-file-system";
|
import { Directory, Paths } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, 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 { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
|||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -59,6 +61,51 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
||||||
|
useJellyseerr();
|
||||||
|
|
||||||
|
// Jellyseerr state
|
||||||
|
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||||
|
settings.jellyseerrServerUrl || "",
|
||||||
|
);
|
||||||
|
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||||
|
|
||||||
|
const isJellyseerrLocked =
|
||||||
|
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
const isJellyseerrConnected = !!jellyseerrApi;
|
||||||
|
|
||||||
|
const handleJellyseerrUrlBlur = useCallback(() => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
updateSettings({ jellyseerrServerUrl: url || undefined });
|
||||||
|
}, [jellyseerrServerUrl, updateSettings]);
|
||||||
|
|
||||||
|
const jellyseerrLoginMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
if (!url) throw new Error("Missing server url");
|
||||||
|
if (!user?.Name) throw new Error("Missing user info");
|
||||||
|
const tempApi = new JellyseerrApi(url);
|
||||||
|
const testResult = await tempApi.test();
|
||||||
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
|
return tempApi.login(user.Name, jellyseerrPassword);
|
||||||
|
},
|
||||||
|
onSuccess: (loggedInUser) => {
|
||||||
|
setJellyseerrUser(loggedInUser);
|
||||||
|
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setJellyseerrPassword("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDisconnectJellyseerr = useCallback(() => {
|
||||||
|
clearAllJellyseerData();
|
||||||
|
setJellyseerrServerUrl("");
|
||||||
|
setJellyseerrPassword("");
|
||||||
|
}, [clearAllJellyseerData]);
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -324,44 +371,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 +546,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 +692,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 +710,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 +724,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 +901,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}
|
||||||
@@ -896,6 +924,81 @@ export default function SettingsTV() {
|
|||||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* seerr Section */}
|
||||||
|
<TVSectionHeader title='seerr' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
|
||||||
|
"Enter your Jellyseerr server URL to enable discover and request features."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
||||||
|
}
|
||||||
|
value={jellyseerrServerUrl}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
||||||
|
"https://jellyseerr.example.com"
|
||||||
|
}
|
||||||
|
onChangeText={setJellyseerrServerUrl}
|
||||||
|
onBlur={handleJellyseerrUrlBlur}
|
||||||
|
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||||
|
<>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.plugins.jellyseerr.password") || "Password"
|
||||||
|
}
|
||||||
|
value={jellyseerrPassword}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
||||||
|
username: user?.Name,
|
||||||
|
}) || `Jellyfin password`
|
||||||
|
}
|
||||||
|
onChangeText={setJellyseerrPassword}
|
||||||
|
secureTextEntry
|
||||||
|
disabled={jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={
|
||||||
|
jellyseerrLoginMutation.isPending
|
||||||
|
? t("common.connecting", "Connecting...") || "Connecting..."
|
||||||
|
: t("common.connect", "Connect") || "Connect"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
onPress={() => jellyseerrLoginMutation.mutate()}
|
||||||
|
disabled={jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TVSettingsRow
|
||||||
|
label={
|
||||||
|
isJellyseerrConnected
|
||||||
|
? t("common.connected", "Connected") || "Connected"
|
||||||
|
: t("common.not_connected", "Not connected") || "Not connected"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={
|
||||||
|
t(
|
||||||
|
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||||
|
) || "Disconnect"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
onPress={handleDisconnectJellyseerr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Storage Section */}
|
{/* Storage Section */}
|
||||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -127,6 +98,7 @@ export default function Page() {
|
|||||||
values={[order]}
|
values={[order]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||||
|
disableSearch={true}
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={levelsFilterId}
|
id={levelsFilterId}
|
||||||
@@ -136,80 +108,71 @@ export default function Page() {
|
|||||||
values={levels}
|
values={levels}
|
||||||
title={t("home.settings.logs.level")}
|
title={t("home.settings.logs.level")}
|
||||||
renderItemLabel={(level) => level}
|
renderItemLabel={(level) => level}
|
||||||
|
disableSearch={true}
|
||||||
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
|
||||||
|
|||||||
@@ -349,6 +349,9 @@ const page: React.FC = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -373,6 +376,7 @@ const page: React.FC = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -397,6 +401,9 @@ const page: React.FC = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -414,6 +421,9 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -431,6 +441,9 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -568,6 +529,9 @@ const Page = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -592,6 +556,7 @@ const Page = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -616,6 +581,9 @@ const Page = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -633,6 +601,9 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -650,6 +621,9 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -667,6 +641,9 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
generalFilters.find((i) => i.key === item)?.value || ""
|
generalFilters.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
import {
|
||||||
|
useIsFocused,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { orderBy, uniqBy } from "lodash";
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +25,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import {
|
||||||
|
useJellyseerr,
|
||||||
|
validateJellyseerrSession,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -106,8 +120,44 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const isFocused = useIsFocused();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
// Alert when seerr server is configured but user hasn't connected (only when focused)
|
||||||
|
const jellyseerrAlertedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||||
|
if (jellyseerrAlertedRef.current) return;
|
||||||
|
jellyseerrAlertedRef.current = true;
|
||||||
|
Alert.alert(
|
||||||
|
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
|
||||||
|
t(
|
||||||
|
"jellyseerr.connect_in_settings",
|
||||||
|
"Jellyseerr is available. Connect in Settings to enable request features.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
||||||
|
|
||||||
|
// Validate jellyseerr session when switching to Discover
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
searchType !== "Discover" ||
|
||||||
|
!jellyseerrApi ||
|
||||||
|
!settings?.jellyseerrServerUrl
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
||||||
|
if (status.valid) return;
|
||||||
|
Alert.alert(
|
||||||
|
t("jellyseerr.session_expired", "Session expired"),
|
||||||
|
t(
|
||||||
|
"jellyseerr.session_expired_connect_again",
|
||||||
|
"Your Jellyseerr session has expired. Please reconnect in Settings.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
@@ -305,8 +355,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";
|
||||||
@@ -26,10 +25,6 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
|||||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
import {
|
|
||||||
LOCAL_SUBTITLE_INDEX_START,
|
|
||||||
toServerSubtitleIndex,
|
|
||||||
} from "@/components/video-player/controls/types";
|
|
||||||
import {
|
import {
|
||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
@@ -54,16 +49,15 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
applyMpvSubtitleSelection,
|
|
||||||
getExternalSubtitleUrl,
|
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
isImageBasedSubtitle,
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -316,37 +310,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 });
|
||||||
@@ -482,12 +445,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]);
|
||||||
@@ -499,23 +456,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(() => {
|
||||||
@@ -662,20 +606,32 @@ export default function DirectPlayerPage() {
|
|||||||
const mediaSource = stream.mediaSource;
|
const mediaSource = stream.mediaSource;
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
// Get external subtitle URLs
|
||||||
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
// - Online: prepend API base path to server URLs
|
||||||
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
let externalSubs: string[] | undefined;
|
||||||
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
if (!offline && api?.basePath) {
|
||||||
)
|
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
.map((s) =>
|
(s) =>
|
||||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
s.Type === "Subtitle" &&
|
||||||
)
|
s.DeliveryMethod === "External" &&
|
||||||
.filter((u): u is string => !!u);
|
s.DeliveryUrl,
|
||||||
|
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
||||||
|
} else if (offline) {
|
||||||
|
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Type === "Subtitle" &&
|
||||||
|
s.DeliveryMethod === "External" &&
|
||||||
|
s.DeliveryUrl,
|
||||||
|
).map((s) => s.DeliveryUrl!);
|
||||||
|
}
|
||||||
|
|
||||||
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
// Calculate track IDs for initial selection
|
||||||
// subtitles). The subtitle selection is applied later, once MPV's real track
|
const initialSubtitleId = getMpvSubtitleId(
|
||||||
// list is known — see applySubtitleSelection / onTracksReady.
|
mediaSource,
|
||||||
|
subtitleIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -693,6 +649,7 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -740,6 +697,7 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -922,29 +880,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),
|
|
||||||
// A local (client-downloaded) sub only exists in the dying mpv
|
|
||||||
// instance — the server must be asked for "none" (-1) instead.
|
|
||||||
subtitleIndex: String(toServerSubtitleIndex(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,
|
||||||
@@ -956,114 +891,34 @@ 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
|
||||||
/**
|
|
||||||
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
|
||||||
* it. Identity-based (external by filename, embedded by language/title) so it
|
|
||||||
* stays correct across external/embedded reordering and server-hidden embedded
|
|
||||||
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
|
||||||
* fired again after each external sub-add) and runtime changes.
|
|
||||||
*/
|
|
||||||
const applySubtitleSelection = useCallback(
|
|
||||||
async (jellyfinSubtitleIndex: number) => {
|
|
||||||
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) => s.Type === "Subtitle",
|
|
||||||
);
|
|
||||||
return applyMpvSubtitleSelection(videoRef.current, {
|
|
||||||
subtitleStreams,
|
|
||||||
jellyfinSubtitleIndex,
|
|
||||||
getExpectedExternalUrl: (s) =>
|
|
||||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[stream?.mediaSource, offline, api?.basePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-negotiate the stream with new track params (server re-processes it,
|
|
||||||
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
|
|
||||||
// replacePlayer, resuming at the live position.
|
|
||||||
const replaceWithTrackSelection = useCallback(
|
|
||||||
(params: { subtitleIndex?: string; audioIndex?: string }) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item?.Id ?? "",
|
|
||||||
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
|
|
||||||
subtitleIndex:
|
|
||||||
params.subtitleIndex ??
|
|
||||||
String(toServerSubtitleIndex(currentSubtitleIndex)),
|
|
||||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition: msToTicks(progress.get()).toString(),
|
|
||||||
}).toString();
|
|
||||||
// Destroy the current mpv instance before re-navigating, same rationale as
|
|
||||||
// goToNextItem: Expo Router briefly holds two players during the
|
|
||||||
// transition and two decoders/surfaces OOM-kill low-RAM devices.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item?.Id,
|
|
||||||
currentAudioIndex,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
stream?.mediaSource?.Id,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
progress,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV/mobile subtitle track change handler
|
|
||||||
const handleSubtitleIndexChange = useCallback(
|
const handleSubtitleIndexChange = useCallback(
|
||||||
async (index: number) => {
|
async (index: number) => {
|
||||||
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
|
|
||||||
// resolvable against server streams — just track the live index.
|
|
||||||
if (index <= LOCAL_SUBTITLE_INDEX_START) {
|
|
||||||
setCurrentSubtitleIndex(index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subs = stream?.mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) => s.Type === "Subtitle",
|
|
||||||
);
|
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
|
||||||
const target = subs?.find((s) => s.Index === index);
|
|
||||||
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
|
|
||||||
// Burned-in subs are pixels, not tracks: switching TO one and switching
|
|
||||||
// AWAY from an active one both need a server re-process (same guard as
|
|
||||||
// VideoContext's needsReplace on the mobile menu path).
|
|
||||||
const needsReplace =
|
|
||||||
isTranscoding &&
|
|
||||||
((target && isImageBasedSubtitle(target)) ||
|
|
||||||
(current && isImageBasedSubtitle(current)));
|
|
||||||
if (needsReplace) {
|
|
||||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentSubtitleIndex(index);
|
setCurrentSubtitleIndex(index);
|
||||||
const result = await applySubtitleSelection(index);
|
|
||||||
// Safety net: a menu-listed sub the player can't select (server-burned
|
// Check if we're transcoding
|
||||||
// Encode, sidecar never sub-added) needs the server to re-process the
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
// stream with it.
|
|
||||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
if (index === -1) {
|
||||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
// Disable subtitles
|
||||||
|
await videoRef.current?.disableSubtitles?.();
|
||||||
|
} else {
|
||||||
|
// Convert Jellyfin index to MPV track ID
|
||||||
|
const mpvTrackId = getMpvSubtitleId(
|
||||||
|
stream?.mediaSource,
|
||||||
|
index,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
||||||
|
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[stream?.mediaSource],
|
||||||
applySubtitleSelection,
|
|
||||||
replaceWithTrackSelection,
|
|
||||||
stream?.mediaSource,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1182,10 +1037,6 @@ export default function DirectPlayerPage() {
|
|||||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
// Free the current mpv instance before navigating, matching goToNextItem —
|
|
||||||
// otherwise two decoders/surfaces overlap during the transition and can
|
|
||||||
// OOM-kill low-RAM devices.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
@@ -1198,24 +1049,9 @@ export default function DirectPlayerPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
const addSubtitleFile = useCallback(
|
const addSubtitleFile = useCallback(async (path: string) => {
|
||||||
async (path: string) => {
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
// Set the live index to the new local sub's REAL index BEFORE the add.
|
}, []);
|
||||||
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
|
|
||||||
// downloaded path's position (not a blanket sentinel, which would collide
|
|
||||||
// with the first local sub at -100 and mis-record the selection). Any
|
|
||||||
// local index resolves to notFound on the onTracksReady re-apply, so it
|
|
||||||
// still doesn't clobber the freshly selected track; carry-over now keeps
|
|
||||||
// the correct local sub.
|
|
||||||
const locals = itemId ? getSubtitlesForItem(itemId) : [];
|
|
||||||
const pos = locals.findIndex((s) => s.filePath === path);
|
|
||||||
setCurrentSubtitleIndex(
|
|
||||||
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
|
|
||||||
);
|
|
||||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
|
||||||
},
|
|
||||||
[itemId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV: Refresh subtitle tracks after server-side subtitle download
|
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||||
@@ -1269,15 +1105,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,
|
||||||
@@ -1288,7 +1115,6 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
videoRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1441,16 +1267,12 @@ 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);
|
||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
// Fired after embedded tracks enumerate and again after each
|
|
||||||
// external sub-add; re-resolve so the final fire (full track
|
|
||||||
// list) selects the right track by identity.
|
|
||||||
void applySubtitleSelection(currentSubtitleIndex);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hasPlaybackStarted && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
InteractionManager,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -193,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
|
||||||
@@ -330,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>
|
||||||
)}
|
)}
|
||||||
@@ -647,23 +645,10 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
const handleTrackSelect = useCallback(
|
const handleTrackSelect = useCallback(
|
||||||
(option: { setTrack?: () => void }) => {
|
(option: { setTrack?: () => void }) => {
|
||||||
if (modalState?.deferApplyUntilDismissed) {
|
|
||||||
// Player: setTrack can navigate (replacePlayer for a burn-in switch
|
|
||||||
// while transcoding); a router.replace fired while this modal is the
|
|
||||||
// active route targets the MODAL and is swallowed. Close FIRST, apply
|
|
||||||
// after dismissal.
|
|
||||||
handleClose();
|
|
||||||
InteractionManager.runAfterInteractions(() => option.setTrack?.());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Detail page: setTrack only updates state. Run it BEFORE closing so the
|
|
||||||
// re-render happens while the modal is up; deferring it until after
|
|
||||||
// dismissal re-renders the detail page after focus returns and yanks TV
|
|
||||||
// focus, leaving navigation stuck.
|
|
||||||
option.setTrack?.();
|
option.setTrack?.();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
[handleClose, modalState?.deferApplyUntilDismissed],
|
[handleClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
|
|||||||
@@ -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 {};
|
||||||
122
components/BitRateSheet.tsx
Normal file
122
components/BitRateSheet.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export type Bitrate = {
|
||||||
|
key: string;
|
||||||
|
value: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BITRATES: Bitrate[] = [
|
||||||
|
{
|
||||||
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "8 Mb/s",
|
||||||
|
value: 8000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "4 Mb/s",
|
||||||
|
value: 4000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "2 Mb/s",
|
||||||
|
value: 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "1 Mb/s",
|
||||||
|
value: 1000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "500 Kb/s",
|
||||||
|
value: 500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
|
].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
onChange: (value: Bitrate) => void;
|
||||||
|
selected?: Bitrate | null;
|
||||||
|
inverted?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BitrateSheet: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
inverted,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (inverted)
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
}, [inverted]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex shrink'
|
||||||
|
style={{
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</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}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={t("item_card.quality")}
|
||||||
|
data={sorted}
|
||||||
|
values={selected ? [selected] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const label = (item as any).key || "";
|
||||||
|
return label.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}}
|
||||||
|
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as Bitrate | undefined;
|
||||||
|
if (chosen) onChange(chosen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -40,10 +40,7 @@ import {
|
|||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import {
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
LOCAL_SUBTITLE_INDEX_START,
|
|
||||||
type Track,
|
|
||||||
} from "@/components/video-player/controls/types";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -59,7 +56,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -236,13 +232,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
||||||
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -253,6 +248,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
// State to trigger refresh of local subtitles list
|
// State to trigger refresh of local subtitles list
|
||||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Starting index for local (client-downloaded) subtitles
|
||||||
|
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||||
|
|
||||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
@@ -413,13 +411,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
// Get subtitle streams from the fresh data
|
||||||
// (embedded first, externals last) — same as the initial list.
|
const streams =
|
||||||
const streams = [
|
mediaSource?.MediaStreams?.filter(
|
||||||
...(mediaSource?.MediaStreams?.filter(
|
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? []),
|
) ?? [];
|
||||||
].sort(compareTracksForMenu);
|
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ 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 { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import { BITRATES } from "./BitrateSelector";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
@@ -64,12 +63,9 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
[
|
(x) => x.Type === "Subtitle",
|
||||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
) || [],
|
||||||
(x) => x.Type === "Subtitle",
|
|
||||||
) || []),
|
|
||||||
].sort(compareTracksForMenu),
|
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
75
components/MediaSourceSheet.tsx
Normal file
75
components/MediaSourceSheet.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useCallback, 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> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
onChange: (value: MediaSourceInfo) => void;
|
||||||
|
selected?: MediaSourceInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceSheet: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedName = useMemo(() => {
|
||||||
|
if (!selected) return "";
|
||||||
|
return getDisplayName(selected);
|
||||||
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
|
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex shrink' style={{ minWidth: 75 }}>
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={t("item_card.video")}
|
||||||
|
data={item.MediaSources || []}
|
||||||
|
values={selected ? [selected] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(src, query) =>
|
||||||
|
getDisplayName(src as MediaSourceInfo)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase())
|
||||||
|
}
|
||||||
|
renderItemLabel={(src) => (
|
||||||
|
<Text>{getDisplayName(src as MediaSourceInfo)}</Text>
|
||||||
|
)}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as MediaSourceInfo | undefined;
|
||||||
|
if (chosen) onChange(chosen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,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} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPresentedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else if (isPresentedRef.current) {
|
} else {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -23,9 +22,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
|
||||||
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
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 () =>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
import { FilterSheetContent } from "./FilterSheetContent";
|
|
||||||
|
|
||||||
interface FilterButtonProps<T> extends ViewProps {
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
disableSearch?: boolean;
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
values: T[];
|
values: T[];
|
||||||
title: string;
|
title: string;
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
queryFn: (params: any) => Promise<any>;
|
queryFn: (params: any) => Promise<any>;
|
||||||
renderItemLabel: (item: T) => string;
|
searchFilter?: (item: T, query: string) => boolean;
|
||||||
|
renderItemLabel: (item: T) => React.ReactNode;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
icon?: "filter" | "sort";
|
icon?: "filter" | "sort";
|
||||||
}
|
}
|
||||||
@@ -25,11 +27,13 @@ export const FilterButton = <T,>({
|
|||||||
values, // selected values
|
values, // selected values
|
||||||
title,
|
title,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
|
searchFilter,
|
||||||
|
disableSearch = false,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
icon = "filter",
|
icon = "filter",
|
||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
@@ -38,59 +42,61 @@ export const FilterButton = <T,>({
|
|||||||
enabled: !!id && !!queryFn && !!queryKey,
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openSheet = () => {
|
|
||||||
if (!filters?.length) return;
|
|
||||||
showModal(
|
|
||||||
<FilterSheetContent<T>
|
|
||||||
title={title}
|
|
||||||
data={filters}
|
|
||||||
initialValues={values}
|
|
||||||
set={set}
|
|
||||||
renderItemLabel={renderItemLabel}
|
|
||||||
multiple={multiple}
|
|
||||||
onClose={hideModal}
|
|
||||||
/>,
|
|
||||||
{ snapPoints: ["85%"] },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={openSheet}>
|
<>
|
||||||
<View
|
<TouchableOpacity
|
||||||
className={`
|
onPress={() => {
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
filters?.length && setOpen(true);
|
||||||
${
|
}}
|
||||||
values.length > 0
|
|
||||||
? "bg-purple-600 border border-purple-700"
|
|
||||||
: "bg-neutral-900 border border-neutral-900"
|
|
||||||
}
|
|
||||||
${filters?.length === 0 ? "opacity-50" : ""}
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<Text
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
|
${
|
||||||
|
values.length > 0
|
||||||
|
? "bg-purple-600 border border-purple-700"
|
||||||
|
: "bg-neutral-900 border border-neutral-900"
|
||||||
|
}
|
||||||
|
${filters?.length === 0 && "opacity-50"}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||||
text-xs font-semibold`}
|
text-xs font-semibold`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{icon === "filter" ? (
|
{icon === "filter" ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='filter'
|
name='filter'
|
||||||
size={14}
|
size={14}
|
||||||
color='white'
|
color='white'
|
||||||
style={{ opacity: 0.5 }}
|
style={{ opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FontAwesome
|
<FontAwesome
|
||||||
name='sort'
|
name='sort'
|
||||||
size={14}
|
size={14}
|
||||||
color='white'
|
color='white'
|
||||||
style={{ opacity: 0.5 }}
|
style={{ opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<FilterSheet<T>
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
data={filters}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
renderItemLabel={renderItemLabel}
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
disableSearch={disableSearch}
|
||||||
|
multiple={multiple}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
257
components/filters/FilterSheet.tsx
Normal file
257
components/filters/FilterSheet.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
|
||||||
|
interface Props<T> extends ViewProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
data?: T[] | null;
|
||||||
|
values: T[];
|
||||||
|
set: (value: T[]) => void;
|
||||||
|
title: string;
|
||||||
|
searchFilter?: (item: T, query: string) => boolean;
|
||||||
|
renderItemLabel: (item: T) => React.ReactNode;
|
||||||
|
disableSearch?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterSheet Component
|
||||||
|
*
|
||||||
|
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
||||||
|
*
|
||||||
|
* @template T - The type of items in the list
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props
|
||||||
|
* @param {boolean} props.open - Whether the bottom sheet is open
|
||||||
|
* @param {function} props.setOpen - Function to set the open state
|
||||||
|
* @param {T[] | null} [props.data] - The full list of items to filter from
|
||||||
|
* @param {T[]} props.values - The currently selected items
|
||||||
|
* @param {function} props.set - Function to update the selected items
|
||||||
|
* @param {string} props.title - The title of the bottom sheet
|
||||||
|
* @param {function} props.searchFilter - Function to filter items based on search query
|
||||||
|
* @param {function} props.renderItemLabel - Function to render the label for each item
|
||||||
|
* @param {boolean} [props.disableSearch=false] - Whether to disable the search input
|
||||||
|
*
|
||||||
|
* @returns {React.ReactElement} The FilterSheet component
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays a list of items in a bottom sheet
|
||||||
|
* - Allows searching and filtering of items
|
||||||
|
* - Supports single selection of items
|
||||||
|
* - Loads items in batches for performance optimization
|
||||||
|
* - Customizable item rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FilterSheet = <T,>({
|
||||||
|
values,
|
||||||
|
data: _data,
|
||||||
|
open,
|
||||||
|
set,
|
||||||
|
setOpen,
|
||||||
|
title,
|
||||||
|
searchFilter,
|
||||||
|
renderItemLabel,
|
||||||
|
disableSearch = false,
|
||||||
|
multiple = false,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
|
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!search) return _data;
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||||
|
if (_data && searchFilter?.(_data[i], search)) {
|
||||||
|
results.push(_data[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.slice(0, 100);
|
||||||
|
}, [search, _data, searchFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
|
if (data.length > 15) {
|
||||||
|
setShowSearch(true);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Loads data in batches of LIMIT size, starting from offset,
|
||||||
|
// to implement efficient "load more" functionality
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_data || _data.length === 0) return;
|
||||||
|
|
||||||
|
const newData = [...data];
|
||||||
|
|
||||||
|
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||||
|
const item = _data[i];
|
||||||
|
// Check if this item already exists in our data array
|
||||||
|
// some dups happened with re-renders during testing
|
||||||
|
const exists = newData.some((existingItem) =>
|
||||||
|
isEqual(existingItem, item),
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
newData.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(newData);
|
||||||
|
}, [offset, _data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderData = useMemo(() => {
|
||||||
|
if (search.length > 0 && showSearch) return filteredData;
|
||||||
|
return data;
|
||||||
|
}, [search, filteredData, data]);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
style={{}}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='mt-2 mb-8'
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='font-bold text-2xl'>{title}</Text>
|
||||||
|
<Text className='mb-2 text-neutral-500'>
|
||||||
|
{t("search.x_items", { count: _data?.length })}
|
||||||
|
</Text>
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
className='my-2 border-neutral-800 border'
|
||||||
|
value={search}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||||
|
>
|
||||||
|
{renderData?.map((item, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (multiple) {
|
||||||
|
if (!values.includes(item)) set(values.concat(item));
|
||||||
|
else set(values.filter((v) => v !== item));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
if (!values.includes(item)) {
|
||||||
|
set([item]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
{values.some((i) => isEqual(i, item)) ? (
|
||||||
|
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='h-1 divide-neutral-700 '
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{data.length < (_data?.length || 0) && (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setOffset(offset + LIMIT);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
|
||||||
import { isEqual } from "lodash";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
title: string;
|
|
||||||
data: T[];
|
|
||||||
initialValues: T[];
|
|
||||||
set: (value: T[]) => void;
|
|
||||||
renderItemLabel: (item: T) => string;
|
|
||||||
multiple?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEARCH_THRESHOLD = 15;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sheet content for FilterButton, rendered inside the GlobalModal bottom
|
|
||||||
* sheet. The GlobalModal stores its content as a static snapshot, so this
|
|
||||||
* component owns the selection state locally and mirrors changes back to the
|
|
||||||
* caller through `set`.
|
|
||||||
*
|
|
||||||
* Uses a virtualized BottomSheetFlatList — filter lists (genres, tags, years)
|
|
||||||
* can contain thousands of entries.
|
|
||||||
*/
|
|
||||||
export const FilterSheetContent = <T,>({
|
|
||||||
title,
|
|
||||||
data,
|
|
||||||
initialValues,
|
|
||||||
set,
|
|
||||||
renderItemLabel,
|
|
||||||
multiple = false,
|
|
||||||
onClose,
|
|
||||||
}: Props<T>) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const [values, setValues] = useState<T[]>(initialValues);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const showSearch = data.length > SEARCH_THRESHOLD;
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!search || !showSearch) return data;
|
|
||||||
const query = search.toLowerCase();
|
|
||||||
return data.filter((item) =>
|
|
||||||
renderItemLabel(item).toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}, [data, search, showSearch, renderItemLabel]);
|
|
||||||
|
|
||||||
const select = (item: T) => {
|
|
||||||
const selected = values.some((v) => isEqual(v, item));
|
|
||||||
if (multiple) {
|
|
||||||
const next = selected
|
|
||||||
? values.filter((v) => !isEqual(v, item))
|
|
||||||
: values.concat(item);
|
|
||||||
setValues(next);
|
|
||||||
set(next);
|
|
||||||
} else {
|
|
||||||
if (!selected) {
|
|
||||||
setValues([item]);
|
|
||||||
set([item]);
|
|
||||||
}
|
|
||||||
setTimeout(() => onClose(), 250);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='font-bold text-2xl mt-2'>{title}</Text>
|
|
||||||
<Text className='mb-2 text-neutral-500'>
|
|
||||||
{t("search.x_items", { count: data.length })}
|
|
||||||
</Text>
|
|
||||||
{showSearch && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
className='my-2 border-neutral-800 border'
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
returnKeyType='done'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
marginBottom: Math.max(16, insets.bottom),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BottomSheetFlatList
|
|
||||||
data={filteredData}
|
|
||||||
keyExtractor={(item, index) => `${renderItemLabel(item)}-${index}`}
|
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
initialNumToRender={20}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View
|
|
||||||
style={{ height: StyleSheet.hairlineWidth }}
|
|
||||||
className='bg-neutral-700'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const selected = values.some((v) => isEqual(v, item));
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => select(item)}
|
|
||||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name={selected ? "radio-button-on" : "radio-button-off"}
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, 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";
|
||||||
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,6 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
const SCALE_PADDING = 20;
|
|
||||||
|
|
||||||
interface TVDiscoverPosterProps {
|
interface TVDiscoverPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
const posterWidth = sizes.posters.poster;
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: sizes.gaps.small,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year && (
|
{year != null && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: sizes.gaps.small,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
if (!flatData || flatData.length === 0) return null;
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TVSubmitButton
|
<TVSubmitButton
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
label={t("login.login_button")}
|
label={t("login.login")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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} />;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user