mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
Compare commits
155 Commits
fix/tv-see
...
fix/skip-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e5dc35da | ||
|
|
5de017612d | ||
|
|
c00d2f050e | ||
|
|
ca9e9f1486 | ||
|
|
41e2dc3b81 | ||
|
|
9b2d289908 | ||
|
|
3cc4fd1a1e | ||
|
|
22739277b7 | ||
|
|
5623a5cddb | ||
|
|
e481f22e10 | ||
|
|
4487682412 | ||
|
|
994056d5f0 | ||
|
|
31cf9e8dff | ||
|
|
3a77ffc63c | ||
|
|
e6200a09ad | ||
|
|
fe36e2e0fc | ||
|
|
a1ba5d8c1d | ||
|
|
299d80ce03 | ||
|
|
1eaca76e97 | ||
|
|
9d9da134c0 | ||
|
|
c3275cda34 | ||
|
|
cd60c0ca07 | ||
|
|
6f6e3ed49f | ||
|
|
fd4215407d | ||
|
|
71b82a75ee | ||
|
|
e0cf1559bc | ||
|
|
610158efbd | ||
|
|
a2802b4570 | ||
|
|
9f08b51e1e | ||
|
|
06076b1a85 | ||
|
|
7d8014f686 | ||
|
|
e79ae53e4e | ||
|
|
4813fdab81 | ||
|
|
980245f19d | ||
|
|
d5d0976ff9 | ||
|
|
6dd98ccae8 | ||
|
|
228e847a4e | ||
|
|
15c66bc714 | ||
|
|
b4eb853048 | ||
|
|
c4209cbf8e | ||
|
|
fd504cef14 | ||
|
|
dbdf4767a5 | ||
|
|
1860863c60 | ||
|
|
69651cdfa3 | ||
|
|
25ee7dad85 | ||
|
|
76d9b3177b | ||
|
|
34d9b43dcb | ||
|
|
039d7cfaec | ||
|
|
9cdfebe908 | ||
|
|
bee8323110 | ||
|
|
8b02cfcb65 | ||
|
|
3020bf5903 | ||
|
|
1f76119082 | ||
|
|
e0017b8092 | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
4c5a3dc658 | ||
|
|
b39eb35795 | ||
|
|
bd0eef0407 | ||
|
|
c4b6390a5e | ||
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
98dfefbdd0 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
49a903f48a | ||
|
|
8618e2d6c3 | ||
|
|
95b5dab293 | ||
|
|
c5d39d40b8 | ||
|
|
2df160adb7 | ||
|
|
8d54d65b63 | ||
|
|
95f4d15e98 | ||
|
|
fbeb025d73 | ||
|
|
64b9224b7a | ||
|
|
b59759638c | ||
|
|
8219e44b5b | ||
|
|
77156630d6 | ||
|
|
680f930a47 | ||
|
|
376ccd1f93 | ||
|
|
ed8744389b | ||
|
|
5221ed1963 | ||
|
|
461d055c7a | ||
|
|
58f3646477 | ||
|
|
b879026745 | ||
|
|
33ca07ca82 | ||
|
|
e5d56d6ad1 | ||
|
|
80602ecd23 | ||
|
|
14ea18e0d2 | ||
|
|
28fcb303c6 | ||
|
|
28a75a2b8c | ||
|
|
d9677cbece | ||
|
|
90e9084949 | ||
|
|
aa0eb0a655 | ||
|
|
115c163aeb | ||
|
|
286a3cad47 | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97b6a912e0 | ||
|
|
97ef9b5ee7 | ||
|
|
cc0007926d | ||
|
|
ff708ec832 | ||
|
|
9e29305e28 | ||
|
|
ae9c05637b | ||
|
|
f820bedf6e | ||
|
|
47c5d61f28 | ||
|
|
517bc7bbb5 | ||
|
|
d6980cfc8e | ||
|
|
b256e99fc8 | ||
|
|
e660b98871 | ||
|
|
ce66f0256e | ||
|
|
2ec6594462 | ||
|
|
18f01fa4ab | ||
|
|
df56d62acd | ||
|
|
872d14786e | ||
|
|
5bf07b4798 | ||
|
|
c3dceedad0 | ||
|
|
bf3dc4a366 | ||
|
|
3e81291843 | ||
|
|
7703a1c76f | ||
|
|
7983c68b9f | ||
|
|
335f1efb2c | ||
|
|
434cb3bd39 | ||
|
|
7a6daa011d | ||
|
|
149e3b1b17 | ||
|
|
f00dad02ba | ||
|
|
b7ec841118 | ||
|
|
03864b2a9a | ||
|
|
96116e0451 | ||
|
|
938918fa06 | ||
|
|
a4b6f456f2 | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
168bf2e54e | ||
|
|
6f0230c2ca | ||
|
|
d12beee529 | ||
|
|
02ffac167b | ||
|
|
4eb734c99f | ||
|
|
b7bae0072f | ||
|
|
1685571406 | ||
|
|
36ed7539a2 |
@@ -13,7 +13,7 @@ build:
|
||||
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
@@ -15,7 +15,7 @@ build:
|
||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||
- run:
|
||||
name: Prebuild (Android TV, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
@@ -18,10 +18,10 @@ build:
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
# android/ is gitignored, so generate native code fresh. --no-install
|
||||
# because deps are already installed above; bunx keeps it on bun.
|
||||
# because deps are already installed above; bun x keeps it on bun.
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
|
||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
||||
# 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.
|
||||
- run:
|
||||
name: Prebuild (iOS/tvOS, bun)
|
||||
command: bunx expo prebuild --platform ios --no-install
|
||||
command: bun x expo prebuild --platform ios --no-install
|
||||
|
||||
- run:
|
||||
name: Install CocoaPods
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,10 +75,13 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you using?
|
||||
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.
|
||||
options:
|
||||
- 0.54.1
|
||||
- 0.51.0
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- 0.28.0
|
||||
- Older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
|
||||
21
.github/actions/refresh-pr-comment/action.yml
vendored
Normal file
21
.github/actions/refresh-pr-comment/action.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- 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 descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
64
.github/renovate.json
vendored
64
.github/renovate.json
vendored
@@ -30,9 +30,17 @@
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
],
|
||||
"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": {
|
||||
@@ -44,22 +52,42 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"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"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"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:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
||||
name: 📦 Post Build Artifacts
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
|
||||
|
||||
// Log current status of each build for debugging
|
||||
buildRuns.forEach(run => {
|
||||
@@ -184,21 +184,35 @@ jobs:
|
||||
const latestAndroidRun = findBestRun('Android APK 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
|
||||
if (latestAppsRun) {
|
||||
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 {
|
||||
// Get all jobs for this workflow run
|
||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
@@ -229,10 +243,8 @@ jobs:
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||
);
|
||||
|
||||
const job = findJobForTarget(jobs.jobs, jobNames);
|
||||
|
||||
if (job) {
|
||||
buildStatuses[platform] = {
|
||||
name: job.name,
|
||||
@@ -358,6 +370,43 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -369,9 +418,9 @@ jobs:
|
||||
const buildTargets = [
|
||||
{ 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: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
|
||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
|
||||
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||
];
|
||||
|
||||
@@ -387,11 +436,12 @@ jobs:
|
||||
let status = '⏳ Pending';
|
||||
let downloadLink = '*Waiting for build...*';
|
||||
|
||||
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||
// is merged - show them as disabled instead of stuck pending.
|
||||
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||
// Signed tvOS stays disabled until EAS has tvOS provisioning
|
||||
// profiles (app + TopShelf targets); non-interactive builds can't
|
||||
// create them. Unsigned tvOS builds, so it flows through normally.
|
||||
if (target.name === 'tvOS') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
|
||||
} else if (matchingStatus) {
|
||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||
status = '✅ Complete';
|
||||
@@ -406,11 +456,9 @@ jobs:
|
||||
let durationInfo = '';
|
||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
||||
}
|
||||
|
||||
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
@@ -420,10 +468,16 @@ jobs:
|
||||
downloadLink = '*Build cancelled*';
|
||||
} else if (matchingStatus.status === 'in_progress') {
|
||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||
downloadLink = '*Build in progress...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
|
||||
: '*Build in progress...*';
|
||||
} else if (matchingStatus.status === 'queued') {
|
||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||
downloadLink = '*Waiting to start...*';
|
||||
const ref = referenceDurations[target.statusKey];
|
||||
downloadLink = ref
|
||||
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
|
||||
: '*Waiting to start...*';
|
||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||
// Workflow completed but conclusion not yet available (rare edge case)
|
||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||
@@ -444,7 +498,22 @@ jobs:
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
// Show installation instructions if we have any artifacts
|
||||
// Static rundown of the build optimisations + what each artifact
|
||||
// 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) {
|
||||
commentBody += `### 🔧 Installation Instructions\n\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,77 +11,102 @@ on:
|
||||
push:
|
||||
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:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
name: 🤖 Build Android APK (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- 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
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
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
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
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
|
||||
|
||||
- name: 📅 Set date tag
|
||||
@@ -95,76 +120,94 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-android-tv:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
name: 🤖 Build Android APK (TV)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- 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
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
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
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
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
|
||||
|
||||
- name: 📅 Set date tag
|
||||
@@ -178,34 +221,53 @@ jobs:
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
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
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -215,23 +277,12 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
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
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
run: eas build -p ios --local --non-interactive
|
||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
||||
# 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
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -243,34 +294,46 @@ jobs:
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-phone-unsigned:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
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
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -280,12 +343,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
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
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -301,36 +358,57 @@ jobs:
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
- name: 🔄 Refresh PR build comment
|
||||
uses: ./.github/actions/refresh-pr-comment
|
||||
|
||||
build-ios-tv:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix below.
|
||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
||||
# 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'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
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
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -340,23 +418,10 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
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
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: eas build -p ios --local --non-interactive
|
||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -377,28 +442,37 @@ jobs:
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
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
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
@@ -408,12 +482,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
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
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
@@ -428,3 +496,6 @@ jobs:
|
||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
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,30 +13,33 @@ concurrency:
|
||||
jobs:
|
||||
check-lockfile:
|
||||
name: 🔍 Check bun.lock and package.json consistency
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 🛡️ Verify lockfile consistency
|
||||
run: |
|
||||
|
||||
13
.github/workflows/ci-codeql.yml
vendored
13
.github/workflows/ci-codeql.yml
vendored
@@ -8,11 +8,14 @@ on:
|
||||
schedule:
|
||||
- cron: '24 2 * * *'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: 🔎 Analyze with CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -24,16 +27,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
4
.github/workflows/conflict.yml
vendored
4
.github/workflows/conflict.yml
vendored
@@ -10,14 +10,14 @@ on:
|
||||
jobs:
|
||||
label:
|
||||
name: 🏷️ Labeling Merge Conflicts
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 🚩 Apply merge conflict label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: '⚔️ merge-conflict'
|
||||
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:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
18
.github/workflows/detect-duplicate.yml
vendored
18
.github/workflows/detect-duplicate.yml
vendored
@@ -15,21 +15,23 @@ jobs:
|
||||
detect:
|
||||
name: 🔍 Find similar issues
|
||||
if: github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
108
.github/workflows/linting.yml
vendored
108
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,47 +11,19 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- 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 }}
|
||||
fetch-depth: 0
|
||||
@@ -65,30 +37,41 @@ jobs:
|
||||
|
||||
expo-doctor:
|
||||
name: 🚑 Expo Doctor Check
|
||||
if: false
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 🛒 Checkout repository
|
||||
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
|
||||
- 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:
|
||||
bun-version: latest
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- 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
|
||||
|
||||
code_quality:
|
||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,24 +80,29 @@ jobs:
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- 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:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
bun-version: latest
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
4
.github/workflows/notification.yml
vendored
4
.github/workflows/notification.yml
vendored
@@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: 🚨 Notify Discord on Failure
|
||||
|
||||
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
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
Normal file
42
.github/workflows/pr-title.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Queue successive releases in order instead of dropping the extra pending run.
|
||||
queue: max
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,8 +24,9 @@ on:
|
||||
jobs:
|
||||
approve:
|
||||
name: 🔐 Approve release
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
environment: production
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: ✅ Release approved
|
||||
run: echo "Release approved for ${{ github.sha }}"
|
||||
@@ -31,7 +34,7 @@ jobs:
|
||||
build:
|
||||
name: 🚀 ${{ matrix.name }}
|
||||
needs: approve
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
@@ -62,38 +65,40 @@ jobs:
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
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
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
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
|
||||
# credentials.json + cert + provisioning profiles from secrets.
|
||||
- name: 🔐 Restore tvOS signing credentials
|
||||
@@ -176,13 +181,13 @@ jobs:
|
||||
name: 📦 Draft GitHub Release
|
||||
needs: build
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
show-progress: false
|
||||
|
||||
50
.github/workflows/trivy-scan.yml
vendored
Normal file
50
.github/workflows/trivy-scan.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
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,67 +1,104 @@
|
||||
name: 🐛 Update Bug Report Template
|
||||
name: 🐛 Update Issue Form Versions
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # Run on every published release on any branch
|
||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
||||
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:
|
||||
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
group: update-issue-form
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-bug-report:
|
||||
update-issue-form:
|
||||
name: 🔢 Populate version dropdown
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
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 Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Extract minor version from app.json
|
||||
id: minor
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||
with:
|
||||
result-encoding: string
|
||||
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: 🔢 Populate version dropdown from GitHub releases
|
||||
id: populate
|
||||
run: bun scripts/update-issue-form.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
- name: 📝 Update bug report version
|
||||
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
|
||||
- name: 📬 Create pull request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
||||
branch: ci/update-issue-form
|
||||
base: develop
|
||||
delete-branch: true
|
||||
labels: ⚙️ ci, 🤖 github-actions
|
||||
title: 'chore(): Update bug report template to match release version'
|
||||
commit-message: "chore: update issue form version dropdown"
|
||||
title: "chore: update issue form version dropdown"
|
||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
||||
body: |
|
||||
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 }})
|
||||
# 📦 Pull Request
|
||||
|
||||
## 📝 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,10 +12,6 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- 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 functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -143,14 +143,6 @@ 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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
79
app.config.ts
Normal file
79
app.config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -71,8 +71,8 @@
|
||||
],
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
"./plugins/withTVUserManagement.js",
|
||||
"./plugins/withExcludeMedia3Dash.ts",
|
||||
"./plugins/withTVUserManagement.ts",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -107,6 +107,9 @@
|
||||
],
|
||||
"expo-localization",
|
||||
"expo-asset",
|
||||
"expo-audio",
|
||||
"expo-image",
|
||||
"expo-sharing",
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{
|
||||
@@ -131,17 +134,17 @@
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidAlertColors.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVOSTopShelf.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
["./plugins/with-runtime-framework-headers.ts"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||
["./plugins/withAndroidAlertColors.ts"],
|
||||
["./plugins/withAndroidManifest.ts"],
|
||||
["./plugins/withTrustLocalCerts.ts"],
|
||||
["./plugins/withGradleProperties.ts"],
|
||||
["./plugins/withTVOSAppIcon.ts"],
|
||||
["./plugins/withTVOSTopShelf.ts"],
|
||||
["./plugins/withTVXcodeEnv.ts"],
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
"./plugins/withGitPod.ts",
|
||||
{
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function IndexLayout() {
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
@@ -158,7 +157,6 @@ export default function IndexLayout() {
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
|
||||
@@ -324,32 +324,44 @@ export default function SettingsTV() {
|
||||
// MPV alignment options
|
||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||
{
|
||||
label: "Center",
|
||||
label: t("home.settings.subtitles.align.left"),
|
||||
value: "left",
|
||||
selected: currentAlignX === "left",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.center"),
|
||||
value: "center",
|
||||
selected: currentAlignX === "center",
|
||||
},
|
||||
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||
{
|
||||
label: t("home.settings.subtitles.align.right"),
|
||||
value: "right",
|
||||
selected: currentAlignX === "right",
|
||||
},
|
||||
],
|
||||
[currentAlignX],
|
||||
[currentAlignX, t],
|
||||
);
|
||||
|
||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||
{
|
||||
label: "Center",
|
||||
label: t("home.settings.subtitles.align.top"),
|
||||
value: "top",
|
||||
selected: currentAlignY === "top",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.subtitles.align.center"),
|
||||
value: "center",
|
||||
selected: currentAlignY === "center",
|
||||
},
|
||||
{
|
||||
label: "Bottom",
|
||||
label: t("home.settings.subtitles.align.bottom"),
|
||||
value: "bottom",
|
||||
selected: currentAlignY === "bottom",
|
||||
},
|
||||
],
|
||||
[currentAlignY],
|
||||
[currentAlignY, t],
|
||||
);
|
||||
|
||||
// Cache mode options
|
||||
@@ -499,13 +511,13 @@ export default function SettingsTV() {
|
||||
|
||||
const alignXLabel = useMemo(() => {
|
||||
const option = alignXOptions.find((o) => o.selected);
|
||||
return option?.label || "Center";
|
||||
}, [alignXOptions]);
|
||||
return option?.label || t("home.settings.subtitles.align.center");
|
||||
}, [alignXOptions, t]);
|
||||
|
||||
const alignYLabel = useMemo(() => {
|
||||
const option = alignYOptions.find((o) => o.selected);
|
||||
return option?.label || "Bottom";
|
||||
}, [alignYOptions]);
|
||||
return option?.label || t("home.settings.subtitles.align.bottom");
|
||||
}, [alignYOptions, t]);
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
@@ -645,7 +657,7 @@ export default function SettingsTV() {
|
||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label='Vertical Margin'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
@@ -663,11 +675,11 @@ export default function SettingsTV() {
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Horizontal Alignment'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Horizontal Alignment",
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -677,11 +689,11 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Vertical Alignment'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
value={alignYLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Vertical Alignment",
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||
options: alignYOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -854,6 +866,13 @@ export default function SettingsTV() {
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||
value={settings.useEpisodeImagesForNextUp}
|
||||
onToggle={(value) =>
|
||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_home_backdrop")}
|
||||
value={settings.showHomeBackdrop}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Switch, View } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className='px-4'
|
||||
className='px-4 pt-4'
|
||||
>
|
||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
@@ -71,7 +72,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
||||
<ListGroup>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
@@ -60,7 +61,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import { useNavigation } from "expo-router";
|
||||
import type * as SharingType from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -72,6 +75,25 @@ export default function Page() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -88,8 +110,15 @@ export default function Page() {
|
||||
}, [share, loading]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<ScrollView
|
||||
// Like the sibling settings pages, let iOS auto-inset the content below the
|
||||
// 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
|
||||
id={orderFilterId}
|
||||
queryKey='log'
|
||||
@@ -98,7 +127,6 @@ export default function Page() {
|
||||
values={[order]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={levelsFilterId}
|
||||
@@ -108,71 +136,80 @@ export default function Page() {
|
||||
values={levels}
|
||||
title={t("home.settings.logs.level")}
|
||||
renderItemLabel={(level) => level}
|
||||
disableSearch={true}
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
className='pb-4 px-4'
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
disabled={!log.data}
|
||||
onPress={() =>
|
||||
setState((v) => ({
|
||||
...v,
|
||||
[log.timestamp]: !v[log.timestamp],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<Text
|
||||
className={`mb-1
|
||||
<View className='flex flex-col space-y-2 px-4'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
disabled={!log.data}
|
||||
onPress={() =>
|
||||
setState((v) => ({
|
||||
...v,
|
||||
[log.timestamp]: !v[log.timestamp],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<Text
|
||||
className={`mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
${log.level === "DEBUG" && "text-purple-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
|
||||
<Text className='text-xs'>
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable className='text-xs'>
|
||||
{log.message}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{log.data && (
|
||||
<>
|
||||
{!state[log.timestamp] && (
|
||||
<Text className='text-xs mt-0.5'>
|
||||
{t("home.settings.logs.click_for_more_info")}
|
||||
</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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{filteredLogs?.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.settings.logs.no_logs_available")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</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
|
||||
header/message stay tap-to-toggle. */}
|
||||
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
||||
</ScrollView>
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
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>
|
||||
</Collapsible>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{filteredLogs?.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.settings.logs.no_logs_available")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
||||
title={t("home.settings.music.prefer_downloaded")}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.preferLocalAudio}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
||||
title={t("home.settings.music.lookahead_enabled")}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.audioLookaheadEnabled}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListGroup className='mt-4'>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteDownloadedSongsClicked}
|
||||
|
||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<View>
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='p-4'>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='px-4'>
|
||||
<KefinTweaksSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
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) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
if (!marlinUrlLocked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||
}, [navigation, value, marlinUrlLocked, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<View className='px-4 pt-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
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"] });
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
<SettingSwitch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={searchEngineLocked || hasStreamystats}
|
||||
onValueChange={(val) => {
|
||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
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"}
|
||||
<ListGroup className='mt-2'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.marlin_search.url")}
|
||||
disabledByAdmin={marlinUrlLocked}
|
||||
>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||
className='text-white text-right flex-1'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<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>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
const isStreamystatsEnabled = !!url;
|
||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||
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 cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<View className='px-4 pt-4'>
|
||||
<ListGroup className='flex-1'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.url")}
|
||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={url}
|
||||
value={effectiveUrl}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||
disabledByAdmin={searchLocked}
|
||||
>
|
||||
<Switch
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
{/* Locked controls show the live admin value and can't be toggled —
|
||||
local form state would let the switch flip while the write guard
|
||||
drops the change. */}
|
||||
<SettingSwitch
|
||||
value={
|
||||
searchLocked
|
||||
? settings?.searchEngine === "Streamystats"
|
||||
: useForSearch
|
||||
}
|
||||
disabled={!isStreamystatsEnabled || searchLocked}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={movieRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={movieRecs}
|
||||
<SettingSwitch
|
||||
value={
|
||||
movieRecsLocked
|
||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||
: movieRecs
|
||||
}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={seriesRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={seriesRecs}
|
||||
<SettingSwitch
|
||||
value={
|
||||
seriesRecsLocked
|
||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||
: seriesRecs
|
||||
}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
disabledByAdmin={promotedWatchlistsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={promotedWatchlists}
|
||||
<SettingSwitch
|
||||
value={
|
||||
promotedWatchlistsLocked
|
||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||
: promotedWatchlists
|
||||
}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
disabledByAdmin={hideWatchlistsTabLocked}
|
||||
>
|
||||
<Switch
|
||||
value={hideWatchlistsTab}
|
||||
<SettingSwitch
|
||||
value={
|
||||
hideWatchlistsTabLocked
|
||||
? (settings?.hideWatchlistsTab ?? false)
|
||||
: hideWatchlistsTab
|
||||
}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</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 */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -349,9 +349,6 @@ const page: React.FC = () => {
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -376,7 +373,6 @@ const page: React.FC = () => {
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -401,9 +397,6 @@ const page: React.FC = () => {
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -421,9 +414,6 @@ const page: React.FC = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -441,9 +431,6 @@ const page: React.FC = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import {
|
||||
useFocusEffect,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -80,8 +85,9 @@ const Page = () => {
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
filterBy?: string;
|
||||
fromSeeAll?: string;
|
||||
};
|
||||
const { libraryId } = searchParams;
|
||||
const { libraryId, fromSeeAll } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
@@ -112,6 +118,22 @@ const Page = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
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();
|
||||
|
||||
// TV Filter queries
|
||||
@@ -269,6 +291,23 @@ const Page = () => {
|
||||
});
|
||||
}, [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(
|
||||
async ({
|
||||
pageParam,
|
||||
@@ -529,9 +568,6 @@ const Page = () => {
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -556,7 +592,6 @@ const Page = () => {
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -581,9 +616,6 @@ const Page = () => {
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -601,9 +633,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -621,9 +650,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -641,9 +667,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
generalFilters.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -305,6 +305,8 @@ export default function SearchPage() {
|
||||
},
|
||||
hideWhenScrolling: 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
|
||||
hintTextColor: "#fff",
|
||||
headerIconColor: "#fff",
|
||||
|
||||
@@ -3,16 +3,24 @@ import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
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 { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
isTabRoute,
|
||||
useTVHomeBackHandler,
|
||||
useTVTabRootBackHandler,
|
||||
} from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(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() {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle TV back button - prevent app exit when at root
|
||||
// Must be called before any conditional return (rules of hooks)
|
||||
useTVHomeBackHandler();
|
||||
|
||||
if (IS_ANDROID_TV) {
|
||||
return <TVTabLayout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
|
||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||
}}
|
||||
>
|
||||
<Text className='text-neutral-500'>No track playing</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("music.no_track_playing")}
|
||||
</Text>
|
||||
</View>
|
||||
</BottomSheetModalProvider>
|
||||
);
|
||||
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
Now Playing
|
||||
{t("music.now_playing")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||
{history.length > 0
|
||||
? t("music.playing_from_queue")
|
||||
: t("music.up_next")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className='flex-1 items-center justify-center py-20'>
|
||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -25,6 +26,10 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
toServerSubtitleIndex,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
@@ -49,15 +54,16 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
getExternalSubtitleUrl,
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
@@ -310,6 +316,37 @@ export default function DirectPlayerPage() {
|
||||
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||
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(() => {
|
||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
@@ -445,6 +482,12 @@ export default function DirectPlayerPage() {
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
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]);
|
||||
@@ -456,10 +499,23 @@ export default function DirectPlayerPage() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||
// 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();
|
||||
// Resume inactivity timer when leaving player (TV only)
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -606,32 +662,20 @@ export default function DirectPlayerPage() {
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
// - Online: prepend API base path to server URLs
|
||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
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!);
|
||||
}
|
||||
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||
)
|
||||
.map((s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
)
|
||||
.filter((u): u is string => !!u);
|
||||
|
||||
// Calculate track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||
// list is known — see applySubtitleSelection / onTracksReady.
|
||||
const initialAudioId = getMpvAudioId(
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
@@ -649,7 +693,6 @@ export default function DirectPlayerPage() {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
@@ -697,7 +740,6 @@ export default function DirectPlayerPage() {
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
@@ -880,6 +922,29 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
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
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -891,34 +956,114 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
);
|
||||
|
||||
// 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(
|
||||
async (index: number) => {
|
||||
setCurrentSubtitleIndex(index);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Check if we're transcoding
|
||||
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;
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
// 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);
|
||||
}
|
||||
setCurrentSubtitleIndex(index);
|
||||
const result = await applySubtitleSelection(index);
|
||||
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||
// stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
applySubtitleSelection,
|
||||
replaceWithTrackSelection,
|
||||
stream?.mediaSource,
|
||||
currentSubtitleIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// Technical info toggle handler
|
||||
@@ -1037,6 +1182,10 @@ export default function DirectPlayerPage() {
|
||||
previousItem.UserData?.PlaybackPositionTicks?.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);
|
||||
}, [
|
||||
previousItem,
|
||||
@@ -1049,9 +1198,24 @@ export default function DirectPlayerPage() {
|
||||
]);
|
||||
|
||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||
const addSubtitleFile = useCallback(async (path: string) => {
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
}, []);
|
||||
const addSubtitleFile = useCallback(
|
||||
async (path: string) => {
|
||||
// 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
|
||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||
@@ -1105,6 +1269,15 @@ export default function DirectPlayerPage() {
|
||||
nextItem.UserData?.PlaybackPositionTicks?.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);
|
||||
}, [
|
||||
nextItem,
|
||||
@@ -1115,6 +1288,7 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue,
|
||||
router,
|
||||
isPlaybackStopped,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
@@ -1267,12 +1441,16 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
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 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -75,6 +76,20 @@ export default function TVOptionModal() {
|
||||
}, [isReady]);
|
||||
|
||||
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);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -192,6 +193,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -328,7 +330,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||
Hash Match
|
||||
{t("player.hash_match")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -645,10 +647,23 @@ export default function TVSubtitleModal() {
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(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?.();
|
||||
handleClose();
|
||||
},
|
||||
[handleClose],
|
||||
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
305
app/_layout.tsx
305
app/_layout.tsx
@@ -7,9 +7,11 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Image } from "expo-image";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
@@ -26,6 +28,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
@@ -84,7 +87,8 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -100,6 +104,22 @@ SplashScreen.setOptions({
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -333,9 +353,12 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(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(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -404,143 +427,149 @@ function Layout() {
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<InactivityProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<WifiSsidProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
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
|
||||
/>
|
||||
<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 />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
{!Platform.isTV && (
|
||||
<PendingAccountSaveModal />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</WifiSsidProvider>
|
||||
</InactivityProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
|
||||
@@ -3,7 +3,6 @@ declare global {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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 {};
|
||||
@@ -1,122 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||
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`;
|
||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* 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,7 +40,10 @@ import {
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
type Track,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -56,6 +59,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
@@ -232,12 +236,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
return streams ?? [];
|
||||
}, [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 streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
return streams ?? [];
|
||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// Store handleSubtitleChange in a ref for stable callback reference
|
||||
@@ -248,9 +253,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
// State to trigger refresh of local subtitles list
|
||||
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)
|
||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)
|
||||
: freshItem.MediaSources?.[0];
|
||||
|
||||
// Get subtitle streams from the fresh data
|
||||
const streams =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||
// (embedded first, externals last) — same as the initial list.
|
||||
const streams = [
|
||||
...(mediaSource?.MediaStreams?.filter(
|
||||
(s: MediaStream) => s.Type === "Subtitle",
|
||||
) ?? [];
|
||||
) ?? []),
|
||||
].sort(compareTracksForMenu);
|
||||
|
||||
// Convert to Track[] with setTrack callbacks
|
||||
const tracks: Track[] = streams.map((stream) => ({
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { BITRATES } from "./BitrateSelector";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
[
|
||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || []),
|
||||
].sort(compareTracksForMenu),
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
52
components/PendingAccountSaveModal.tsx
Normal file
52
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
onPress={onPress}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
const handlePlayInSession = async (sessionId: string) => {
|
||||
if (!api || !item.Id) return;
|
||||
|
||||
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Select Session</Text>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t("home.sessions.select_session")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</View>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<Text style={styles.noSessionsText}>
|
||||
No active sessions found
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</Text>
|
||||
{session.NowPlayingItem && (
|
||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||
Now playing:{" "}
|
||||
{t("home.sessions.now_playing")}{" "}
|
||||
{session.NowPlayingItem.SeriesName
|
||||
? `${session.NowPlayingItem.SeriesName} :`
|
||||
: ""}
|
||||
|
||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
const isPresentedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
} else if (isPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
isPresentedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
if (index >= 0) {
|
||||
isPresentedRef.current = true;
|
||||
} else if (index === -1 && isPresentedRef.current) {
|
||||
isPresentedRef.current = false;
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
const subs = 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]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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,43 +1,119 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (Platform.isTV) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
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={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
style={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
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 }) => {
|
||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||
const isAggregate = isAggregateType(item);
|
||||
const isPlayed = item.UserData?.Played === true;
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Fully watched → white checkmark badge (top-right)
|
||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
}
|
||||
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
return (
|
||||
<>
|
||||
{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' />
|
||||
)}
|
||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||
<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 { Ionicons } from "@expo/vector-icons";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { Platform } from "react-native";
|
||||
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'
|
||||
{...pressableProps}
|
||||
>
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
intensity={100}
|
||||
className='overflow-hidden rounded-full p-2'
|
||||
>
|
||||
<Ionicons
|
||||
<Feather
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
name='chevron-left'
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</BlurView>
|
||||
@@ -49,13 +49,18 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className=' rounded-full p-2'
|
||||
// Match the Settings page back button: chevron flush to the edge with a
|
||||
// 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}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons
|
||||
<Feather
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
name='chevron-left'
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
40
components/common/SettingSwitch.tsx
Normal file
40
components/common/SettingSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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 { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
|
||||
{isTranscoding && (
|
||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||
<Text className='text-xs text-purple-400'>
|
||||
{t("home.downloads.transcoding")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
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(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { FilterSheetContent } from "./FilterSheetContent";
|
||||
|
||||
interface FilterButtonProps<T> extends ViewProps {
|
||||
id: string;
|
||||
disableSearch?: boolean;
|
||||
queryKey: string;
|
||||
values: T[];
|
||||
title: string;
|
||||
set: (value: T[]) => void;
|
||||
queryFn: (params: any) => Promise<any>;
|
||||
searchFilter?: (item: T, query: string) => boolean;
|
||||
renderItemLabel: (item: T) => React.ReactNode;
|
||||
renderItemLabel: (item: T) => string;
|
||||
multiple?: boolean;
|
||||
icon?: "filter" | "sort";
|
||||
}
|
||||
@@ -27,13 +25,11 @@ export const FilterButton = <T,>({
|
||||
values, // selected values
|
||||
title,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
disableSearch = false,
|
||||
multiple = false,
|
||||
icon = "filter",
|
||||
...props
|
||||
}: FilterButtonProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
queryKey: ["filters", title, queryKey, id],
|
||||
@@ -42,61 +38,59 @@ export const FilterButton = <T,>({
|
||||
enabled: !!id && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
filters?.length && setOpen(true);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
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"}
|
||||
text-xs font-semibold`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{icon === "filter" ? (
|
||||
<Ionicons
|
||||
name='filter'
|
||||
size={14}
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesome
|
||||
name='sort'
|
||||
size={14}
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<FilterSheet<T>
|
||||
const openSheet = () => {
|
||||
if (!filters?.length) return;
|
||||
showModal(
|
||||
<FilterSheetContent<T>
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
data={filters}
|
||||
values={values}
|
||||
initialValues={values}
|
||||
set={set}
|
||||
renderItemLabel={renderItemLabel}
|
||||
searchFilter={searchFilter}
|
||||
disableSearch={disableSearch}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</>
|
||||
onClose={hideModal}
|
||||
/>,
|
||||
{ snapPoints: ["85%"] },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={openSheet}>
|
||||
<View
|
||||
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"}
|
||||
text-xs font-semibold`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{icon === "filter" ? (
|
||||
<Ionicons
|
||||
name='filter'
|
||||
size={14}
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesome
|
||||
name='sort'
|
||||
size={14}
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
134
components/filters/FilterSheetContent.tsx
Normal file
134
components/filters/FilterSheetContent.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
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
|
||||
// (primary path is the LibraryChanged WebSocket event).
|
||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
||||
// Show intro modal on first launch
|
||||
useEffect(() => {
|
||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||
if (!hasShownIntro) {
|
||||
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
||||
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
||||
if (!hasShownIntro && !pendingAccountSave) {
|
||||
const timer = setTimeout(() => {
|
||||
showIntro();
|
||||
}, 1000);
|
||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [showIntro]);
|
||||
}, [showIntro, pendingAccountSave]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
@@ -221,22 +230,33 @@ const HomeMobile = () => {
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
// Use getItems (not getLatestMedia) so we get item-level results
|
||||
// filtered by type from a specific library. getLatestMedia is
|
||||
// episode-oriented and groups results, which drops Series when
|
||||
// combined with a parentId + includeItemTypes filter.
|
||||
//
|
||||
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||
//
|
||||
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||
// a new episode is added (series cards for new episodes, matching how
|
||||
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId,
|
||||
includeItemTypes,
|
||||
recursive: true,
|
||||
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",
|
||||
pageSize,
|
||||
@@ -250,9 +270,7 @@ const HomeMobile = () => {
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
|
||||
@@ -140,9 +140,11 @@ export const Home = () => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch the image before starting the crossfade
|
||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||
// 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 {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
}, [isSuccess, onLoaded]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||
const allItems = useMemo(() => {
|
||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ContinueWatchingPoster
|
||||
item={item}
|
||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const items = data?.pages.flat() ?? [];
|
||||
@@ -201,12 +203,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
const handleSeeAllPress = useCallback(() => {
|
||||
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({
|
||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||
pathname: "/[libraryId]",
|
||||
params: {
|
||||
libraryId: parentId,
|
||||
sortBy: SortByOption.DateCreated,
|
||||
sortOrder: SortOrderOption.Descending,
|
||||
fromSeeAll: "true",
|
||||
},
|
||||
} as any);
|
||||
}, [router, parentId]);
|
||||
@@ -225,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
onFocus={() => handleItemFocus(item)}
|
||||
width={itemWidth}
|
||||
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -237,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showItemActions,
|
||||
handleItemFocus,
|
||||
ITEM_GAP,
|
||||
settings?.useEpisodeImagesForNextUp,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -326,9 +336,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
initialNumToRender={4}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={false}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
style={{ overflow: "visible" }}
|
||||
@@ -348,11 +358,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||
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
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ContinueWatchingPoster
|
||||
item={item}
|
||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
|
||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const posterUrl = useMemo(() => {
|
||||
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.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
@@ -256,8 +257,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
let isCancelled = false;
|
||||
|
||||
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 {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
@@ -379,7 +383,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Extra top padding for tvOS to clear the menu bar
|
||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
||||
const tvosTopPadding = scaleSize(145);
|
||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[library],
|
||||
[api, library],
|
||||
);
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
|
||||
@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||
{title}
|
||||
</Text>
|
||||
{title ? (
|
||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
<View
|
||||
style={[]}
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -32,14 +33,22 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const { t } = useTranslation();
|
||||
const effectiveSubtitle = disabledByAdmin
|
||||
? t("home.settings.disabled_by_admin")
|
||||
: subtitle;
|
||||
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)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isDisabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
@@ -58,7 +67,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
disabled = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||
scaleAmount: 1,
|
||||
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
<Text
|
||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||
>
|
||||
LIVE
|
||||
{t("player.live")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Live TV
|
||||
{t("live_tv.title")}
|
||||
</Text>
|
||||
|
||||
{/* Tab Bar */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import type { SavedServer } from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -35,6 +37,7 @@ const CredentialsSchema = z.object({
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
@@ -42,9 +45,11 @@ export const Login: React.FC = () => {
|
||||
login,
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -64,13 +69,50 @@ export const Login: React.FC = () => {
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// Save account state
|
||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||
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 [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||
|
||||
// A Quick Connect login with "save account" on flags the post-login save:
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -117,55 +159,34 @@ export const Login: React.FC = () => {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
if (saveAccount) {
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
const ok = await performLogin(credentials.username, credentials.password);
|
||||
// The protection picker shows AFTER a successful login (global modal) —
|
||||
// never for a failed one.
|
||||
if (ok && saveAccount) {
|
||||
setPendingAccountSave({ serverName });
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password, serverName, options);
|
||||
await login(username, password, serverName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,15 +280,8 @@ export const Login: React.FC = () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
[
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
],
|
||||
);
|
||||
setQuickConnectActive(true);
|
||||
setQuickConnectCode(code);
|
||||
}
|
||||
} catch (_error) {
|
||||
Alert.alert(
|
||||
@@ -402,7 +416,7 @@ export const Login: React.FC = () => {
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
aria-label={t("server.server_url")}
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
@@ -444,14 +458,11 @@ export const Login: React.FC = () => {
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
{/* Dismissing only hides the code — polling continues so the login still
|
||||
completes if the code is authorized from another device afterwards. */}
|
||||
<QuickConnectCodeModal
|
||||
code={quickConnectCode}
|
||||
onClose={() => setQuickConnectCode(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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 {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login")}
|
||||
label={t("login.login_button")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch the image before starting the crossfade
|
||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user