mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 09:32:50 +01:00
Compare commits
8 Commits
fix/subtit
...
fix/tv-see
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1d3e4f66 | ||
|
|
151a39c7fc | ||
|
|
a4bc67bc23 | ||
|
|
ac41fa7863 | ||
|
|
cd5300e4ba | ||
|
|
36d18e2bec | ||
|
|
326956dfda | ||
|
|
7528274249 |
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,13 +75,10 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
description: What version of Streamyfin are you using?
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.54.1
|
||||||
- 0.51.0
|
- 0.51.0
|
||||||
- 0.47.1
|
|
||||||
- 0.30.2
|
|
||||||
- 0.28.0
|
|
||||||
- Older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
21
.github/actions/refresh-pr-comment/action.yml
vendored
21
.github/actions/refresh-pr-comment/action.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
name: Refresh PR build comment
|
|
||||||
description: >-
|
|
||||||
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
|
|
||||||
comment reflects live per-platform progress as each build job finishes.
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
|
|
||||||
# artifact-comment's concurrency group collapses simultaneous nudges, so
|
|
||||||
# this can't spam the comment. Skipped on forks (their read-only token
|
|
||||||
# cannot dispatch). github.token is used because composite actions cannot
|
|
||||||
# read the secrets context.
|
|
||||||
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
continue-on-error: true
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
HEAD_REF: ${{ github.head_ref }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"
|
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
64
.github/renovate.json
vendored
64
.github/renovate.json
vendored
@@ -30,17 +30,9 @@
|
|||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||||
"matchStrings": [
|
"matchStrings": [
|
||||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||||
],
|
],
|
||||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||||
},
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
|
|
||||||
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
|
|
||||||
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
|
|
||||||
"datasourceTemplate": "npm",
|
|
||||||
"depNameTemplate": "bun"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customDatasources": {
|
"customDatasources": {
|
||||||
@@ -52,42 +44,22 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vulnerabilityAlerts": {
|
"lockFileMaintenance": {
|
||||||
"enabled": true,
|
"vulnerabilityAlerts": {
|
||||||
"addLabels": ["security", "vulnerability"],
|
"enabled": true,
|
||||||
"assigneesFromCodeOwners": true,
|
"addLabels": ["security", "vulnerability"],
|
||||||
"commitMessageSuffix": " [SECURITY]"
|
"assigneesFromCodeOwners": true,
|
||||||
},
|
"commitMessageSuffix": " [SECURITY]"
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
|
||||||
"matchPackageNames": [
|
|
||||||
"expo",
|
|
||||||
"react",
|
|
||||||
"react-dom",
|
|
||||||
"react-native",
|
|
||||||
"react-native-web",
|
|
||||||
"expo-*",
|
|
||||||
"@expo/*"
|
|
||||||
],
|
|
||||||
"groupName": "Expo SDK",
|
|
||||||
"dependencyDashboardApproval": true
|
|
||||||
},
|
},
|
||||||
{
|
"packageRules": [
|
||||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
{
|
||||||
"matchManagers": ["github-actions"],
|
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||||
"groupName": "CI dependencies",
|
"matchManagers": ["github-actions"],
|
||||||
"groupSlug": "ci-deps",
|
"groupName": "CI dependencies",
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
"groupSlug": "ci-deps",
|
||||||
"automerge": true
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
},
|
"automerge": true
|
||||||
{
|
}
|
||||||
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
]
|
||||||
"matchDatasources": ["maven"],
|
}
|
||||||
"registryUrls": [
|
|
||||||
"https://dl.google.com/dl/android/maven2/",
|
|
||||||
"https://repo.maven.apache.org/maven2/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
129
.github/workflows/artifact-comment.yml
vendored
129
.github/workflows/artifact-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
comment-artifacts:
|
comment-artifacts:
|
||||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
||||||
name: 📦 Post Build Artifacts
|
name: 📦 Post Build Artifacts
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
|
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||||
|
|
||||||
// Log current status of each build for debugging
|
// Log current status of each build for debugging
|
||||||
buildRuns.forEach(run => {
|
buildRuns.forEach(run => {
|
||||||
@@ -184,35 +184,21 @@ jobs:
|
|||||||
const latestAndroidRun = findBestRun('Android APK Build');
|
const latestAndroidRun = findBestRun('Android APK Build');
|
||||||
const latestIOSRun = findBestRun('iOS IPA Build');
|
const latestIOSRun = findBestRun('iOS IPA Build');
|
||||||
|
|
||||||
// Map our build targets to their job display names. Exact name is
|
|
||||||
// tried first so a signed target never collides with its
|
|
||||||
// "(Unsigned)" sibling (whose name contains the signed name).
|
|
||||||
const jobMappings = {
|
|
||||||
'Android Phone': ['🤖 Build Android APK (Phone)'],
|
|
||||||
'Android TV': ['🤖 Build Android APK (TV)'],
|
|
||||||
'iOS': ['🍎 Build iOS IPA (Phone)'],
|
|
||||||
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
|
|
||||||
'tvOS': ['🍎 Build tvOS IPA'],
|
|
||||||
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prefer an exact name match over a substring match so
|
|
||||||
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
|
|
||||||
const findJobForTarget = (jobs, jobNames) =>
|
|
||||||
jobs.find(j => jobNames.some(name => j.name === name)) ||
|
|
||||||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
|
|
||||||
|
|
||||||
// Format a millisecond duration as "Xm Ys".
|
|
||||||
const fmtDuration = (ms) => {
|
|
||||||
const min = Math.floor(ms / 60000);
|
|
||||||
const sec = Math.floor((ms % 60000) / 1000);
|
|
||||||
return `${min}m ${sec}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// For the consolidated workflow, get individual job statuses
|
// For the consolidated workflow, get individual job statuses
|
||||||
if (latestAppsRun) {
|
if (latestAppsRun) {
|
||||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||||
|
|
||||||
|
// Map job names to our build targets. Declared outside the try so
|
||||||
|
// the catch fallback can reuse the same keys.
|
||||||
|
const jobMappings = {
|
||||||
|
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||||
|
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||||
|
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
|
||||||
|
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
|
||||||
|
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
|
||||||
|
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all jobs for this workflow run
|
// Get all jobs for this workflow run
|
||||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||||
@@ -243,8 +229,10 @@ jobs:
|
|||||||
|
|
||||||
// Create individual status for each job
|
// Create individual status for each job
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||||
const job = findJobForTarget(jobs.jobs, jobNames);
|
const job = jobs.jobs.find(j =>
|
||||||
|
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||||
|
);
|
||||||
|
|
||||||
if (job) {
|
if (job) {
|
||||||
buildStatuses[platform] = {
|
buildStatuses[platform] = {
|
||||||
name: job.name,
|
name: job.name,
|
||||||
@@ -370,43 +358,6 @@ jobs:
|
|||||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pull per-job durations from the latest successful develop build so
|
|
||||||
// in-progress / queued targets can show a realistic ETA instead of
|
|
||||||
// an open-ended spinner. Best-effort: any failure just drops the ETA.
|
|
||||||
let referenceDurations = {};
|
|
||||||
try {
|
|
||||||
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
workflow_id: 'build-apps.yml',
|
|
||||||
branch: 'develop',
|
|
||||||
status: 'success',
|
|
||||||
per_page: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (devRuns.workflow_runs.length > 0) {
|
|
||||||
const refRun = devRuns.workflow_runs[0];
|
|
||||||
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
run_id: refRun.id
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
|
||||||
const job = findJobForTarget(refJobs.jobs, jobNames);
|
|
||||||
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
|
|
||||||
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`Reference durations from develop run ${refRun.id}:`,
|
|
||||||
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
|
|
||||||
} else {
|
|
||||||
console.log('No successful develop build found for ETA reference');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to fetch develop reference durations:', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build comment body with progressive status for individual builds
|
// Build comment body with progressive status for individual builds
|
||||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
||||||
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
||||||
@@ -418,9 +369,9 @@ jobs:
|
|||||||
const buildTargets = [
|
const buildTargets = [
|
||||||
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||||
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||||
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
|
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
|
||||||
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
|
||||||
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
|
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
|
||||||
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -436,12 +387,11 @@ jobs:
|
|||||||
let status = '⏳ Pending';
|
let status = '⏳ Pending';
|
||||||
let downloadLink = '*Waiting for build...*';
|
let downloadLink = '*Waiting for build...*';
|
||||||
|
|
||||||
// Signed tvOS stays disabled until EAS has tvOS provisioning
|
// tvOS builds are temporarily disabled until feat/tv-interface
|
||||||
// profiles (app + TopShelf targets); non-interactive builds can't
|
// is merged - show them as disabled instead of stuck pending.
|
||||||
// create them. Unsigned tvOS builds, so it flows through normally.
|
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
|
||||||
if (target.name === 'tvOS') {
|
|
||||||
status = '💤 Disabled';
|
status = '💤 Disabled';
|
||||||
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
|
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||||
} else if (matchingStatus) {
|
} else if (matchingStatus) {
|
||||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||||
status = '✅ Complete';
|
status = '✅ Complete';
|
||||||
@@ -456,9 +406,11 @@ jobs:
|
|||||||
let durationInfo = '';
|
let durationInfo = '';
|
||||||
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
if (matchingStatus.started_at && matchingStatus.completed_at) {
|
||||||
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
|
||||||
durationInfo = ` - ${fmtDuration(durationMs)}`;
|
const durationMin = Math.floor(durationMs / 60000);
|
||||||
|
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
durationInfo = ` - ${durationMin}m ${durationSec}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||||
} else if (matchingStatus.conclusion === 'failure') {
|
} else if (matchingStatus.conclusion === 'failure') {
|
||||||
status = `❌ [Failed](${matchingStatus.url})`;
|
status = `❌ [Failed](${matchingStatus.url})`;
|
||||||
@@ -468,16 +420,10 @@ jobs:
|
|||||||
downloadLink = '*Build cancelled*';
|
downloadLink = '*Build cancelled*';
|
||||||
} else if (matchingStatus.status === 'in_progress') {
|
} else if (matchingStatus.status === 'in_progress') {
|
||||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||||
const ref = referenceDurations[target.statusKey];
|
downloadLink = '*Build in progress...*';
|
||||||
downloadLink = ref
|
|
||||||
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
|
|
||||||
: '*Build in progress...*';
|
|
||||||
} else if (matchingStatus.status === 'queued') {
|
} else if (matchingStatus.status === 'queued') {
|
||||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||||
const ref = referenceDurations[target.statusKey];
|
downloadLink = '*Waiting to start...*';
|
||||||
downloadLink = ref
|
|
||||||
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
|
|
||||||
: '*Waiting to start...*';
|
|
||||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||||
// Workflow completed but conclusion not yet available (rare edge case)
|
// Workflow completed but conclusion not yet available (rare edge case)
|
||||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||||
@@ -498,22 +444,7 @@ jobs:
|
|||||||
|
|
||||||
commentBody += `\n`;
|
commentBody += `\n`;
|
||||||
|
|
||||||
// Static rundown of the build optimisations + what each artifact
|
// Show installation instructions if we have any artifacts
|
||||||
// installs on. Always shown (even mid-build) so testers know what
|
|
||||||
// to expect before downloads are ready.
|
|
||||||
commentBody += `<details>\n`;
|
|
||||||
commentBody += `<summary>📦 Build details & device compatibility</summary>\n\n`;
|
|
||||||
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
|
|
||||||
commentBody += `| Artifact | Architectures | Installs on |\n`;
|
|
||||||
commentBody += `|---|---|---|\n`;
|
|
||||||
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
|
|
||||||
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
|
|
||||||
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
|
|
||||||
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
|
|
||||||
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
|
|
||||||
commentBody += `</details>\n\n`;
|
|
||||||
|
|
||||||
// Installation instructions only matter once something is downloadable.
|
|
||||||
if (allArtifacts.length > 0) {
|
if (allArtifacts.length > 0) {
|
||||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||||
|
|||||||
183
.github/workflows/build-apps.yml
vendored
183
.github/workflows/build-apps.yml
vendored
@@ -11,23 +11,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
|
||||||
# run (artifacts + logs) without needing Expo access.
|
|
||||||
env:
|
|
||||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
|
||||||
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (Phone)
|
name: 🤖 Build Android APK (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
@@ -38,12 +28,12 @@ jobs:
|
|||||||
android: false
|
android: false
|
||||||
dotnet: true
|
dotnet: true
|
||||||
haskell: true
|
haskell: true
|
||||||
large-packages: false
|
large-packages: true
|
||||||
docker-images: true
|
docker-images: true
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -53,58 +43,45 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: ☕ 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"
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
- name: 🚀 Build APK
|
- name: 🚀 Build APK
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
|
|
||||||
# sideload target). Overrides app.json buildArchs for this build only,
|
|
||||||
# so local `bun run android` (x86_64 emulator) is unaffected.
|
|
||||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
|
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
@@ -118,16 +95,12 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-android-tv:
|
build-android-tv:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (TV)
|
name: 🤖 Build Android APK (TV)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🗑️ Free Disk Space
|
- name: 🗑️ Free Disk Space
|
||||||
@@ -138,12 +111,12 @@ jobs:
|
|||||||
android: false
|
android: false
|
||||||
dotnet: true
|
dotnet: true
|
||||||
haskell: true
|
haskell: true
|
||||||
large-packages: false
|
large-packages: true
|
||||||
docker-images: true
|
docker-images: true
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -153,57 +126,45 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: ☕ 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"
|
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches/modules-2
|
~/.gradle/caches
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
- name: 🚀 Build APK
|
- name: 🚀 Build APK
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
|
|
||||||
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
|
|
||||||
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
|
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
@@ -217,20 +178,16 @@ jobs:
|
|||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -240,16 +197,15 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -263,10 +219,10 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -275,9 +231,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
run: eas build -p ios --local --non-interactive
|
||||||
# the production version tier and stops them inflating the store build counter.
|
|
||||||
run: eas build -p ios --local --non-interactive --profile ci
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -289,20 +243,16 @@ jobs:
|
|||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-phone-unsigned:
|
build-ios-phone-unsigned:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -312,16 +262,15 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -335,7 +284,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -352,24 +301,18 @@ jobs:
|
|||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|
||||||
build-ios-tv:
|
build-ios-tv:
|
||||||
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
|
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||||
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
|
# Re-enable by removing the `false &&` prefix below.
|
||||||
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
|
|
||||||
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
|
|
||||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build tvOS IPA
|
name: 🍎 Build tvOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -379,16 +322,15 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -402,10 +344,10 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -414,7 +356,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -435,11 +377,10 @@ jobs:
|
|||||||
name: 🍎 Build tvOS IPA (Unsigned)
|
name: 🍎 Build tvOS IPA (Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -449,16 +390,15 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -472,7 +412,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.4"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -488,6 +428,3 @@ jobs:
|
|||||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 🔄 Refresh PR build comment
|
|
||||||
uses: ./.github/actions/refresh-pr-comment
|
|
||||||
|
|||||||
13
.github/workflows/check-lockfile.yml
vendored
13
.github/workflows/check-lockfile.yml
vendored
@@ -13,13 +13,13 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
check-lockfile:
|
check-lockfile:
|
||||||
name: 🔍 Check bun.lock and package.json consistency
|
name: 🔍 Check bun.lock and package.json consistency
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -29,17 +29,14 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
|
||||||
|
|
||||||
- name: 🛡️ Verify lockfile consistency
|
- name: 🛡️ Verify lockfile consistency
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
13
.github/workflows/ci-codeql.yml
vendored
13
.github/workflows/ci-codeql.yml
vendored
@@ -8,14 +8,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '24 2 * * *'
|
- cron: '24 2 * * *'
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: codeql-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: 🔎 Analyze with CodeQL
|
name: 🔎 Analyze with CodeQL
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
@@ -27,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|||||||
4
.github/workflows/conflict.yml
vendored
4
.github/workflows/conflict.yml
vendored
@@ -10,14 +10,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
name: 🏷️ Labeling Merge Conflicts
|
name: 🏷️ Labeling Merge Conflicts
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚩 Apply merge conflict label
|
- name: 🚩 Apply merge conflict label
|
||||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
6
.github/workflows/crowdin.yml
vendored
6
.github/workflows/crowdin.yml
vendored
@@ -19,16 +19,16 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-translations:
|
sync-translations:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
9
.github/workflows/detect-duplicate.yml
vendored
9
.github/workflows/detect-duplicate.yml
vendored
@@ -15,22 +15,21 @@ jobs:
|
|||||||
detect:
|
detect:
|
||||||
name: 🔍 Find similar issues
|
name: 🔍 Find similar issues
|
||||||
if: github.actor != 'github-actions[bot]'
|
if: github.actor != 'github-actions[bot]'
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.ts
|
run: bun scripts/detect-duplicate-issue.mjs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
28
.github/workflows/linting.yml
vendored
28
.github/workflows/linting.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
validate_pr_title:
|
validate_pr_title:
|
||||||
name: "📝 Validate PR Title"
|
name: "📝 Validate PR Title"
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -46,12 +46,12 @@ jobs:
|
|||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: 🔍 Vulnerable Dependencies
|
name: 🔍 Vulnerable Dependencies
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -65,10 +65,11 @@ jobs:
|
|||||||
|
|
||||||
expo-doctor:
|
expo-doctor:
|
||||||
name: 🚑 Expo Doctor Check
|
name: 🚑 Expo Doctor Check
|
||||||
runs-on: ubuntu-26.04
|
if: false
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -77,21 +78,17 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies (bun)
|
- name: 📦 Install dependencies (bun)
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: 🚑 Run Expo Doctor
|
- name: 🚑 Run Expo Doctor
|
||||||
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
|
|
||||||
# without failing the gate (some checks are known-noisy for this setup).
|
|
||||||
continue-on-error: true
|
|
||||||
run: bun expo-doctor
|
run: bun expo-doctor
|
||||||
|
|
||||||
code_quality:
|
code_quality:
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -100,11 +97,10 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
- "i18n:check"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -113,14 +109,12 @@ jobs:
|
|||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=node-version depName=node versioning=node
|
node-version: '24.x'
|
||||||
node-version: "24.18.0"
|
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
4
.github/workflows/notification.yml
vendored
4
.github/workflows/notification.yml
vendored
@@ -12,7 +12,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: 🛎️ Notify Discord
|
- name: 🛎️ Notify Discord
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
🔗 ${{ github.event.pull_request.html_url }}
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
|
|
||||||
notify-on-failure:
|
notify-on-failure:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||||
steps:
|
steps:
|
||||||
- name: 🚨 Notify Discord on Failure
|
- name: 🚨 Notify Discord on Failure
|
||||||
|
|||||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -22,9 +22,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve:
|
approve:
|
||||||
name: 🔐 Approve release
|
name: 🔐 Approve release
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
environment: production
|
environment: production
|
||||||
permissions: {}
|
|
||||||
steps:
|
steps:
|
||||||
- name: ✅ Release approved
|
- name: ✅ Release approved
|
||||||
run: echo "Release approved for ${{ github.sha }}"
|
run: echo "Release approved for ${{ github.sha }}"
|
||||||
@@ -32,7 +31,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: 🚀 ${{ matrix.name }}
|
name: 🚀 ${{ matrix.name }}
|
||||||
needs: approve
|
needs: approve
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
@@ -64,7 +63,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -73,16 +72,15 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
bun-version: latest
|
||||||
bun-version: "1.3.14"
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
@@ -90,7 +88,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -178,13 +176,13 @@ jobs:
|
|||||||
name: 📦 Draft GitHub Release
|
name: 📦 Draft GitHub Release
|
||||||
needs: build
|
needs: build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|||||||
50
.github/workflows/trivy-scan.yml
vendored
50
.github/workflows/trivy-scan.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
name: 🛡️ Trivy Security Scan
|
|
||||||
|
|
||||||
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
|
||||||
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
|
||||||
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
|
||||||
# upload needs a write token that fork PRs don't get).
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
schedule:
|
|
||||||
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: trivy-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
trivy:
|
|
||||||
name: 🔎 Filesystem scan
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write # upload SARIF to code scanning
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
|
|
||||||
# Trivy's own action caches the vulnerability DB + binary internally
|
|
||||||
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
|
|
||||||
# step is needed — it only duplicated the cache.
|
|
||||||
- name: 🔎 Run Trivy filesystem scan
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
|
||||||
scan-type: fs
|
|
||||||
scan-ref: .
|
|
||||||
scanners: vuln,secret,misconfig
|
|
||||||
ignore-unfixed: true
|
|
||||||
severity: CRITICAL,HIGH
|
|
||||||
format: sarif
|
|
||||||
output: trivy-results.sarif
|
|
||||||
|
|
||||||
- name: 📤 Upload results to code scanning
|
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
sarif_file: trivy-results.sarif
|
|
||||||
category: trivy-fs
|
|
||||||
124
.github/workflows/update-issue-form.yml
vendored
124
.github/workflows/update-issue-form.yml
vendored
@@ -1,103 +1,67 @@
|
|||||||
name: 🐛 Update Issue Form Versions
|
name: 🐛 Update Bug Report Template
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
# Only full releases populate the dropdown (no drafts/prereleases).
|
types: [published] # Run on every published release on any branch
|
||||||
types: [released]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Fixed group so a release event and the weekly cron can't race on the same
|
|
||||||
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-issue-form
|
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-issue-form:
|
update-bug-report:
|
||||||
name: 🔢 Populate version dropdown
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
node-version: '24.x'
|
||||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
cache: 'npm'
|
||||||
# PR would revert any form edits made on develop since that release.
|
|
||||||
ref: develop
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🔍 Extract minor version from app.json
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
id: minor
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=npm depName=bun
|
result-encoding: string
|
||||||
bun-version: "1.3.14"
|
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
|
- name: 📝 Update bug report version
|
||||||
id: populate
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
run: bun scripts/update-issue-form.mjs
|
with:
|
||||||
env:
|
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||||
GH_TOKEN: ${{ github.token }}
|
dry_run: no-push
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: 📬 Create pull request
|
- name: ⚙️ Update bug report node version dropdown
|
||||||
id: cpr
|
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||||
|
with:
|
||||||
|
dropdown: _node_version
|
||||||
|
package: node
|
||||||
|
semver: '>=24.0.0'
|
||||||
|
dry_run: no-push
|
||||||
|
|
||||||
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci/update-issue-form
|
branch: ci-update-bug-report
|
||||||
base: develop
|
base: develop
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
labels: ⚙️ ci, 🤖 github-actions
|
labels: ⚙️ ci, 🤖 github-actions
|
||||||
commit-message: "chore: update issue form version dropdown"
|
title: 'chore(): Update bug report template to match release version'
|
||||||
title: "chore: update issue form version dropdown"
|
|
||||||
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
|
||||||
body: |
|
body: |
|
||||||
# 📦 Pull Request
|
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||||
|
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||||
## 📝 Description
|
|
||||||
|
|
||||||
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
|
||||||
|
|
||||||
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
|
||||||
|
|
||||||
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
|
||||||
|
|
||||||
N/A — automated maintenance.
|
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
|
||||||
|
|
||||||
N/A — issue-template metadata only, no app UI.
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
|
||||||
- [x] Verified that changes behave as expected for all platforms
|
|
||||||
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
|
||||||
- [x] No secrets, hardcoded credentials, or private config files are included
|
|
||||||
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
|
||||||
|
|
||||||
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
|
||||||
|
|
||||||
- name: 🔀 Enable auto-merge
|
|
||||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
|
||||||
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
|
||||||
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
|
||||||
# Auto-merge is still worth enabling: once checks run and reviews land,
|
|
||||||
# the PR merges itself.
|
|
||||||
run: |
|
|
||||||
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
|
||||||
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
- Use TypeScript for all files (no .js)
|
||||||
- Use functional React components with hooks
|
- Use functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
|||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Download Streamyfin
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
|
|||||||
29
app.config.js
Normal file
29
app.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV !== "1") {
|
||||||
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
config.plugins.push([
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
cameraPermission:
|
||||||
|
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only override googleServicesFile if env var is set
|
||||||
|
const androidConfig = {};
|
||||||
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
|
||||||
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
|
||||||
import "tsx/cjs";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
|
||||||
const git = (args: string[]): string | null => {
|
|
||||||
try {
|
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
|
||||||
.toString()
|
|
||||||
.trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMeta = {
|
|
||||||
commit:
|
|
||||||
(
|
|
||||||
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
|
||||||
process.env.GITHUB_SHA ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
|
||||||
git(["rev-parse", "HEAD"]) ||
|
|
||||||
""
|
|
||||||
).slice(0, 7) || null,
|
|
||||||
branch:
|
|
||||||
process.env.EAS_BUILD_GIT_BRANCH ||
|
|
||||||
process.env.GITHUB_HEAD_REF ||
|
|
||||||
process.env.GITHUB_REF_NAME ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
|
||||||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
|
||||||
null,
|
|
||||||
profile:
|
|
||||||
process.env.EAS_BUILD_PROFILE ||
|
|
||||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
|
||||||
null,
|
|
||||||
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
|
||||||
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
|
||||||
runNumber:
|
|
||||||
process.env.GITHUB_RUN_NUMBER ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
|
||||||
null,
|
|
||||||
builtAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
|
||||||
if (process.env.EXPO_TV !== "1") {
|
|
||||||
config.plugins?.push("expo-background-task");
|
|
||||||
|
|
||||||
config.plugins?.push([
|
|
||||||
"react-native-google-cast",
|
|
||||||
{ useDefaultExpandedMediaControls: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
config.plugins?.push([
|
|
||||||
"expo-camera",
|
|
||||||
{
|
|
||||||
cameraPermission:
|
|
||||||
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
|
||||||
const androidConfig: { googleServicesFile?: string } = {};
|
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.extra = { ...config.extra, build: buildMeta };
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
|
||||||
...config,
|
|
||||||
} as ExpoConfig;
|
|
||||||
};
|
|
||||||
27
app.json
27
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.ts",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
"./plugins/withTVUserManagement.ts",
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -107,9 +107,6 @@
|
|||||||
],
|
],
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
"expo-audio",
|
|
||||||
"expo-image",
|
|
||||||
"expo-sharing",
|
|
||||||
[
|
[
|
||||||
"react-native-edge-to-edge",
|
"react-native-edge-to-edge",
|
||||||
{
|
{
|
||||||
@@ -134,17 +131,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.ts"],
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidAlertColors.ts"],
|
["./plugins/withAndroidAlertColors.js"],
|
||||||
["./plugins/withAndroidManifest.ts"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.ts"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.ts"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withTVOSAppIcon.ts"],
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
["./plugins/withTVOSTopShelf.ts"],
|
["./plugins/withTVOSTopShelf.js"],
|
||||||
["./plugins/withTVXcodeEnv.ts"],
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.ts",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Directory, Paths } from "expo-file-system";
|
import { Directory, Paths } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
|||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -59,6 +61,51 @@ export default function SettingsTV() {
|
|||||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
||||||
|
useJellyseerr();
|
||||||
|
|
||||||
|
// Jellyseerr state
|
||||||
|
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||||
|
settings.jellyseerrServerUrl || "",
|
||||||
|
);
|
||||||
|
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||||
|
|
||||||
|
const isJellyseerrLocked =
|
||||||
|
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
const isJellyseerrConnected = !!jellyseerrApi;
|
||||||
|
|
||||||
|
const handleJellyseerrUrlBlur = useCallback(() => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
updateSettings({ jellyseerrServerUrl: url || undefined });
|
||||||
|
}, [jellyseerrServerUrl, updateSettings]);
|
||||||
|
|
||||||
|
const jellyseerrLoginMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const url = jellyseerrServerUrl.trim();
|
||||||
|
if (!url) throw new Error("Missing server url");
|
||||||
|
if (!user?.Name) throw new Error("Missing user info");
|
||||||
|
const tempApi = new JellyseerrApi(url);
|
||||||
|
const testResult = await tempApi.test();
|
||||||
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
|
return tempApi.login(user.Name, jellyseerrPassword);
|
||||||
|
},
|
||||||
|
onSuccess: (loggedInUser) => {
|
||||||
|
setJellyseerrUser(loggedInUser);
|
||||||
|
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setJellyseerrPassword("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDisconnectJellyseerr = useCallback(() => {
|
||||||
|
clearAllJellyseerData();
|
||||||
|
setJellyseerrServerUrl("");
|
||||||
|
setJellyseerrPassword("");
|
||||||
|
}, [clearAllJellyseerData]);
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -877,6 +924,81 @@ export default function SettingsTV() {
|
|||||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* seerr Section */}
|
||||||
|
<TVSectionHeader title='seerr' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
|
||||||
|
"Enter your Jellyseerr server URL to enable discover and request features."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
||||||
|
}
|
||||||
|
value={jellyseerrServerUrl}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
||||||
|
"https://jellyseerr.example.com"
|
||||||
|
}
|
||||||
|
onChangeText={setJellyseerrServerUrl}
|
||||||
|
onBlur={handleJellyseerrUrlBlur}
|
||||||
|
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||||
|
<>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.plugins.jellyseerr.password") || "Password"
|
||||||
|
}
|
||||||
|
value={jellyseerrPassword}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
||||||
|
username: user?.Name,
|
||||||
|
}) || `Jellyfin password`
|
||||||
|
}
|
||||||
|
onChangeText={setJellyseerrPassword}
|
||||||
|
secureTextEntry
|
||||||
|
disabled={jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={
|
||||||
|
jellyseerrLoginMutation.isPending
|
||||||
|
? t("common.connecting", "Connecting...") || "Connecting..."
|
||||||
|
: t("common.connect", "Connect") || "Connect"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
onPress={() => jellyseerrLoginMutation.mutate()}
|
||||||
|
disabled={jellyseerrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TVSettingsRow
|
||||||
|
label={
|
||||||
|
isJellyseerrConnected
|
||||||
|
? t("common.connected", "Connected") || "Connected"
|
||||||
|
: t("common.not_connected", "Not connected") || "Not connected"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={
|
||||||
|
t(
|
||||||
|
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||||
|
) || "Disconnect"
|
||||||
|
}
|
||||||
|
value=''
|
||||||
|
onPress={handleDisconnectJellyseerr}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Storage Section */}
|
{/* Storage Section */}
|
||||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
import {
|
||||||
|
useIsFocused,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { orderBy, uniqBy } from "lodash";
|
import { orderBy, uniqBy } from "lodash";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +25,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import {
|
||||||
|
useJellyseerr,
|
||||||
|
validateJellyseerrSession,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -106,8 +120,44 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const isFocused = useIsFocused();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
// Alert when seerr server is configured but user hasn't connected (only when focused)
|
||||||
|
const jellyseerrAlertedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||||
|
if (jellyseerrAlertedRef.current) return;
|
||||||
|
jellyseerrAlertedRef.current = true;
|
||||||
|
Alert.alert(
|
||||||
|
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
|
||||||
|
t(
|
||||||
|
"jellyseerr.connect_in_settings",
|
||||||
|
"Jellyseerr is available. Connect in Settings to enable request features.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
||||||
|
|
||||||
|
// Validate jellyseerr session when switching to Discover
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
searchType !== "Discover" ||
|
||||||
|
!jellyseerrApi ||
|
||||||
|
!settings?.jellyseerrServerUrl
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
||||||
|
if (status.valid) return;
|
||||||
|
Alert.alert(
|
||||||
|
t("jellyseerr.session_expired", "Session expired"),
|
||||||
|
t(
|
||||||
|
"jellyseerr.session_expired_connect_again",
|
||||||
|
"Your Jellyseerr session has expired. Please reconnect in Settings.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
JellyseerrSearchSort[
|
JellyseerrSearchSort[
|
||||||
@@ -305,8 +355,6 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
|
||||||
textColor: "#fff",
|
|
||||||
// Android: placeholder and icon color
|
// Android: placeholder and icon color
|
||||||
hintTextColor: "#fff",
|
hintTextColor: "#fff",
|
||||||
headerIconColor: "#fff",
|
headerIconColor: "#fff",
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "expo-router/react-navigation";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
|
||||||
import { TVNavBar } from "@/components/tv/TVNavBar";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import {
|
|
||||||
isTabRoute,
|
|
||||||
useTVHomeBackHandler,
|
|
||||||
useTVTabRootBackHandler,
|
|
||||||
} from "@/hooks/useTVBackHandler";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
|
|||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
|
||||||
|
|
||||||
function TVTabLayout() {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = useSegments();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const currentTab = segments.find(isTabRoute);
|
|
||||||
const lastSegment = segments[segments.length - 1] ?? "";
|
|
||||||
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
|
||||||
|
|
||||||
const tabs: TVNavBarTab[] = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{ key: "(home)", label: t("tabs.home") },
|
|
||||||
{ key: "(search)", label: t("tabs.search") },
|
|
||||||
{ key: "(favorites)", label: t("tabs.favorites") },
|
|
||||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
|
||||||
? null
|
|
||||||
: { key: "(watchlists)", label: t("watchlists.title") },
|
|
||||||
{ key: "(libraries)", label: t("tabs.library") },
|
|
||||||
!settings?.showCustomMenuLinks
|
|
||||||
? null
|
|
||||||
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
|
||||||
{ key: "(settings)", label: t("tabs.settings") },
|
|
||||||
].filter((tab): tab is TVNavBarTab => tab !== null),
|
|
||||||
[
|
|
||||||
settings?.streamyStatsServerUrl,
|
|
||||||
settings?.hideWatchlistsTab,
|
|
||||||
settings?.showCustomMenuLinks,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTabKey = currentTab ?? "(home)";
|
|
||||||
|
|
||||||
const visibleKeys = useMemo(
|
|
||||||
() => new Set(tabs.map((tab) => tab.key)),
|
|
||||||
[tabs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
if (key === currentTab) return;
|
|
||||||
|
|
||||||
if (key === "(home)") eventBus.emit("scrollToTop");
|
|
||||||
if (key === "(search)") eventBus.emit("searchTabPressed");
|
|
||||||
|
|
||||||
router.replace(`/(auth)/(tabs)/${key}`);
|
|
||||||
},
|
|
||||||
[currentTab, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateHome = useCallback(() => {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)");
|
|
||||||
}, [router]);
|
|
||||||
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
|
||||||
|
|
||||||
// If current tab is no longer visible (setting changed), navigate to home
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)");
|
|
||||||
}
|
|
||||||
}, [visibleKeys, activeTabKey, router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<SystemBars hidden={false} style='light' />
|
|
||||||
<Stack
|
|
||||||
screenOptions={{ headerShown: false, animation: "none" }}
|
|
||||||
initialRouteName='(home)'
|
|
||||||
>
|
|
||||||
<Stack.Screen name='index' redirect />
|
|
||||||
</Stack>
|
|
||||||
<TVNavBar
|
|
||||||
tabs={tabs}
|
|
||||||
activeTabKey={activeTabKey}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Must be called before any conditional return (rules of hooks)
|
// Handle TV back button - prevent app exit when at root
|
||||||
useTVHomeBackHandler();
|
useTVHomeBackHandler();
|
||||||
|
|
||||||
if (IS_ANDROID_TV) {
|
|
||||||
return <TVTabLayout />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
applyMpvSubtitleSelection,
|
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -456,23 +456,10 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
videoRef.current?.pause();
|
||||||
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
|
||||||
// episode's player) mounts while the old 4K decoder is still alive,
|
|
||||||
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
|
||||||
// later React unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
// Pre-libmpv-1.0 used `stop()`:
|
|
||||||
// videoRef.current?.stop();
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
// Release the keep-awake wakelock acquired during playback so it
|
|
||||||
// doesn't follow us back to the home screen and block the TV
|
|
||||||
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
|
||||||
// and only released on the "paused" event; without this, navigating
|
|
||||||
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
|
||||||
deactivateKeepAwake();
|
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -639,9 +626,12 @@ export default function DirectPlayerPage() {
|
|||||||
).map((s) => s.DeliveryUrl!);
|
).map((s) => s.DeliveryUrl!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
// Calculate track IDs for initial selection
|
||||||
// subtitles). The subtitle selection is applied later, once MPV's real track
|
const initialSubtitleId = getMpvSubtitleId(
|
||||||
// list is known — see applySubtitleSelection / onTracksReady.
|
mediaSource,
|
||||||
|
subtitleIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -659,6 +649,7 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -706,6 +697,7 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -903,41 +895,30 @@ export default function DirectPlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
/**
|
|
||||||
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
|
||||||
* it. Identity-based (external by filename, embedded by language/title) so it
|
|
||||||
* stays correct across external/embedded reordering and server-hidden embedded
|
|
||||||
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
|
||||||
* fired again after each external sub-add) and runtime changes.
|
|
||||||
*/
|
|
||||||
const applySubtitleSelection = useCallback(
|
|
||||||
async (jellyfinSubtitleIndex: number) => {
|
|
||||||
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) => s.Type === "Subtitle",
|
|
||||||
);
|
|
||||||
await applyMpvSubtitleSelection(videoRef.current, {
|
|
||||||
subtitleStreams,
|
|
||||||
jellyfinSubtitleIndex,
|
|
||||||
// The exact URL each external sub was loaded into MPV with — mirrors the
|
|
||||||
// externalSubtitles array built in videoSource (online: basePath +
|
|
||||||
// DeliveryUrl, offline: local DeliveryUrl).
|
|
||||||
getExpectedExternalUrl: (s) => {
|
|
||||||
if (!s.DeliveryUrl) return undefined;
|
|
||||||
if (offline) return s.DeliveryUrl;
|
|
||||||
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[stream?.mediaSource, offline, api?.basePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
// TV/mobile subtitle track change handler
|
|
||||||
const handleSubtitleIndexChange = useCallback(
|
const handleSubtitleIndexChange = useCallback(
|
||||||
async (index: number) => {
|
async (index: number) => {
|
||||||
setCurrentSubtitleIndex(index);
|
setCurrentSubtitleIndex(index);
|
||||||
await applySubtitleSelection(index);
|
|
||||||
|
// Check if we're transcoding
|
||||||
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[applySubtitleSelection],
|
[stream?.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1124,15 +1105,6 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
|
||||||
// decoder + surface buffers are freed before the new player screen
|
|
||||||
// mounts. Without this, Expo Router briefly holds two simultaneous
|
|
||||||
// mpv instances during the transition (~768 MB of surface buffers
|
|
||||||
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
|
||||||
// devices. Native stop() is idempotent so the subsequent React
|
|
||||||
// unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1143,7 +1115,6 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
videoRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
@@ -1302,10 +1273,6 @@ export default function DirectPlayerPage() {
|
|||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
// Fired after embedded tracks enumerate and again after each
|
|
||||||
// external sub-add; re-resolve so the final fire (full track
|
|
||||||
// list) selects the right track by identity.
|
|
||||||
void applySubtitleSelection(currentSubtitleIndex);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hasPlaybackStarted && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
@@ -101,22 +100,6 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
|
||||||
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
|
||||||
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
|
||||||
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
|
||||||
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
|
||||||
try {
|
|
||||||
Image.configureCache({
|
|
||||||
maxMemoryCost: Platform.isTV
|
|
||||||
? 8 * 1024 * 1024 // ~8 MB on TV
|
|
||||||
: 128 * 1024 * 1024, // ~128 MB on mobile
|
|
||||||
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this.replaceAll("_", " ").replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -56,7 +56,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -233,13 +232,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
||||||
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -413,13 +411,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
// Get subtitle streams from the fresh data
|
||||||
// (embedded first, externals last) — same as the initial list.
|
const streams =
|
||||||
const streams = [
|
mediaSource?.MediaStreams?.filter(
|
||||||
...(mediaSource?.MediaStreams?.filter(
|
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? []),
|
) ?? [];
|
||||||
].sort(compareTracksForMenu);
|
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -64,12 +63,9 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
[
|
(x) => x.Type === "Subtitle",
|
||||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
) || [],
|
||||||
(x) => x.Type === "Subtitle",
|
|
||||||
) || []),
|
|
||||||
].sort(compareTracksForMenu),
|
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPresentedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else if (isPresentedRef.current) {
|
} else {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -23,9 +22,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
|
||||||
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source, streamType],
|
[source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -140,11 +140,9 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
// Prefetch the image before starting the crossfade
|
||||||
// decoded ARGB) is too large to pin in the memory cache on every
|
|
||||||
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={4}
|
initialNumToRender={5}
|
||||||
maxToRenderPerBatch={2}
|
maxToRenderPerBatch={3}
|
||||||
windowSize={3}
|
windowSize={5}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
|
|||||||
@@ -256,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
|
||||||
// out of the memory cache avoids bloat when the user cycles through
|
|
||||||
// hero items quickly.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
@@ -382,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
// Extra top padding for tvOS to clear the menu bar
|
// Extra top padding for tvOS to clear the menu bar
|
||||||
const tvosTopPadding = scaleSize(145);
|
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
||||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,6 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
const SCALE_PADDING = 20;
|
|
||||||
|
|
||||||
interface TVDiscoverPosterProps {
|
interface TVDiscoverPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
const posterWidth = sizes.posters.poster;
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: sizes.gaps.small,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year && (
|
{year != null && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: sizes.gaps.small,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
if (!flatData || flatData.length === 0) return null;
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slideTitle}
|
{slideTitle}
|
||||||
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[api, library],
|
[library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TVSubmitButton
|
<TVSubmitButton
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
label={t("login.login_button")}
|
label={t("login.login")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
// Prefetch the image before starting the crossfade
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
@@ -14,20 +15,21 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
const SCALE_PADDING = 20;
|
|
||||||
|
|
||||||
interface TVJellyseerrPosterProps {
|
interface TVJellyseerrPosterProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
isFirstItem?: boolean;
|
isFirstItem?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
@@ -43,18 +45,22 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
const posterWidth = sizes.posters.poster;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
@@ -64,9 +70,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 210,
|
width: posterWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: 24,
|
borderRadius: sizes.gaps.small,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
}}
|
}}
|
||||||
@@ -117,13 +123,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: sizes.gaps.small,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{year && (
|
{year != null && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
@@ -142,13 +148,16 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
interface TVJellyseerrPersonPosterProps {
|
interface TVJellyseerrPersonPosterProps {
|
||||||
item: PersonResult;
|
item: PersonResult;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation();
|
useTVFocusAnimation();
|
||||||
@@ -157,13 +166,21 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const avatarSize = Math.round(sizes.posters.poster * 0.67);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
{
|
{
|
||||||
width: 160,
|
width: avatarSize,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
@@ -174,9 +191,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 140,
|
width: avatarSize,
|
||||||
height: 140,
|
height: avatarSize,
|
||||||
borderRadius: 70,
|
borderRadius: avatarSize / 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
borderWidth: focused ? 3 : 0,
|
borderWidth: focused ? 3 : 0,
|
||||||
@@ -198,7 +215,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
<Ionicons
|
||||||
|
name='person'
|
||||||
|
size={Math.round(avatarSize * 0.35)}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -207,7 +228,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
fontSize: typography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: sizes.gaps.small,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
@@ -223,6 +244,7 @@ interface TVJellyseerrMovieSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: MovieResult[];
|
items: MovieResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onItemPress: (item: MovieResult) => void;
|
onItemPress: (item: MovieResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,20 +252,22 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
|
disabled = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -254,9 +278,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -264,6 +288,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -275,6 +300,7 @@ interface TVJellyseerrTvSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: TvResult[];
|
items: TvResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onItemPress: (item: TvResult) => void;
|
onItemPress: (item: TvResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,20 +308,22 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
|
disabled = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -306,9 +334,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
@@ -316,6 +344,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
isFirstItem={isFirstSection && index === 0}
|
isFirstItem={isFirstSection && index === 0}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -327,6 +356,7 @@ interface TVJellyseerrPersonSectionProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: PersonResult[];
|
items: PersonResult[];
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onItemPress: (item: PersonResult) => void;
|
onItemPress: (item: PersonResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,20 +364,22 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
isFirstSection: _isFirstSection = false,
|
isFirstSection: _isFirstSection = false,
|
||||||
|
disabled = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const sizes = useScaledTVSizes();
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: sizes.gaps.small,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: sizes.padding.scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -358,15 +390,16 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: SCALE_PADDING,
|
paddingHorizontal: sizes.padding.scale,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: sizes.padding.scale,
|
||||||
gap: 20,
|
gap: sizes.gaps.item,
|
||||||
}}
|
}}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TVJellyseerrPersonPoster
|
<TVJellyseerrPersonPoster
|
||||||
item={item}
|
item={item}
|
||||||
onPress={() => onItemPress(item)}
|
onPress={() => onItemPress(item)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -384,6 +417,7 @@ export interface TVJellyseerrSearchResultsProps {
|
|||||||
onMoviePress: (item: MovieResult) => void;
|
onMoviePress: (item: MovieResult) => void;
|
||||||
onTvPress: (item: TvResult) => void;
|
onTvPress: (item: TvResult) => void;
|
||||||
onPersonPress: (item: PersonResult) => void;
|
onPersonPress: (item: PersonResult) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVJellyseerrSearchResults: React.FC<
|
export const TVJellyseerrSearchResults: React.FC<
|
||||||
@@ -398,8 +432,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
onMoviePress,
|
onMoviePress,
|
||||||
onTvPress,
|
onTvPress,
|
||||||
onPersonPress,
|
onPersonPress,
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -410,7 +446,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -418,7 +454,9 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
<Text
|
||||||
|
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
||||||
|
>
|
||||||
"{searchQuery}"
|
"{searchQuery}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -435,18 +473,21 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
|
disabled={disabled}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
|
disabled={disabled}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
|
disabled={disabled}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
|
|
||||||
// Image URL getter for music items
|
// Image URL getter for music items
|
||||||
const getImageUrl = useMemo(() => {
|
const getImageUrl = useMemo(() => {
|
||||||
@@ -270,6 +271,9 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
defaultValue=''
|
defaultValue=''
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
|
hasTVPreferredFocus
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -290,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
setSearchType={setSearchType}
|
setSearchType={setSearchType}
|
||||||
showDiscover={showDiscover}
|
showDiscover={showDiscover}
|
||||||
|
disabled={isSearchFocused}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -316,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
// every keystroke as results re-render. User navigates down to the
|
// every keystroke as results re-render. User navigates down to the
|
||||||
// grid manually.
|
// grid manually.
|
||||||
isFirstSection={false}
|
isFirstSection={false}
|
||||||
|
disabled={isSearchFocused}
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
@@ -339,6 +345,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
loading={jellyseerrLoading}
|
loading={jellyseerrLoading}
|
||||||
noResults={jellyseerrNoResults}
|
noResults={jellyseerrNoResults}
|
||||||
searchQuery={debouncedSearch}
|
searchQuery={debouncedSearch}
|
||||||
|
disabled={isSearchFocused}
|
||||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||||
|
|||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
|
</Text>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import * as Application from "expo-application";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getVersionInfo } from "@/utils/version";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
@@ -13,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Graduated build identifier — see utils/version.ts:
|
const version =
|
||||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
Application?.nativeApplicationVersion ||
|
||||||
const { display: version } = getVersionInfo();
|
Application?.nativeBuildVersion ||
|
||||||
|
"N/A";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleProp,
|
|
||||||
View,
|
|
||||||
ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
|
||||||
import { TVPadding } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
|
|
||||||
export interface TVNavBarTab {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TVNavBarProps {
|
|
||||||
tabs: TVNavBarTab[];
|
|
||||||
activeTabKey: string;
|
|
||||||
onTabChange: (key: string) => void;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TVNavBarTabItem: React.FC<{
|
|
||||||
label: string;
|
|
||||||
isActive: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onLayout: (e: {
|
|
||||||
nativeEvent: { layout: { x: number; width: number } };
|
|
||||||
}) => void;
|
|
||||||
hasTVPreferredFocus: boolean;
|
|
||||||
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
|
||||||
useTVFocusAnimation({
|
|
||||||
scaleAmount: 1.05,
|
|
||||||
duration: 120,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bg = focused
|
|
||||||
? "rgba(255, 255, 255, 0.95)"
|
|
||||||
: isActive
|
|
||||||
? "rgba(255, 255, 255, 0.15)"
|
|
||||||
: "transparent";
|
|
||||||
|
|
||||||
const textColor = focused
|
|
||||||
? "#000"
|
|
||||||
: isActive
|
|
||||||
? "#fff"
|
|
||||||
: "rgba(255, 255, 255, 0.7)";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onSelect}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
||||||
onLayout={onLayout}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedStyle,
|
|
||||||
{
|
|
||||||
backgroundColor: bg,
|
|
||||||
borderRadius: scaleSize(24),
|
|
||||||
borderWidth: isActive && !focused ? 1 : 0,
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
|
||||||
paddingHorizontal: scaleSize(28),
|
|
||||||
paddingVertical: scaleSize(14),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: typography.heading,
|
|
||||||
color: textColor,
|
|
||||||
fontWeight: isActive || focused ? "600" : "400",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
|
||||||
tabs,
|
|
||||||
activeTabKey,
|
|
||||||
onTabChange,
|
|
||||||
style,
|
|
||||||
}) => {
|
|
||||||
const scrollRef = React.useRef<ScrollView>(null);
|
|
||||||
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const handleTabLayout = React.useCallback(
|
|
||||||
(key: string) =>
|
|
||||||
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
|
|
||||||
tabLayouts.current[key] = e.nativeEvent.layout;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabChange = React.useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
onTabChange(key);
|
|
||||||
|
|
||||||
const layout = tabLayouts.current[key];
|
|
||||||
if (layout && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTo({
|
|
||||||
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
|
|
||||||
animated: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onTabChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tabs.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
contentContainerStyle={{
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: scaleSize(12),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TVNavBarTabItem
|
|
||||||
key={tab.key}
|
|
||||||
label={tab.label}
|
|
||||||
isActive={tab.key === activeTabKey}
|
|
||||||
onSelect={() => handleTabChange(tab.key)}
|
|
||||||
onLayout={handleTabLayout(tab.key)}
|
|
||||||
hasTVPreferredFocus={tab.key === activeTabKey}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
placeholder={{ blurhash }}
|
placeholder={{ blurhash }}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
source={{ uri: imageUrl }}
|
source={{ uri: imageUrl }}
|
||||||
recyclingKey={item.Id}
|
|
||||||
cachePolicy='memory-disk'
|
cachePolicy='memory-disk'
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
|||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
|
||||||
export { TVNavBar } from "./TVNavBar";
|
|
||||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
@@ -318,10 +317,8 @@ export const Controls: FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||||
// Skip streams without a real index: `?? -1` would alias them to the
|
// Skip streams without a real index: `?? -1` would alias them to the
|
||||||
// "disable subtitles" sentinel and mis-route selection. Order like
|
// "disable subtitles" sentinel and mis-route selection.
|
||||||
// jellyfin-web (embedded first, externals last, forced/default up).
|
return streams
|
||||||
return [...streams]
|
|
||||||
.sort(compareTracksForMenu)
|
|
||||||
.filter((stream) => typeof stream.Index === "number")
|
.filter((stream) => typeof stream.Index === "number")
|
||||||
.map((stream) => {
|
.map((stream) => {
|
||||||
const index = stream.Index as number;
|
const index = stream.Index as number;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
type SubtitleSearchResult,
|
type SubtitleSearchResult,
|
||||||
useRemoteSubtitles,
|
useRemoteSubtitles,
|
||||||
} from "@/hooks/useRemoteSubtitles";
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
|
||||||
interface TVSubtitleSheetProps {
|
interface TVSubtitleSheetProps {
|
||||||
@@ -97,19 +96,13 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
|
||||||
const sortedTracks = useMemo(
|
|
||||||
() => [...subtitleTracks].sort(compareTracksForMenu),
|
|
||||||
[subtitleTracks],
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialSelectedTrackIndex = useMemo(() => {
|
const initialSelectedTrackIndex = useMemo(() => {
|
||||||
if (currentSubtitleIndex === -1) return 0;
|
if (currentSubtitleIndex === -1) return 0;
|
||||||
const trackIdx = sortedTracks.findIndex(
|
const trackIdx = subtitleTracks.findIndex(
|
||||||
(t) => t.Index === currentSubtitleIndex,
|
(t) => t.Index === currentSubtitleIndex,
|
||||||
);
|
);
|
||||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||||
}, [sortedTracks, currentSubtitleIndex]);
|
}, [subtitleTracks, currentSubtitleIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -222,7 +215,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
value: -1,
|
value: -1,
|
||||||
selected: currentSubtitleIndex === -1,
|
selected: currentSubtitleIndex === -1,
|
||||||
};
|
};
|
||||||
const options = sortedTracks.map((track) => ({
|
const options = subtitleTracks.map((track) => ({
|
||||||
label:
|
label:
|
||||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
sublabel: track.Codec?.toUpperCase(),
|
sublabel: track.Codec?.toUpperCase(),
|
||||||
@@ -230,7 +223,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
selected: track.Index === currentSubtitleIndex,
|
selected: track.Index === currentSubtitleIndex,
|
||||||
}));
|
}));
|
||||||
return [noneOption, ...options];
|
return [noneOption, ...options];
|
||||||
}, [sortedTracks, currentSubtitleIndex, t]);
|
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -342,12 +342,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
{info?.demuxerMaxBytes !== undefined
|
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
|
||||||
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
|
||||||
")"
|
|
||||||
: ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
@@ -356,12 +350,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.estimatedVfFps !== undefined && (
|
|
||||||
<Text style={textStyle}>
|
|
||||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
|
||||||
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
Dropped: {info.droppedFrames} frames
|
||||||
|
|||||||
@@ -23,29 +23,32 @@
|
|||||||
* - Used to report playback state to Jellyfin server
|
* - Used to report playback state to Jellyfin server
|
||||||
* - Value of -1 means disabled/none
|
* - Value of -1 means disabled/none
|
||||||
*
|
*
|
||||||
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
* 2. MPV INDEX (track.mpvIndex)
|
||||||
* - Selection resolves the server Index against MPV's REAL track list via
|
* - MPV's internal track ID
|
||||||
* applyMpvSubtitleSelection: externals matched by external-filename,
|
* - MPV orders tracks as: [all embedded, then all external]
|
||||||
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
||||||
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
||||||
* reordered or the server hid embedded subs (#954 et al.).
|
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SUBTITLE HANDLING
|
* SUBTITLE HANDLING
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Embedded & External:
|
* Embedded (DeliveryMethod.Embed):
|
||||||
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
* - Already in MPV's track list
|
||||||
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
* - Select via setSubtitleTrack(mpvId)
|
||||||
* embedded first, externals last, forced/default float up).
|
*
|
||||||
|
* External (DeliveryMethod.External):
|
||||||
|
* - Loaded into MPV on video start
|
||||||
|
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
||||||
*
|
*
|
||||||
* Image-based during transcoding:
|
* Image-based during transcoding:
|
||||||
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
* - Burned into video by Jellyfin, not in MPV
|
||||||
|
* - Requires replacePlayer() to change
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -58,14 +61,9 @@ import {
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import type { MpvAudioTrack } from "@/modules";
|
import type { MpvAudioTrack } from "@/modules";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import {
|
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||||
applyMpvSubtitleSelection,
|
|
||||||
compareTracksForMenu,
|
|
||||||
isImageBasedSubtitle,
|
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
@@ -89,7 +87,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||||
const playerControls = usePlayerControls();
|
const playerControls = usePlayerControls();
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
@@ -144,19 +141,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracksReady) return;
|
if (!tracksReady) return;
|
||||||
|
|
||||||
// Guard every state commit against stale runs: api?.basePath /
|
|
||||||
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
|
|
||||||
// earlier async run (which captured an old `api`) must not finish later and
|
|
||||||
// overwrite the fresh track list with callbacks bound to stale closures.
|
|
||||||
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
|
|
||||||
let cancelled = false;
|
|
||||||
const commitSubtitleTracks = (next: Track[]) => {
|
|
||||||
if (!cancelled) setSubtitleTracks(next);
|
|
||||||
};
|
|
||||||
const commitAudioTracks = (next: Track[]) => {
|
|
||||||
if (!cancelled) setAudioTracks(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
// Check if this is offline transcoded content
|
// Check if this is offline transcoded content
|
||||||
// For transcoded offline content, only ONE audio track exists in the file
|
// For transcoded offline content, only ONE audio track exists in the file
|
||||||
@@ -182,10 +166,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
commitAudioTracks(audio);
|
setAudioTracks(audio);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: show no audio tracks if the stored track wasn't found
|
// Fallback: show no audio tracks if the stored track wasn't found
|
||||||
commitAudioTracks([]);
|
setAudioTracks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subtitles in transcoded offline content:
|
// For subtitles in transcoded offline content:
|
||||||
@@ -195,24 +179,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
downloadedItem.userData.subtitleStreamIndex;
|
downloadedItem.userData.subtitleStreamIndex;
|
||||||
const subs: Track[] = [];
|
const subs: Track[] = [];
|
||||||
|
|
||||||
// If an IMAGE subtitle was burned into the transcoded download it's in the
|
|
||||||
// video pixels — it can't be turned off or swapped. Show only that entry
|
|
||||||
// instead of advertising "Disable"/text controls that can't affect it.
|
|
||||||
const burnedInSub = allSubs.find(
|
|
||||||
(s) => s.Index === downloadedSubtitleIndex,
|
|
||||||
);
|
|
||||||
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
|
|
||||||
commitSubtitleTracks([
|
|
||||||
{
|
|
||||||
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
|
|
||||||
index: burnedInSub.Index ?? -1,
|
|
||||||
mpvIndex: -1,
|
|
||||||
setTrack: () => {},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add "Disable" option
|
// Add "Disable" option
|
||||||
subs.push({
|
subs.push({
|
||||||
name: "Disable",
|
name: "Disable",
|
||||||
@@ -224,82 +190,123 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text subs are muxed into the transcoded file and switchable; resolve by
|
// For text-based subs, they should still be available in the file
|
||||||
// identity against MPV's real track list (same as online). Order matches web.
|
let subIdx = 1;
|
||||||
// Image subs aren't in the transcoded file (only the burned one was, handled
|
for (const sub of allSubs) {
|
||||||
// above), so skip them here.
|
if (sub.IsTextSubtitleStream) {
|
||||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
|
||||||
if (!isImageBasedSubtitle(sub)) {
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: -1,
|
mpvIndex: subIdx,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
|
playerControls.setSubtitleTrack(subIdx);
|
||||||
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
subIdx++;
|
||||||
|
} else if (sub.Index === downloadedSubtitleIndex) {
|
||||||
|
// This image-based sub was burned in - show it but indicate it's active
|
||||||
|
subs.push({
|
||||||
|
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
||||||
|
index: sub.Index ?? -1,
|
||||||
|
mpvIndex: -1, // Can't be changed
|
||||||
|
setTrack: () => {
|
||||||
|
// Already burned in, just update params
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
void applyMpvSubtitleSelection(playerControls, {
|
|
||||||
subtitleStreams: allSubs,
|
|
||||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
|
||||||
getExpectedExternalUrl: (s) => {
|
|
||||||
if (!s.DeliveryUrl) return undefined;
|
|
||||||
if (offline) return s.DeliveryUrl;
|
|
||||||
return api?.basePath
|
|
||||||
? `${api.basePath}${s.DeliveryUrl}`
|
|
||||||
: undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commitSubtitleTracks(subs);
|
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MPV track handling
|
// MPV track handling
|
||||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||||
if (cancelled) return;
|
|
||||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||||
|
|
||||||
|
// Separate embedded vs external subtitles from Jellyfin's list
|
||||||
|
// MPV orders tracks as: [all embedded, then all external]
|
||||||
|
const embeddedSubs = allSubs.filter(
|
||||||
|
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
||||||
|
);
|
||||||
|
const externalSubs = allSubs.filter(
|
||||||
|
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count embedded subs that will be in MPV
|
||||||
|
// (excludes image-based subs during transcoding as they're burned in)
|
||||||
|
const embeddedInPlayer = embeddedSubs.filter(
|
||||||
|
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
||||||
|
);
|
||||||
|
|
||||||
const subs: Track[] = [];
|
const subs: Track[] = [];
|
||||||
|
|
||||||
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
// Process all Jellyfin subtitles
|
||||||
// track list by identity (applyMpvSubtitleSelection) — never positional
|
for (const sub of allSubs) {
|
||||||
// index math, which mis-selects across external/embedded reordering and
|
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
||||||
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
const isExternal =
|
||||||
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
|
||||||
// Image-based subs during transcoding are burned into the video by the
|
// For image-based subs during transcoding, need to refresh player
|
||||||
// server; both switching TO one and switching AWAY from a currently
|
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||||
// active one require a player refresh (re-transcode), not a track change.
|
subs.push({
|
||||||
const needsReplace =
|
name: sub.DisplayTitle || "Unknown",
|
||||||
isTranscoding &&
|
index: sub.Index ?? -1,
|
||||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
mpvIndex: -1,
|
||||||
|
setTrack: () => {
|
||||||
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate MPV track ID based on type
|
||||||
|
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||||
|
let mpvId = -1;
|
||||||
|
|
||||||
|
if (isEmbedded) {
|
||||||
|
// Find position among embedded subs that are in player
|
||||||
|
const embeddedPosition = embeddedInPlayer.findIndex(
|
||||||
|
(s) => s.Index === sub.Index,
|
||||||
|
);
|
||||||
|
if (embeddedPosition !== -1) {
|
||||||
|
mpvId = embeddedPosition + 1; // 1-based ID
|
||||||
|
}
|
||||||
|
} else if (isExternal) {
|
||||||
|
// Find position among external subs, offset by embedded count
|
||||||
|
const externalPosition = externalSubs.findIndex(
|
||||||
|
(s) => s.Index === sub.Index,
|
||||||
|
);
|
||||||
|
if (externalPosition !== -1) {
|
||||||
|
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: -1,
|
mpvIndex: mpvId,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
if (needsReplace) {
|
// Transcoding + switching to/from image-based sub
|
||||||
|
if (
|
||||||
|
isTranscoding &&
|
||||||
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||||
|
) {
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
|
||||||
void applyMpvSubtitleSelection(playerControls, {
|
// Direct switch in player
|
||||||
subtitleStreams: allSubs,
|
if (mpvId !== -1) {
|
||||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
playerControls.setSubtitleTrack(mpvId);
|
||||||
// Mirror how external subs are loaded into MPV (online: basePath +
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
return;
|
||||||
// external-filename lines up.
|
}
|
||||||
getExpectedExternalUrl: (s) => {
|
|
||||||
if (!s.DeliveryUrl) return undefined;
|
// Fallback - refresh player
|
||||||
if (offline) return s.DeliveryUrl;
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return api?.basePath
|
|
||||||
? `${api.basePath}${s.DeliveryUrl}`
|
|
||||||
: undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -367,29 +374,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||||
// at the front (unshifted), local downloaded subs at the end.
|
setAudioTracks(audio);
|
||||||
commitSubtitleTracks(subs);
|
|
||||||
commitAudioTracks(audio);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
return () => {
|
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
|
||||||
// API is ready so online externals don't resolve with undefined.
|
|
||||||
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
|
||||||
// decision — rebuild when it flips so we refresh the stream when we should.
|
|
||||||
}, [
|
|
||||||
tracksReady,
|
|
||||||
mediaSource,
|
|
||||||
offline,
|
|
||||||
downloadedItem,
|
|
||||||
itemId,
|
|
||||||
api?.basePath,
|
|
||||||
isCurrentSubImageBased,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "English", value: "eng" },
|
||||||
|
{ label: "Spanish", value: "spa" },
|
||||||
|
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||||
|
{ label: "Hindi", value: "hin" },
|
||||||
|
{ label: "Arabic", value: "ara" },
|
||||||
|
{ label: "French", value: "fra" },
|
||||||
|
{ label: "Russian", value: "rus" },
|
||||||
|
{ label: "Portuguese", value: "por" },
|
||||||
|
{ label: "Japanese", value: "jpn" },
|
||||||
|
{ label: "German", value: "deu" },
|
||||||
|
{ label: "Italian", value: "ita" },
|
||||||
|
{ label: "Korean", value: "kor" },
|
||||||
|
{ label: "Turkish", value: "tur" },
|
||||||
|
{ label: "Dutch", value: "nld" },
|
||||||
|
{ label: "Polish", value: "pol" },
|
||||||
|
{ label: "Vietnamese", value: "vie" },
|
||||||
|
{ label: "Thai", value: "tha" },
|
||||||
|
{ label: "Indonesian", value: "ind" },
|
||||||
|
{ label: "Greek", value: "ell" },
|
||||||
|
{ label: "Swedish", value: "swe" },
|
||||||
|
{ label: "Danish", value: "dan" },
|
||||||
|
{ label: "Norwegian", value: "nor" },
|
||||||
|
{ label: "Finnish", value: "fin" },
|
||||||
|
{ label: "Czech", value: "ces" },
|
||||||
|
{ label: "Hungarian", value: "hun" },
|
||||||
|
{ label: "Romanian", value: "ron" },
|
||||||
|
{ label: "Ukrainian", value: "ukr" },
|
||||||
|
{ label: "Hebrew", value: "heb" },
|
||||||
|
{ label: "Bengali", value: "ben" },
|
||||||
|
{ label: "Punjabi", value: "pan" },
|
||||||
|
{ label: "Tagalog", value: "tgl" },
|
||||||
|
{ label: "Swahili", value: "swa" },
|
||||||
|
{ label: "Malay", value: "msa" },
|
||||||
|
{ label: "Persian", value: "fas" },
|
||||||
|
{ label: "Urdu", value: "urd" },
|
||||||
|
];
|
||||||
@@ -3,13 +3,9 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
const MediaTypes = {
|
export default {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
|
||||||
|
|
||||||
export default MediaTypes;
|
|
||||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
|||||||
|
|
||||||
Relevant files:
|
Relevant files:
|
||||||
|
|
||||||
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
||||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||||
|
|||||||
16
eas.json
16
eas.json
@@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"bun": "1.3.14",
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"bun": "1.3.14",
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"bun": "1.3.14",
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
"bun": "1.3.14",
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
@@ -97,14 +97,6 @@
|
|||||||
"credentialsSource": "local",
|
"credentialsSource": "local",
|
||||||
"config": "ios-production.yml"
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ci": {
|
|
||||||
"extends": "production",
|
|
||||||
"autoIncrement": false
|
|
||||||
},
|
|
||||||
"ci_tv": {
|
|
||||||
"extends": "production_tv",
|
|
||||||
"autoIncrement": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!item.Id) {
|
||||||
|
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||||
|
console.error("Attempted to open a file without an ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOfflineSettings, setPlayUrl, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { openFile };
|
||||||
|
};
|
||||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
|
import {
|
||||||
|
adjustToNearBlack,
|
||||||
|
calculateTextColor,
|
||||||
|
isCloseToBlack,
|
||||||
|
itemThemeColorAtom,
|
||||||
|
} from "@/utils/atoms/primaryColor";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
|
*
|
||||||
|
* @param item - The BaseItemDto object representing the item.
|
||||||
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useImageColors = ({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api) return;
|
||||||
|
if (url) return { uri: url };
|
||||||
|
if (item)
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [api, item, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv) return;
|
||||||
|
if (disabled) return;
|
||||||
|
if (source?.uri) {
|
||||||
|
const _primary = storage.getString(`${source.uri}-primary`);
|
||||||
|
const _text = storage.getString(`${source.uri}-text`);
|
||||||
|
|
||||||
|
if (_primary && _text) {
|
||||||
|
setPrimaryColor({
|
||||||
|
primary: _primary,
|
||||||
|
text: _text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from the image
|
||||||
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: false,
|
||||||
|
})
|
||||||
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
|
let primary = "#fff";
|
||||||
|
let text = "#000";
|
||||||
|
let backup = "#fff";
|
||||||
|
|
||||||
|
// Select the appropriate color based on the platform
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
primary = colors.dominant;
|
||||||
|
backup = colors.vibrant;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
primary = colors.detail;
|
||||||
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the primary color if it's too close to black
|
||||||
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
|
setPrimaryColor({
|
||||||
|
primary,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the colors in storage
|
||||||
|
if (source.uri && primary) {
|
||||||
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
|
storage.set(`${source.uri}-text`, text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||||
|
|
||||||
|
if (isTv) return;
|
||||||
|
};
|
||||||
@@ -70,6 +70,30 @@ export const clearJellyseerrStorageData = () => {
|
|||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.remove(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JellyseerrSessionStatus =
|
||||||
|
| { valid: true }
|
||||||
|
| { valid: false; reason: "no_session" | "expired" };
|
||||||
|
|
||||||
|
export async function validateJellyseerrSession(
|
||||||
|
serverUrl: string,
|
||||||
|
): Promise<JellyseerrSessionStatus> {
|
||||||
|
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
|
||||||
|
if (!user || !cookies) {
|
||||||
|
return { valid: false, reason: "no_session" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = new JellyseerrApi(serverUrl);
|
||||||
|
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
|
||||||
|
return { valid: true };
|
||||||
|
} catch {
|
||||||
|
clearJellyseerrStorageData();
|
||||||
|
return { valid: false, reason: "expired" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
STATUS = "/status",
|
STATUS = "/status",
|
||||||
API_V1 = "/api/v1",
|
API_V1 = "/api/v1",
|
||||||
@@ -450,7 +474,8 @@ export const useJellyseerr = () => {
|
|||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
setJellyseerrUser(undefined);
|
setJellyseerrUser(undefined);
|
||||||
updateSettings({ jellyseerrServerUrl: undefined });
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
}, []);
|
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
|
|||||||
@@ -4,42 +4,41 @@ import { Platform } from "react-native";
|
|||||||
import {
|
import {
|
||||||
disableTVMenuKeyInterception,
|
disableTVMenuKeyInterception,
|
||||||
enableTVMenuKeyInterception,
|
enableTVMenuKeyInterception,
|
||||||
useTVBackPress,
|
|
||||||
} from "./useTVBackPress";
|
} from "./useTVBackPress";
|
||||||
|
|
||||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||||
|
|
||||||
/** All tab route names used in the bottom tab navigator. */
|
|
||||||
export const TAB_ROUTES = [
|
|
||||||
"(home)",
|
|
||||||
"(search)",
|
|
||||||
"(favorites)",
|
|
||||||
"(libraries)",
|
|
||||||
"(watchlists)",
|
|
||||||
"(custom-links)",
|
|
||||||
"(settings)",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type TabRoute = (typeof TAB_ROUTES)[number];
|
|
||||||
|
|
||||||
/** Check if a segment string is a tab route. */
|
|
||||||
export function isTabRoute(s: string): s is TabRoute {
|
|
||||||
return (TAB_ROUTES as readonly string[]).includes(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're at the root of a tab
|
* Check if we're at the root of a tab
|
||||||
*/
|
*/
|
||||||
function isAtTabRoot(segments: string[]): boolean {
|
function isAtTabRoot(segments: string[]): boolean {
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
return isTabRoute(lastSegment) || lastSegment === "index";
|
const tabNames = [
|
||||||
|
"(home)",
|
||||||
|
"(search)",
|
||||||
|
"(favorites)",
|
||||||
|
"(libraries)",
|
||||||
|
"(watchlists)",
|
||||||
|
"(settings)",
|
||||||
|
"(custom-links)",
|
||||||
|
];
|
||||||
|
return tabNames.includes(lastSegment) || lastSegment === "index";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current tab name from segments
|
* Get the current tab name from segments
|
||||||
*/
|
*/
|
||||||
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
function getCurrentTab(segments: string[]): string | undefined {
|
||||||
return segments.find(isTabRoute);
|
return segments.find(
|
||||||
|
(s) =>
|
||||||
|
s === "(home)" ||
|
||||||
|
s === "(search)" ||
|
||||||
|
s === "(favorites)" ||
|
||||||
|
s === "(libraries)" ||
|
||||||
|
s === "(watchlists)" ||
|
||||||
|
s === "(settings)" ||
|
||||||
|
s === "(custom-links)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
|
|||||||
export function useTVHomeBackHandler() {
|
export function useTVHomeBackHandler() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
|
// Get current state
|
||||||
const currentTab = getCurrentTab(segments);
|
const currentTab = getCurrentTab(segments);
|
||||||
const atTabRoot = isAtTabRoot(segments);
|
const atTabRoot = isAtTabRoot(segments);
|
||||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||||
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
|
|||||||
enableTVMenuKeyInterception();
|
enableTVMenuKeyInterception();
|
||||||
}, [isOnHomeRoot]);
|
}, [isOnHomeRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
|
||||||
*
|
|
||||||
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
|
||||||
* built-in tab-level back handling — pressing back at a tab root would pop the
|
|
||||||
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
|
||||||
* to Home instead.
|
|
||||||
*/
|
|
||||||
export function useTVTabRootBackHandler(
|
|
||||||
onNavigateHome: () => void,
|
|
||||||
isAtTabRoot: boolean,
|
|
||||||
currentTab: string | undefined,
|
|
||||||
) {
|
|
||||||
useTVBackPress(() => {
|
|
||||||
if (!Platform.isTV || Platform.OS !== "android") return false;
|
|
||||||
if (!isAtTabRoot || currentTab === "(home)") return false;
|
|
||||||
onNavigateHome();
|
|
||||||
return true;
|
|
||||||
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
|||||||
const fetchSSID = useCallback(async () => {
|
const fetchSSID = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
const result = await getSSID();
|
const result = await getSSID();
|
||||||
|
console.log("[WiFi Debug] Native module SSID:", result);
|
||||||
setSSID(result);
|
setSSID(result);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<service
|
<service
|
||||||
android:name=".DownloadService"
|
android:name=".DownloadService"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import android.content.pm.ServiceInfo
|
|||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@@ -28,7 +27,6 @@ class DownloadService : Service() {
|
|||||||
private var currentDownloadTitle = "Preparing download..."
|
private var currentDownloadTitle = "Preparing download..."
|
||||||
private var currentProgress = 0
|
private var currentProgress = 0
|
||||||
private var isForegroundStarted = false
|
private var isForegroundStarted = false
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
|
|
||||||
inner class DownloadServiceBinder : Binder() {
|
inner class DownloadServiceBinder : Binder() {
|
||||||
fun getService(): DownloadService = this@DownloadService
|
fun getService(): DownloadService = this@DownloadService
|
||||||
@@ -38,12 +36,6 @@ class DownloadService : Service() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d(TAG, "DownloadService created")
|
Log.d(TAG, "DownloadService created")
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
val pm = getSystemService(PowerManager::class.java)
|
|
||||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
|
|
||||||
wakeLock?.acquire()
|
|
||||||
|
|
||||||
Log.d(TAG, "Wake lock acquired")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder {
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
@@ -101,8 +93,6 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
wakeLock?.let { if (it.isHeld) it.release() }
|
|
||||||
Log.d(TAG, "Wake lock released")
|
|
||||||
Log.d(TAG, "DownloadService destroyed")
|
Log.d(TAG, "DownloadService destroyed")
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// libmpv from Maven Central
|
// libmpv from Maven Central
|
||||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
Binary file not shown.
@@ -3,14 +3,13 @@ package expo.modules.mpvplayer
|
|||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.content.res.AssetManager
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.system.Os
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPV renderer that wraps libmpv for video playback.
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
@@ -36,30 +35,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
|
|
||||||
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
|
|
||||||
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
|
|
||||||
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
|
|
||||||
* → ANR. We force software decoding here.
|
|
||||||
*
|
|
||||||
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
|
|
||||||
* false positive would needlessly drop shipping hardware to software decoding. The
|
|
||||||
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
|
|
||||||
* emulator build fingerprint, none of which appear on real devices.
|
|
||||||
*/
|
|
||||||
private fun isEmulator(): Boolean {
|
|
||||||
val hardware = Build.HARDWARE.lowercase()
|
|
||||||
if (hardware == "goldfish" || hardware == "ranchu") return true
|
|
||||||
|
|
||||||
val product = Build.PRODUCT
|
|
||||||
if (product == "sdk" || product.startsWith("sdk_")) return true
|
|
||||||
|
|
||||||
val fingerprint = Build.FINGERPRINT
|
|
||||||
return fingerprint.startsWith("generic") ||
|
|
||||||
fingerprint.contains("emulator", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Delegate {
|
interface Delegate {
|
||||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||||
fun onPauseChanged(isPaused: Boolean)
|
fun onPauseChanged(isPaused: Boolean)
|
||||||
@@ -76,15 +51,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
private var surface: Surface? = null
|
private var surface: Surface? = null
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
private var isStopping = false
|
||||||
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
|
||||||
// player screen gets a fresh mpv handle and drops the reference on stop.
|
|
||||||
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
|
||||||
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
|
||||||
// so we mirror Findroid and let the JVM GC + native finalization path
|
|
||||||
// reclaim resources. Only one player is alive at a time in this app.
|
|
||||||
private var mpv: MPVLib? = null
|
|
||||||
|
|
||||||
// Cached state
|
// Cached state
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
private var cachedDuration: Double = 0.0
|
private var cachedDuration: Double = 0.0
|
||||||
@@ -144,108 +112,100 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun start(voDriver: String = "gpu-next") {
|
fun start(voDriver: String = "gpu-next") {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Per-instance handle — see class-level comment. Each player gets
|
MPVLib.create(context)
|
||||||
// its own mpv; we drop the reference in stop().
|
MPVLib.addObserver(this)
|
||||||
val mpv = MPVLib.create(context)
|
|
||||||
this.mpv = mpv
|
/**
|
||||||
mpv.addObserver(this)
|
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||||
|
*
|
||||||
// Resolved once — TV gets the memory-pressure customizations
|
* Technical Background:
|
||||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
* ====================
|
||||||
// audio-buffer) that would be counterproductive on higher-RAM
|
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
||||||
// those come from user settings via load().
|
* even when subtitle tracks are properly detected and loaded.
|
||||||
val isTV = isTvDevice()
|
*
|
||||||
|
* Why This Is Necessary:
|
||||||
// mpv config directory — used by the config-dir option below and
|
* =====================
|
||||||
// as XDG_CONFIG_HOME for fontconfig.
|
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
||||||
|
* mpv cannot access them directly due to sandboxing and library isolation.
|
||||||
|
*
|
||||||
|
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
||||||
|
* configured directory, mpv either:
|
||||||
|
* - Fails silently (subtitles don't appear)
|
||||||
|
* - Falls back to a default font that may not support the required character set
|
||||||
|
* - Crashes or produces rendering errors
|
||||||
|
*
|
||||||
|
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
||||||
|
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
||||||
|
*
|
||||||
|
* Reference:
|
||||||
|
* =========
|
||||||
|
* This workaround is documented in the mpv-android project:
|
||||||
|
* https://github.com/mpv-android/mpv-android/issues/96
|
||||||
|
*
|
||||||
|
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
||||||
|
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
||||||
|
*/
|
||||||
|
// Create mpv config directory and copy font files
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
|
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||||
|
// This needs to be named `subfont.ttf` else it won't work
|
||||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
arrayOf("subfont.ttf").forEach { fileName ->
|
||||||
// persists its font index across runs instead of re-walking
|
val file = File(mpvDir, fileName)
|
||||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
if (file.exists()) return@forEach
|
||||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
context.assets
|
||||||
// holds onto. Without this we see "No usable fontconfig
|
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||||
// configuration file found, using fallback" on every re-init.
|
.copyTo(FileOutputStream(file))
|
||||||
try {
|
|
||||||
val cacheDir = context.cacheDir.absolutePath
|
|
||||||
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
|
||||||
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
|
||||||
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
|
||||||
Os.setenv("HOME", configDir, true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
|
||||||
}
|
}
|
||||||
|
MPVLib.setOptionString("config", "yes")
|
||||||
mpv?.setOptionString("config", "yes")
|
MPVLib.setOptionString("config-dir", mpvDir.path)
|
||||||
mpv?.setOptionString("config-dir", mpvDir.path)
|
|
||||||
|
|
||||||
// Configure mpv options before initialization (based on Findroid)
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
this.voDriver = voDriver
|
this.voDriver = voDriver
|
||||||
mpv?.setOptionString("vo", voDriver)
|
MPVLib.setOptionString("vo", voDriver)
|
||||||
mpv?.setOptionString("gpu-context", "android")
|
MPVLib.setOptionString("gpu-context", "android")
|
||||||
mpv?.setOptionString("opengl-es", "yes")
|
MPVLib.setOptionString("opengl-es", "yes")
|
||||||
|
|
||||||
// Hardware decoder codecs (shared)
|
// Hardware video decoding
|
||||||
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
// TV: zero-copy (mediacodec) for better performance on low-power devices
|
||||||
|
// Mobile: copy mode (mediacodec-copy) for better compatibility
|
||||||
// Pause on initial cache fill (shared default). The actual
|
val isTV = isTvDevice()
|
||||||
// cache mode, cache-secs, and demuxer cache sizes come from
|
if (isTV) {
|
||||||
// user preferences and are applied per-load in load().
|
MPVLib.setOptionString("hwdec", "mediacodec")
|
||||||
mpv?.setOptionString("cache-pause-initial", "yes")
|
MPVLib.setOptionString("profile", "fast")
|
||||||
|
} else {
|
||||||
// Hardware decode path + TV-only memory options. Demuxer cache
|
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||||
// sizes and cache-secs are NOT set here — they come from user
|
|
||||||
// preferences via load().
|
|
||||||
// - Emulator: software decode. Its MediaCodec can't bind an
|
|
||||||
// output surface (surface 0x0); HEVC then fails cleanly and
|
|
||||||
// mpv auto-falls-back to software, but H.264 "opens"
|
|
||||||
// deceptively and wedges the core with no fallback (black
|
|
||||||
// video, then any command — seek/pause — deadlocks the UI
|
|
||||||
// thread → ANR). hwdec=no makes every codec render via the
|
|
||||||
// gpu-next VO. Real devices unaffected.
|
|
||||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
|
||||||
// low-power devices) + fast profile.
|
|
||||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
|
||||||
when {
|
|
||||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
|
||||||
isTV -> {
|
|
||||||
mpv?.setOptionString("hwdec", "mediacodec")
|
|
||||||
mpv?.setOptionString("profile", "fast")
|
|
||||||
// Don't retain already-played content for backward
|
|
||||||
// seeking over a network source — Jellyfin can re-fetch
|
|
||||||
// on demand. Saves up to ~30 MiB on long seeks and
|
|
||||||
// reduces swap pressure.
|
|
||||||
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
|
||||||
// Larger audio buffer to absorb page-fault stalls
|
|
||||||
// (default ~0.2s). Cheap insurance against the audio
|
|
||||||
// underruns that happen when the kernel is swap-thrashing.
|
|
||||||
mpv?.setOptionString("audio-buffer", "0.5")
|
|
||||||
}
|
|
||||||
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
|
||||||
}
|
}
|
||||||
|
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||||
|
|
||||||
|
// Cache settings for better network streaming
|
||||||
|
MPVLib.setOptionString("cache", "yes")
|
||||||
|
MPVLib.setOptionString("cache-pause-initial", "yes")
|
||||||
|
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
||||||
|
|
||||||
// Seeking optimization - faster seeking at the cost of less precision
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
// Use keyframe seeking by default (much faster for network streams)
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
mpv?.setOptionString("hr-seek", "no")
|
MPVLib.setOptionString("hr-seek", "no")
|
||||||
// Drop frames during seeking for faster response
|
// Drop frames during seeking for faster response
|
||||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
// Subtitle settings
|
// Subtitle settings
|
||||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||||
mpv?.setOptionString("sub-use-margins", "no")
|
MPVLib.setOptionString("sub-use-margins", "no")
|
||||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||||
mpv?.setOptionString("subs-fallback", "yes")
|
MPVLib.setOptionString("subs-fallback", "yes")
|
||||||
|
|
||||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||||
mpv?.setOptionString("force-window", "no")
|
MPVLib.setOptionString("force-window", "no")
|
||||||
mpv?.setOptionString("keep-open", "always")
|
MPVLib.setOptionString("keep-open", "always")
|
||||||
|
|
||||||
mpv.initialize()
|
MPVLib.initialize()
|
||||||
|
|
||||||
// Observe properties
|
// Observe properties
|
||||||
observeProperties()
|
observeProperties()
|
||||||
|
|
||||||
@@ -258,68 +218,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
if (isStopping) return
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
|
|
||||||
|
isStopping = true
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
val m = mpv
|
try {
|
||||||
mpv = null
|
MPVLib.removeObserver(this)
|
||||||
|
MPVLib.detachSurface()
|
||||||
// Clear cached media state on the main thread so the next player
|
MPVLib.destroy()
|
||||||
// screen doesn't observe stale position/duration values during the
|
} catch (e: Exception) {
|
||||||
// (async) teardown below.
|
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||||
currentUrl = null
|
}
|
||||||
currentHeaders = null
|
|
||||||
pendingExternalSubtitles = emptyList()
|
isStopping = false
|
||||||
initialSubtitleId = null
|
|
||||||
initialAudioId = null
|
|
||||||
cachedPosition = 0.0
|
|
||||||
cachedDuration = 0.0
|
|
||||||
cachedCacheSeconds = 0.0
|
|
||||||
|
|
||||||
if (m == null) return
|
|
||||||
|
|
||||||
// Teardown runs on a background daemon thread. mpv's "stop" command
|
|
||||||
// flushes the demuxer queue and releases the MediaCodec hardware
|
|
||||||
// decoder — synchronous JNI work that can block for hundreds of ms
|
|
||||||
// on TV hardware. Running it on the main thread produced a visible
|
|
||||||
// delay/stutter between pressing "exit" and the confirm alert
|
|
||||||
// appearing. The local `m` keeps the MPVLib instance alive for the
|
|
||||||
// lifetime of this thread even though we've already nulled `mpv`.
|
|
||||||
Thread {
|
|
||||||
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
|
||||||
// force-window=yes, mpv tears down the decoder at stop time but
|
|
||||||
// tries to keep the VO alive — which fires an internal
|
|
||||||
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
|
||||||
// reconfig path crashes with "Missing surface pointer" because we
|
|
||||||
// detach the Surface below before mpv's worker reaches the
|
|
||||||
// reconfig step (command() is async). Setting force-window=no
|
|
||||||
// first makes mpv tear VO down cleanly instead of attempting a
|
|
||||||
// doomed re-init, eliminating the fatal VO error and the
|
|
||||||
// "playback won't restart" aftermath.
|
|
||||||
try {
|
|
||||||
m.setOptionString("force-window", "no")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Stop playback — flushes demuxer queue and signals MediaCodec
|
|
||||||
// to release its hardware decoders. This is the bulk of what
|
|
||||||
// we can reclaim without calling destroy().
|
|
||||||
m.command(arrayOf("stop"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
m.removeObserver(this)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
m.detachSurface()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
|
||||||
}
|
|
||||||
}.also { it.isDaemon = true }.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,10 +247,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = surface
|
this.surface = surface
|
||||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.attachSurface(surface)
|
MPVLib.attachSurface(surface)
|
||||||
mpv?.setOptionString("force-window", "yes")
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
// Read back vo to confirm it's still active
|
// Read back vo to confirm it's still active
|
||||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,8 +270,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = null
|
this.surface = null
|
||||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.detachSurface()
|
MPVLib.detachSurface()
|
||||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +282,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
@@ -385,9 +298,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
val pos = cachedPosition
|
val pos = cachedPosition
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
mpv?.command(arrayOf("frame-step"))
|
MPVLib.command(arrayOf("frame-step"))
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,43 +310,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
startPosition: Double? = null,
|
startPosition: Double? = null,
|
||||||
externalSubtitles: List<String>? = null,
|
externalSubtitles: List<String>? = null,
|
||||||
initialSubtitleId: Int? = null,
|
initialSubtitleId: Int? = null,
|
||||||
initialAudioId: Int? = null,
|
initialAudioId: Int? = null
|
||||||
cacheEnabled: String? = null,
|
|
||||||
cacheSeconds: Int? = null,
|
|
||||||
demuxerMaxBytes: Int? = null,
|
|
||||||
demuxerMaxBackBytes: Int? = null
|
|
||||||
) {
|
) {
|
||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||||
this.initialSubtitleId = initialSubtitleId
|
this.initialSubtitleId = initialSubtitleId
|
||||||
this.initialAudioId = initialAudioId
|
this.initialAudioId = initialAudioId
|
||||||
|
|
||||||
_isLoading = true
|
_isLoading = true
|
||||||
isReadyToSeek = false
|
isReadyToSeek = false
|
||||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
|
||||||
// Stop previous playback
|
// Stop previous playback
|
||||||
mpv?.command(arrayOf("stop"))
|
MPVLib.command(arrayOf("stop"))
|
||||||
|
|
||||||
// Set HTTP headers if provided
|
// Set HTTP headers if provided
|
||||||
updateHttpHeaders(headers)
|
updateHttpHeaders(headers)
|
||||||
|
|
||||||
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
|
||||||
// These override the conservative defaults applied in start() so the
|
|
||||||
// TV/mobile settings screen actually takes effect on Android.
|
|
||||||
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
|
||||||
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
|
||||||
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
|
||||||
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
|
||||||
|
|
||||||
// Set start position. mpv's time parser requires '.' as the decimal
|
// Set start position
|
||||||
// separator; use Locale.US so devices with other default locales
|
|
||||||
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
|
||||||
if (startPosition != null && startPosition > 0) {
|
if (startPosition != null && startPosition > 0) {
|
||||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||||
} else {
|
} else {
|
||||||
mpv?.setPropertyString("start", "0")
|
MPVLib.setPropertyString("start", "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial audio track if specified
|
// Set initial audio track if specified
|
||||||
@@ -453,7 +352,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load the file
|
// Load the file
|
||||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadCurrentItem() {
|
fun reloadCurrentItem() {
|
||||||
@@ -469,29 +368,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||||
mpv?.setPropertyString("http-header-fields", headerString)
|
MPVLib.setPropertyString("http-header-fields", headerString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeProperties() {
|
private fun observeProperties() {
|
||||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||||
// Video dimensions for PiP aspect ratio
|
// Video dimensions for PiP aspect ratio
|
||||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
mpv?.setPropertyBoolean("pause", false)
|
MPVLib.setPropertyBoolean("pause", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
mpv?.setPropertyBoolean("pause", true)
|
MPVLib.setPropertyBoolean("pause", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePause() {
|
fun togglePause() {
|
||||||
@@ -501,22 +400,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun seekTo(seconds: Double) {
|
fun seekTo(seconds: Double) {
|
||||||
val clamped = maxOf(0.0, seconds)
|
val clamped = maxOf(0.0, seconds)
|
||||||
cachedPosition = clamped
|
cachedPosition = clamped
|
||||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(seconds: Double) {
|
fun seekBy(seconds: Double) {
|
||||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||||
cachedPosition = newPosition
|
cachedPosition = newPosition
|
||||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
_playbackSpeed = speed
|
_playbackSpeed = speed
|
||||||
mpv?.setPropertyDouble("speed", speed)
|
MPVLib.setPropertyDouble("speed", speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
@@ -524,96 +423,85 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "sub") continue
|
if (trackType != "sub") continue
|
||||||
|
|
||||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
|
||||||
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
// Identity fields used to map a Jellyfin subtitle to the real track
|
|
||||||
// (instead of fragile positional counting). `external` + `external-filename`
|
|
||||||
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
|
||||||
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
|
|
||||||
track["external"] = external
|
|
||||||
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
|
|
||||||
track["externalFilename"] = it
|
|
||||||
}
|
|
||||||
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
|
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||||
if (trackId < 0) {
|
if (trackId < 0) {
|
||||||
mpv?.setPropertyString("sid", "no")
|
MPVLib.setPropertyString("sid", "no")
|
||||||
} else {
|
} else {
|
||||||
mpv?.setPropertyInt("sid", trackId)
|
MPVLib.setPropertyInt("sid", trackId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
mpv?.setPropertyString("sid", "no")
|
MPVLib.setPropertyString("sid", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return mpv?.getPropertyInt("sid") ?: 0
|
return MPVLib.getPropertyInt("sid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
mpv?.command(arrayOf("sub-add", url, flag))
|
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
mpv?.setPropertyInt("sub-pos", position)
|
MPVLib.setPropertyInt("sub-pos", position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
mpv?.setPropertyDouble("sub-scale", scale)
|
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
mpv?.setPropertyString("sub-align-x", alignment)
|
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
mpv?.setPropertyString("sub-align-y", alignment)
|
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
mpv?.setPropertyInt("sub-font-size", size)
|
MPVLib.setPropertyInt("sub-font-size", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBorderStyle(style: String) {
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
mpv?.setPropertyString("sub-border-style", style)
|
MPVLib.setPropertyString("sub-border-style", style)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBackgroundColor(color: String) {
|
fun setSubtitleBackgroundColor(color: String) {
|
||||||
mpv?.setPropertyString("sub-back-color", color)
|
MPVLib.setPropertyString("sub-back-color", color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAssOverride(mode: String) {
|
fun setSubtitleAssOverride(mode: String) {
|
||||||
mpv?.setPropertyString("sub-ass-override", mode)
|
MPVLib.setPropertyString("sub-ass-override", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
@@ -621,25 +509,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "audio") continue
|
if (trackType != "audio") continue
|
||||||
|
|
||||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
||||||
if (channels != null && channels > 0) {
|
if (channels != null && channels > 0) {
|
||||||
track["channels"] = channels
|
track["channels"] = channels
|
||||||
}
|
}
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -650,11 +538,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||||
mpv?.setPropertyInt("aid", trackId)
|
MPVLib.setPropertyInt("aid", trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return mpv?.getPropertyInt("aid") ?: 0
|
return MPVLib.getPropertyInt("aid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Scaling
|
// MARK: - Video Scaling
|
||||||
@@ -663,7 +551,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Technical Info
|
// MARK: - Technical Info
|
||||||
@@ -672,79 +560,58 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
val info = mutableMapOf<String, Any>()
|
val info = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
// Video dimensions
|
// Video dimensions
|
||||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||||
info["videoWidth"] = it
|
info["videoWidth"] = it
|
||||||
}
|
}
|
||||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||||
info["videoHeight"] = it
|
info["videoHeight"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video codec
|
// Video codec
|
||||||
mpv?.getPropertyString("video-format")?.let {
|
MPVLib.getPropertyString("video-format")?.let {
|
||||||
info["videoCodec"] = it
|
info["videoCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio codec
|
// Audio codec
|
||||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||||
info["audioCodec"] = it
|
info["audioCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// FPS (container fps)
|
// FPS (container fps)
|
||||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||||
info["fps"] = it
|
info["fps"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video bitrate (bits per second)
|
// Video bitrate (bits per second)
|
||||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["videoBitrate"] = it
|
info["videoBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio bitrate (bits per second)
|
// Audio bitrate (bits per second)
|
||||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["audioBitrate"] = it
|
info["audioBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demuxer cache duration (seconds of video buffered)
|
// Demuxer cache duration (seconds of video buffered)
|
||||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||||
info["cacheSeconds"] = it
|
info["cacheSeconds"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured cache limits — read back from mpv to confirm user
|
|
||||||
// settings actually took effect. mpv stores byte sizes as int64
|
|
||||||
// (bytes); convert to MiB for display.
|
|
||||||
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
|
||||||
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
|
||||||
}
|
|
||||||
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
|
||||||
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
|
||||||
}
|
|
||||||
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
|
||||||
info["cacheSecsLimit"] = secs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||||
info["droppedFrames"] = it
|
info["droppedFrames"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||||
mpv?.getPropertyString("vo")?.let {
|
MPVLib.getPropertyString("vo")?.let {
|
||||||
info["voDriver"] = it
|
info["voDriver"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active hardware decoder.
|
// Active hardware decoder
|
||||||
// hwdec-current yields e.g. "mediacodec",
|
MPVLib.getPropertyString("hwdec-active")?.let {
|
||||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
|
||||||
mpv?.getPropertyString("hwdec-current")?.let {
|
|
||||||
info["hwdec"] = it
|
info["hwdec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimated video output fps (renderer-side, after filtering).
|
|
||||||
// Useful for diagnosing display/pipeline drops vs container fps.
|
|
||||||
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
|
||||||
info["estimatedVfFps"] = it
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +704,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||||
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
||||||
}
|
}
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||||
*
|
* This provides a consistent interface for the rest of the app.
|
||||||
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
|
||||||
* a fresh, independent handle. Each player creates its own MPVLib instance
|
|
||||||
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
|
||||||
* call `LibMPV.destroy()` — its native implementation has an internal
|
|
||||||
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
|
||||||
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
|
||||||
* handle lives in process-global state until exit) is strictly safer than
|
|
||||||
* crashing.
|
|
||||||
*
|
|
||||||
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
|
||||||
* stays allocated until the next player's allocation displaces it in scudo's
|
|
||||||
* arena. On a TV app where the player is the dominant memory consumer and
|
|
||||||
* only one player is alive at a time, this is acceptable.
|
|
||||||
*/
|
*/
|
||||||
class MPVLib private constructor(private val instance: LibMPV) {
|
object MPVLib {
|
||||||
|
private const val TAG = "MPVLib"
|
||||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
|
||||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
private var initialized = false
|
||||||
|
|
||||||
|
// Event observer interface
|
||||||
interface EventObserver {
|
interface EventObserver {
|
||||||
fun eventProperty(property: String)
|
fun eventProperty(property: String)
|
||||||
fun eventProperty(property: String, value: Long)
|
fun eventProperty(property: String, value: Long)
|
||||||
@@ -32,144 +23,198 @@ class MPVLib private constructor(private val instance: LibMPV) {
|
|||||||
fun eventProperty(property: String, value: Double)
|
fun eventProperty(property: String, value: Double)
|
||||||
fun event(eventId: Int)
|
fun event(eventId: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val observers = mutableListOf<EventObserver>()
|
private val observers = mutableListOf<EventObserver>()
|
||||||
|
|
||||||
// Library event observer that forwards LibMPV callbacks to our observers.
|
// Library event observer that forwards to our observers
|
||||||
private val libObserver = object : LibMPV.EventObserver {
|
private val libObserver = object : LibMPV.EventObserver {
|
||||||
override fun eventProperty(property: String) =
|
override fun eventProperty(property: String) {
|
||||||
dispatch { it.eventProperty(property) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Long) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Boolean) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: String) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Double) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun event(eventId: Int) =
|
|
||||||
dispatch { it.event(eventId) }
|
|
||||||
|
|
||||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
|
||||||
synchronized(observers) {
|
synchronized(observers) {
|
||||||
observers.forEach(block)
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Long) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Boolean) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: String) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Double) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun event(eventId: Int) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.event(eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addObserver(observer: EventObserver) {
|
fun addObserver(observer: EventObserver) {
|
||||||
synchronized(observers) { observers.add(observer) }
|
synchronized(observers) {
|
||||||
}
|
observers.add(observer)
|
||||||
|
|
||||||
fun removeObserver(observer: EventObserver) {
|
|
||||||
synchronized(observers) { observers.remove(observer) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
instance.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attachSurface(surface: android.view.Surface) {
|
|
||||||
instance.attachSurface(surface)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun detachSurface() {
|
|
||||||
instance.detachSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun command(cmd: Array<String>) {
|
|
||||||
instance.command(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOptionString(name: String, value: String): Int {
|
|
||||||
return instance.setOptionString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyInt(name: String): Int? = try {
|
|
||||||
instance.getPropertyInt(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyDouble(name: String): Double? = try {
|
|
||||||
instance.getPropertyDouble(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyBoolean(name: String): Boolean? = try {
|
|
||||||
instance.getPropertyBoolean(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyString(name: String): String? = try {
|
|
||||||
instance.getPropertyString(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun setPropertyInt(name: String, value: Int) {
|
|
||||||
instance.setPropertyInt(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyDouble(name: String, value: Double) {
|
|
||||||
instance.setPropertyDouble(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
|
||||||
instance.setPropertyBoolean(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyString(name: String, value: String) {
|
|
||||||
instance.setPropertyString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeProperty(name: String, format: Int) {
|
|
||||||
instance.observeProperty(name, format)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a fresh mpv handle. Each call returns an independent instance —
|
|
||||||
* do not share across players. Attach exactly one [EventObserver] per
|
|
||||||
* player via [addObserver].
|
|
||||||
*/
|
|
||||||
fun create(context: Context): MPVLib {
|
|
||||||
val lib = LibMPV.create(context)
|
|
||||||
?: throw IllegalStateException("LibMPV.create returned null")
|
|
||||||
val wrapper = MPVLib(lib)
|
|
||||||
// The libObserver is attached for the lifetime of this MPVLib
|
|
||||||
// instance and forwards every LibMPV callback to our observers
|
|
||||||
// list. Player-specific observers are added/removed via
|
|
||||||
// addObserver/removeObserver.
|
|
||||||
lib.addObserver(wrapper.libObserver)
|
|
||||||
return wrapper
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// MPV Event IDs (kept here so observers can reference them without
|
|
||||||
// holding a reference to an instance).
|
fun removeObserver(observer: EventObserver) {
|
||||||
const val MPV_EVENT_NONE = 0
|
synchronized(observers) {
|
||||||
const val MPV_EVENT_SHUTDOWN = 1
|
observers.remove(observer)
|
||||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
}
|
||||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
}
|
||||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
|
||||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
// MPV Event IDs
|
||||||
const val MPV_EVENT_START_FILE = 6
|
const val MPV_EVENT_NONE = 0
|
||||||
const val MPV_EVENT_END_FILE = 7
|
const val MPV_EVENT_SHUTDOWN = 1
|
||||||
const val MPV_EVENT_FILE_LOADED = 8
|
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||||
const val MPV_EVENT_IDLE = 11
|
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||||
const val MPV_EVENT_TICK = 14
|
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
const val MPV_EVENT_START_FILE = 6
|
||||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
const val MPV_EVENT_END_FILE = 7
|
||||||
const val MPV_EVENT_SEEK = 20
|
const val MPV_EVENT_FILE_LOADED = 8
|
||||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
const val MPV_EVENT_IDLE = 11
|
||||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
const val MPV_EVENT_TICK = 14
|
||||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||||
|
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||||
// End file reason
|
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||||
const val MPV_END_FILE_REASON_EOF = 0
|
const val MPV_EVENT_SEEK = 20
|
||||||
const val MPV_END_FILE_REASON_STOP = 2
|
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||||
const val MPV_END_FILE_REASON_QUIT = 3
|
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||||
const val MPV_END_FILE_REASON_ERROR = 4
|
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
|
||||||
|
// End file reason
|
||||||
|
const val MPV_END_FILE_REASON_EOF = 0
|
||||||
|
const val MPV_END_FILE_REASON_STOP = 2
|
||||||
|
const val MPV_END_FILE_REASON_QUIT = 3
|
||||||
|
const val MPV_END_FILE_REASON_ERROR = 4
|
||||||
|
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and initialize the MPV library
|
||||||
|
*/
|
||||||
|
fun create(context: Context, configDir: String? = null) {
|
||||||
|
if (initialized) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
LibMPV.create(context)
|
||||||
|
LibMPV.addObserver(libObserver)
|
||||||
|
initialized = true
|
||||||
|
Log.i(TAG, "libmpv created successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
LibMPV.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
if (!initialized) return
|
||||||
|
try {
|
||||||
|
LibMPV.removeObserver(libObserver)
|
||||||
|
LibMPV.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
||||||
|
}
|
||||||
|
initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isInitialized(): Boolean = initialized
|
||||||
|
|
||||||
|
fun attachSurface(surface: Surface) {
|
||||||
|
LibMPV.attachSurface(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachSurface() {
|
||||||
|
LibMPV.detachSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun command(cmd: Array<String?>) {
|
||||||
|
LibMPV.command(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOptionString(name: String, value: String): Int {
|
||||||
|
return LibMPV.setOptionString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyInt(name: String): Int? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyInt(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyDouble(name: String): Double? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyDouble(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyBoolean(name: String): Boolean? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyBoolean(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyString(name: String): String? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyString(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyInt(name: String, value: Int) {
|
||||||
|
LibMPV.setPropertyInt(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyDouble(name: String, value: Double) {
|
||||||
|
LibMPV.setPropertyDouble(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||||
|
LibMPV.setPropertyBoolean(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyString(name: String, value: String) {
|
||||||
|
LibMPV.setPropertyString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeProperty(name: String, format: Int) {
|
||||||
|
LibMPV.observeProperty(name, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ class MpvPlayerModule : Module() {
|
|||||||
if (source == null) return@Prop
|
if (source == null) return@Prop
|
||||||
|
|
||||||
val urlString = source["url"] as? String ?: return@Prop
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
// Parse cache config if provided (mirrors iOS)
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val config = VideoLoadConfig(
|
val config = VideoLoadConfig(
|
||||||
url = urlString,
|
url = urlString,
|
||||||
@@ -42,11 +38,7 @@ class MpvPlayerModule : Module() {
|
|||||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
voDriver = source["voDriver"] as? String,
|
voDriver = source["voDriver"] as? String
|
||||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
|
||||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
|
||||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
|
||||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
@@ -68,15 +60,6 @@ class MpvPlayerModule : Module() {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop playback and release the MediaCodec decoder + demuxer.
|
|
||||||
// Does not synchronously tear down the native mpv handle (see
|
|
||||||
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
|
||||||
// away from the player screen to avoid OOM during screen
|
|
||||||
// transitions on low-RAM devices.
|
|
||||||
AsyncFunction("destroy") { view: MpvPlayerView ->
|
|
||||||
view.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||||
view.seekTo(position)
|
view.seekTo(position)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
@@ -24,30 +26,15 @@ data class VideoLoadConfig(
|
|||||||
val autoplay: Boolean = true,
|
val autoplay: Boolean = true,
|
||||||
val initialSubtitleId: Int? = null,
|
val initialSubtitleId: Int? = null,
|
||||||
val initialAudioId: Int? = null,
|
val initialAudioId: Int? = null,
|
||||||
val voDriver: String? = null,
|
val voDriver: String? = null
|
||||||
val cacheEnabled: String? = null,
|
|
||||||
val cacheSeconds: Int? = null,
|
|
||||||
val demuxerMaxBytes: Int? = null,
|
|
||||||
val demuxerMaxBackBytes: Int? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
*
|
* Uses TextureView for reliable Picture-in-Picture support.
|
||||||
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
|
||||||
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
|
||||||
* app's window surface. This matches mpv-android's architecture and
|
|
||||||
* gives mpv a standalone surface.
|
|
||||||
*
|
|
||||||
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
|
||||||
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
|
||||||
* can be stale until the next layout pass. We push dimension updates to
|
|
||||||
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
|
||||||
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
|
||||||
* passes on the view itself) reaches mpv promptly.
|
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
@@ -61,7 +48,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
private var surfaceView: SurfaceView
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
@@ -72,45 +59,30 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var surfaceReady: Boolean = false
|
private var surfaceReady: Boolean = false
|
||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var activeSurface: Surface? = null
|
private var pendingSurface: Surface? = null
|
||||||
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
|
|
||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
|
private var isWaitingForPiPTransition: Boolean = false
|
||||||
|
private var isPiPSurfaceForced: Boolean = false
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// SurfaceView for video rendering. Routes the surface directly to
|
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||||
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
textureView = TextureView(context).apply {
|
||||||
// surface. TextureView composites into the app's window surface
|
|
||||||
// which is less efficient and breaks PiP transitions.
|
|
||||||
surfaceView = SurfaceView(context).apply {
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
|
surfaceTextureListener = this@MpvPlayerView
|
||||||
}
|
}
|
||||||
surfaceView.holder.addCallback(this@MpvPlayerView)
|
addView(textureView)
|
||||||
addView(surfaceView)
|
|
||||||
|
|
||||||
// Push dimension updates to mpv on every view bounds change. This
|
|
||||||
// is the primary PiP black-screen fix: entering PiP fires a layout
|
|
||||||
// pass on the SurfaceView itself, and we proactively tell mpv the
|
|
||||||
// new size so it resizes its EGL swapchain before rendering.
|
|
||||||
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
|
||||||
oldLeft, oldTop, oldRight, oldBottom ->
|
|
||||||
val w = right - left
|
|
||||||
val h = bottom - top
|
|
||||||
val oldW = oldRight - oldLeft
|
|
||||||
val oldH = oldBottom - oldTop
|
|
||||||
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
|
||||||
renderer?.updateSurfaceSize(w, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(surfaceView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -126,17 +98,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
if (isInPiP) {
|
if (isInPiP) {
|
||||||
// Post size syncs after the PiP layout settles. Two passes
|
if (!isWaitingForPiPTransition) {
|
||||||
// catch both the immediate surface re-attach and the
|
isWaitingForPiPTransition = true
|
||||||
// post-animation layout pass. Replaces the old TextureView
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
// measure/layout polling hack (forcePiPBufferSize).
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
}
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
}
|
||||||
} else {
|
} else {
|
||||||
// Restore from PiP: surface resized back to fullscreen.
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
restoreFromPiP()
|
||||||
}
|
}
|
||||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
}
|
}
|
||||||
@@ -149,7 +121,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the renderer with the given VO driver.
|
* Start the renderer with the given VO driver.
|
||||||
* Called lazily on first loadVideo so user settings are available.
|
* Called lazily on first loadVideo so the voDriver setting is available.
|
||||||
*/
|
*/
|
||||||
private fun ensureRendererStarted(voDriver: String?) {
|
private fun ensureRendererStarted(voDriver: String?) {
|
||||||
if (rendererStarted) return
|
if (rendererStarted) return
|
||||||
@@ -158,14 +130,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
|
|
||||||
// If the surface is already alive (surfaceCreated fired before
|
pendingSurface?.let { surface ->
|
||||||
// loadVideo), attach it now. With SurfaceView the surface is
|
|
||||||
// owned by the holder, so we read it from there directly rather
|
|
||||||
// than stashing it on the side.
|
|
||||||
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
|
||||||
activeSurface = surface
|
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
syncSurfaceSizeToView()
|
pendingSurface = null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
@@ -173,20 +140,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SurfaceHolder.Callback
|
// MARK: - TextureView.SurfaceTextureListener
|
||||||
|
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
val surface = holder.surface
|
this.surfaceTexture = surfaceTexture
|
||||||
|
val surface = Surface(surfaceTexture)
|
||||||
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
// The previous Surface reference is holder-owned; do NOT release
|
|
||||||
// it (SurfaceView manages its lifecycle). Just track the new one.
|
|
||||||
activeSurface = surface
|
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
// Push the actual view dimensions immediately so mpv doesn't
|
} else {
|
||||||
// render against stale full-screen geometry during PiP transitions.
|
pendingSurface = surface
|
||||||
syncSurfaceSizeToView()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -197,36 +162,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
if (width > 0 && height > 0) {
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||||
|
this.surfaceTexture = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
// Do NOT issue mpv "stop" here. Playback continues against the
|
return false // mpv manages the SurfaceTexture
|
||||||
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
|
||||||
// background/foreground), we re-attach and frames resume. This
|
|
||||||
// matches the keep-open=always setting in MPVLayerRenderer.
|
|
||||||
//
|
|
||||||
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
|
||||||
activeSurface = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||||
* Read the actual SurfaceView width/height and push them to mpv.
|
// Called every frame — no action needed, mpv drives rendering directly
|
||||||
* The PiP transition can fire surfaceCreated before the view's layout
|
|
||||||
* has settled to PiP dimensions, so we re-sync after layout passes.
|
|
||||||
*/
|
|
||||||
private fun syncSurfaceSizeToView() {
|
|
||||||
if (!surfaceReady) return
|
|
||||||
val w = surfaceView.width
|
|
||||||
val h = surfaceView.height
|
|
||||||
if (w > 0 && h > 0) {
|
|
||||||
renderer?.updateSurfaceSize(w, h)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
@@ -258,11 +207,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
startPosition = config.startPosition,
|
startPosition = config.startPosition,
|
||||||
externalSubtitles = config.externalSubtitles,
|
externalSubtitles = config.externalSubtitles,
|
||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId,
|
initialAudioId = config.initialAudioId
|
||||||
cacheEnabled = config.cacheEnabled,
|
|
||||||
cacheSeconds = config.cacheSeconds,
|
|
||||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
|
||||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
@@ -291,50 +236,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback and release decoder resources.
|
|
||||||
*
|
|
||||||
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
|
||||||
* on a background thread (flushing the demuxer and releasing the
|
|
||||||
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
|
||||||
*
|
|
||||||
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
|
||||||
* nativeDestroy has an internal use-after-free on the JNI global ref
|
|
||||||
* path, so the native mpv handle is intentionally left for the JVM GC
|
|
||||||
* / native finalizer rather than torn down synchronously. See
|
|
||||||
* [MPVLib] class doc for the full rationale.
|
|
||||||
*
|
|
||||||
* Call this BEFORE navigating away from the player screen so the
|
|
||||||
* decoder is reclaimed before the next screen (or the next episode's
|
|
||||||
* player) mounts. Otherwise Expo Router renders the new screen first
|
|
||||||
* and you briefly have two mpv instances + two 4K decoders alive —
|
|
||||||
* instant OOM on a 2 GB device.
|
|
||||||
*/
|
|
||||||
fun destroy() {
|
|
||||||
renderer?.stop()
|
|
||||||
|
|
||||||
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
|
||||||
// instance re-creates the mpv handle and re-attaches the still-live
|
|
||||||
// SurfaceView surface. Without this, rendererStarted stays true and
|
|
||||||
// ensureRendererStarted() early-returns, so renderer.start() is never
|
|
||||||
// called again — but stop() already nulled the renderer's mpv handle.
|
|
||||||
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
|
|
||||||
// against mpv == null, where every mpv?.command() (including the
|
|
||||||
// "stop" and load commands) silently no-ops, leaving a black frame.
|
|
||||||
//
|
|
||||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
|
||||||
// which call destroy() immediately before router.replace() to the
|
|
||||||
// same route — Expo Router reuses the same MpvPlayerView instance,
|
|
||||||
// so the next source load happens on this view without a remount.
|
|
||||||
//
|
|
||||||
// SurfaceView note: the surface is owned by the holder and survives
|
|
||||||
// across destroy()/loadVideo() on the same view instance. The next
|
|
||||||
// ensureRendererStarted() reads it from surfaceView.holder.surface.
|
|
||||||
rendererStarted = false
|
|
||||||
currentUrl = null
|
|
||||||
activeSurface = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
@@ -366,10 +267,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = true
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
|
// Resize buffer to match PiP window after animation settles
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||||
|
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||||
|
*/
|
||||||
|
private fun forcePiPBufferSize() {
|
||||||
|
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||||
|
|
||||||
|
val rect = Rect()
|
||||||
|
textureView.getGlobalVisibleRect(rect)
|
||||||
|
val visW = rect.width()
|
||||||
|
val visH = rect.height()
|
||||||
|
val vw = textureView.width
|
||||||
|
val vh = textureView.height
|
||||||
|
|
||||||
|
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||||
|
|
||||||
|
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||||
|
renderer?.updateSurfaceSize(visW, visH)
|
||||||
|
|
||||||
|
// Force TextureView layout to match PiP visible area.
|
||||||
|
// layoutParams alone doesn't work during PiP because the parent
|
||||||
|
// never re-lays out its children.
|
||||||
|
textureView.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||||
|
)
|
||||||
|
textureView.layout(0, 0, visW, visH)
|
||||||
|
isPiPSurfaceForced = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreFromPiP() {
|
||||||
|
if (!isPiPSurfaceForced) return
|
||||||
|
isPiPSurfaceForced = false
|
||||||
|
|
||||||
|
val lp = textureView.layoutParams
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
textureView.layoutParams = lp
|
||||||
|
textureView.requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
@@ -529,24 +479,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
/**
|
|
||||||
* Proactively tear down the player. Called from onDetachedFromWindow so
|
|
||||||
* the app releases mpv + decoder buffers when the View detaches from the
|
|
||||||
* window. The JS-facing destroy() is intentionally thinner (just
|
|
||||||
* renderer.stop()) — see this thread for why the full teardown was kept
|
|
||||||
* off the JS path.
|
|
||||||
*/
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
renderer?.delegate = null
|
surfaceTexture = null
|
||||||
|
|
||||||
// SurfaceView owns the Surface via its holder — do NOT release it.
|
|
||||||
activeSurface = null
|
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
currentUrl = null
|
|
||||||
rendererStarted = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
// Independently tracks whether the system should auto-enter PiP on home
|
|
||||||
// press. Decoupled from playbackRate so that disabling auto-enter
|
|
||||||
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
|
||||||
// state that buildPiPActions() derives from playbackRate.
|
|
||||||
private var autoEnterEnabled: Boolean = false
|
|
||||||
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
// Disable auto-enter eligibility without touching playbackRate.
|
|
||||||
// playbackRate drives the play/pause icon in buildPiPActions();
|
|
||||||
// mutating it here would cause a stale icon if PiP is re-entered
|
|
||||||
// before the next playback state callback corrects it.
|
|
||||||
autoEnterEnabled = false
|
|
||||||
isInPiPMode = false
|
isInPiPMode = false
|
||||||
pipEntryNotified = false
|
pipEntryNotified = false
|
||||||
unregisterLifecycleCallbacks()
|
unregisterLifecycleCallbacks()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity() ?: return
|
val activity = getActivity()
|
||||||
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
// Push minimal params with just auto-enter disabled. Do NOT call
|
activity.moveTaskToBack(false)
|
||||||
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
|
||||||
// setActions(), which would re-register the broadcast receiver
|
|
||||||
// (just unregistered above) and attach play/pause/skip actions to
|
|
||||||
// params being torn down. That leaves a live receiver + stale
|
|
||||||
// actions after the player has unmounted.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
try {
|
|
||||||
activity.setPictureInPictureParams(
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAutoEnterEnabled(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activity.isInPictureInPictureMode) {
|
|
||||||
activity.moveTaskToBack(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
autoEnterEnabled = rate > 0
|
|
||||||
|
|
||||||
if (rate > 0) {
|
if (rate > 0) {
|
||||||
registerLifecycleCallbacks()
|
registerLifecycleCallbacks()
|
||||||
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
builder.setActions(buildPiPActions())
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
|
|||||||
@@ -770,32 +770,12 @@ final class MPVLayerRenderer {
|
|||||||
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||||
track["lang"] = lang
|
track["lang"] = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
|
||||||
track["codec"] = codec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity fields used to map a Jellyfin subtitle to the real track
|
|
||||||
// (instead of fragile positional counting). `external` + `external-filename`
|
|
||||||
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
|
||||||
var external: Int32 = 0
|
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
|
|
||||||
track["external"] = external != 0
|
|
||||||
|
|
||||||
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
|
|
||||||
track["externalFilename"] = extFilename
|
|
||||||
}
|
|
||||||
|
|
||||||
var ffIndex: Int64 = 0
|
|
||||||
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
|
|
||||||
track["ffIndex"] = Int(ffIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected: Int32 = 0
|
var selected: Int32 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
track["selected"] = selected != 0
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,44 +1020,12 @@ final class MPVLayerRenderer {
|
|||||||
info["cacheSeconds"] = cacheSeconds
|
info["cacheSeconds"] = cacheSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured cache limits — read back from mpv to confirm user
|
|
||||||
// settings actually took effect. mpv stores byte sizes as int64
|
|
||||||
// (bytes); convert to MiB for display.
|
|
||||||
var demuxerMaxBytes: Int64 = 0
|
|
||||||
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
|
||||||
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
|
||||||
}
|
|
||||||
var demuxerMaxBackBytes: Int64 = 0
|
|
||||||
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
|
||||||
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
|
||||||
}
|
|
||||||
var cacheSecsLimit: Double = 0
|
|
||||||
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
|
||||||
info["cacheSecsLimit"] = cacheSecsLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
var droppedFrames: Int64 = 0
|
var droppedFrames: Int64 = 0
|
||||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||||
info["droppedFrames"] = Int(droppedFrames)
|
info["droppedFrames"] = Int(droppedFrames)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver
|
|
||||||
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
|
||||||
info["voDriver"] = voDriver
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active hardware decoder
|
|
||||||
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
|
||||||
info["hwdec"] = hwdec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimated video output fps (post-filter)
|
|
||||||
var estimatedVfFps: Double = 0
|
|
||||||
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
|
||||||
info["estimatedVfFps"] = estimatedVfFps
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,13 +74,7 @@ public class MpvPlayerModule: Module {
|
|||||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronously destroy mpv instance + decoder before navigating
|
|
||||||
// away from the player screen (cross-platform; matches Android).
|
|
||||||
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
|
||||||
view.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
view.seekTo(position: position)
|
view.seekTo(position: position)
|
||||||
|
|||||||
@@ -289,49 +289,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
|
||||||
* freed before the next screen mounts. Safe to call multiple times — the
|
|
||||||
* underlying renderer.stop() guards against re-entry.
|
|
||||||
*
|
|
||||||
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
|
||||||
*/
|
|
||||||
func destroy() {
|
|
||||||
renderer?.stop()
|
|
||||||
|
|
||||||
// Reset view state and re-create the mpv handle so a subsequent
|
|
||||||
// loadVideo() on the SAME view instance can actually load.
|
|
||||||
// Without this, stop() leaves renderer.mpv == nil, and the next
|
|
||||||
// loadVideo(config:) calls renderer.load() which early-returns
|
|
||||||
// at `guard let handle = self.mpv else { return }` — but only
|
|
||||||
// after flipping isLoading = true and dispatching the loading
|
|
||||||
// delegate callback, so the JS layer is stuck in a perpetual
|
|
||||||
// "loading" state with no actual playback.
|
|
||||||
//
|
|
||||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
|
||||||
// which call destroy() immediately before router.replace() to
|
|
||||||
// the same route — Expo Router reuses the same MpvPlayerView
|
|
||||||
// instance, so the next `source` prop update arrives on this
|
|
||||||
// view without a remount. setupView() is otherwise the only
|
|
||||||
// place start() is called, so without re-starting here the
|
|
||||||
// renderer stays dead until the whole view is unmounted and
|
|
||||||
// recreated.
|
|
||||||
//
|
|
||||||
// start() is idempotent (`guard !isRunning else { return }`)
|
|
||||||
// and stop() has already nulled mpv synchronously before
|
|
||||||
// dispatching the async mpv_terminate_destroy, so creating a
|
|
||||||
// fresh handle here is safe even while the old handle's
|
|
||||||
// teardown is still in flight on a background queue (libmpv
|
|
||||||
// handles are independent).
|
|
||||||
currentURL = nil
|
|
||||||
intendedPlayState = false
|
|
||||||
do {
|
|
||||||
try renderer?.start()
|
|
||||||
} catch {
|
|
||||||
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
|
|||||||
@@ -89,14 +89,6 @@ export type MpvPlayerViewProps = {
|
|||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
/**
|
|
||||||
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
|
||||||
* Call before navigating away from the player screen so memory is
|
|
||||||
* freed before the next screen mounts. Safe to call multiple times.
|
|
||||||
*/
|
|
||||||
destroy: () => Promise<void>;
|
|
||||||
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
|
||||||
// stop: () => Promise<void>;
|
|
||||||
seekTo: (position: number) => Promise<void>;
|
seekTo: (position: number) => Promise<void>;
|
||||||
seekBy: (offset: number) => Promise<void>;
|
seekBy: (offset: number) => Promise<void>;
|
||||||
setSpeed: (speed: number) => Promise<void>;
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
@@ -141,14 +133,6 @@ export type SubtitleTrack = {
|
|||||||
id: number;
|
id: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
|
|
||||||
codec?: string;
|
|
||||||
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
|
|
||||||
external?: boolean;
|
|
||||||
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
|
||||||
externalFilename?: string;
|
|
||||||
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
|
|
||||||
ffIndex?: number;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,17 +154,9 @@ export type TechnicalInfo = {
|
|||||||
videoBitrate?: number;
|
videoBitrate?: number;
|
||||||
audioBitrate?: number;
|
audioBitrate?: number;
|
||||||
cacheSeconds?: number;
|
cacheSeconds?: number;
|
||||||
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
|
||||||
demuxerMaxBytes?: number;
|
|
||||||
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
|
||||||
demuxerMaxBackBytes?: number;
|
|
||||||
/** Configured cache-secs floor, read back from mpv */
|
|
||||||
cacheSecsLimit?: number;
|
|
||||||
droppedFrames?: number;
|
droppedFrames?: number;
|
||||||
/** Active video output driver (read from MPV at runtime) */
|
/** Active video output driver (read from MPV at runtime) */
|
||||||
voDriver?: string;
|
voDriver?: string;
|
||||||
/** Active hardware decoder (read from MPV at runtime) */
|
/** Active hardware decoder (read from MPV at runtime) */
|
||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
|
||||||
estimatedVfFps?: number;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
pause: async () => {
|
pause: async () => {
|
||||||
await nativeRef.current?.pause();
|
await nativeRef.current?.pause();
|
||||||
},
|
},
|
||||||
destroy: async () => {
|
|
||||||
await nativeRef.current?.destroy();
|
|
||||||
},
|
|
||||||
seekTo: async (position: number) => {
|
seekTo: async (position: number) => {
|
||||||
await nativeRef.current?.seekTo(position);
|
await nativeRef.current?.seekTo(position);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
<application>
|
<application>
|
||||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
|||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
internal object TvRecommendationsPublisher {
|
internal object TvRecommendationsPublisher {
|
||||||
private const val TAG = "TvRecommendations"
|
private const val TAG = "TvRecommendations"
|
||||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||||
private const val KEY_PAYLOAD = "payload"
|
private const val KEY_PAYLOAD = "payload"
|
||||||
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
private const val KEY_CHANNEL_ID = "channelId"
|
||||||
private const val KEY_PROGRAM_IDS = "programIds"
|
private const val KEY_PROGRAM_IDS = "programIds"
|
||||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||||
|
|
||||||
@@ -163,24 +162,16 @@ internal object TvRecommendationsPublisher {
|
|||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||||
try {
|
try {
|
||||||
val channelMap = JSONObject(programIdsJson)
|
val programIds = JSONObject(programIdsJson)
|
||||||
val channelKeys = channelMap.keys()
|
val keys = programIds.keys()
|
||||||
while (channelKeys.hasNext()) {
|
while (keys.hasNext()) {
|
||||||
val channelId = channelKeys.next()
|
val key = keys.next()
|
||||||
val inner = channelMap.optJSONObject(channelId) ?: continue
|
if (programIds.optLong(key, -1L) == programId) {
|
||||||
val providerKeys = inner.keys()
|
programIds.remove(key)
|
||||||
while (providerKeys.hasNext()) {
|
break
|
||||||
val providerId = providerKeys.next()
|
|
||||||
if (inner.optLong(providerId, -1L) == programId) {
|
|
||||||
inner.remove(providerId)
|
|
||||||
if (inner.length() == 0) {
|
|
||||||
channelMap.remove(channelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||||
}
|
}
|
||||||
@@ -331,8 +322,7 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val channelKey = getChannelKey(displayName)
|
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
val existingChannelId = prefs.getLong(channelKey, -1L)
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
@@ -373,7 +363,7 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
// Channel truly doesn't exist in provider — recreate
|
// Channel truly doesn't exist in provider — recreate
|
||||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||||
prefs.edit().remove(channelKey).apply()
|
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new channel
|
// Create a new channel
|
||||||
@@ -394,7 +384,6 @@ internal object TvRecommendationsPublisher {
|
|||||||
} ?: return -1L
|
} ?: return -1L
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
prefs.edit().putLong(channelKey, channelId).apply()
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
storeChannelLogo(context, channelId)
|
storeChannelLogo(context, channelId)
|
||||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||||
@@ -402,10 +391,6 @@ internal object TvRecommendationsPublisher {
|
|||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getChannelKey(displayName: String): String {
|
|
||||||
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upsertPreviewProgram(
|
private fun upsertPreviewProgram(
|
||||||
context: Context,
|
context: Context,
|
||||||
channelId: Long,
|
channelId: Long,
|
||||||
@@ -477,18 +462,13 @@ internal object TvRecommendationsPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a stable cache key derived from the image URL.
|
* Append a cache-busting parameter to ensure unique URIs when images change.
|
||||||
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
* Per Android docs: "Use unique Uris for all images... the old image will
|
||||||
* that changes whenever the image content changes, so a deterministic
|
* continue to appear if you don't change the Uri."
|
||||||
* hash of the URL is sufficient — the param only changes when the URL
|
|
||||||
* (and therefore the image) actually changes, avoiding unnecessary
|
|
||||||
* re-downloads on every sync.
|
|
||||||
*/
|
*/
|
||||||
private fun appendCacheBuster(imageUrl: String): String {
|
private fun appendCacheBuster(imageUrl: String): String {
|
||||||
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
|
||||||
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
|
||||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||||
return "$imageUrl${separator}_v=$hash"
|
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
@@ -551,8 +531,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
fun getChannelId(context: Context): Long {
|
||||||
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
@@ -587,10 +567,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: SecurityException) {
|
} catch (error: SecurityException) {
|
||||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||||
} catch (error: Exception) {
|
}
|
||||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ContentUris
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
|
||||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (intent.action) {
|
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
||||||
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
return
|
||||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
|
||||||
TvRecommendationsPublisher.refreshFromCache(context)
|
|
||||||
}
|
|
||||||
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
|
||||||
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
|
||||||
if (programId > 0L) {
|
|
||||||
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
|
||||||
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||||
|
TvRecommendationsPublisher.refreshFromCache(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const WifiSsidModule =
|
|||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<string | null> {
|
export async function getSSID(): Promise<string | null> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
|
console.log("[WifiSsid] Module not available on this platform");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
package.json
53
package.json
@@ -17,63 +17,61 @@
|
|||||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "bun scripts/typecheck.ts",
|
"typecheck": "node scripts/typecheck.js",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "1.2.0",
|
"@bottom-tabs/react-navigation": "1.2.0",
|
||||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||||
"@expo/metro-runtime": "~56.0.15",
|
"@expo/metro-runtime": "~56.0.13",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.14",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.3",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-query-persist-client": "^5.100.14",
|
"@tanstack/react-query-persist-client": "^5.100.14",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~56.0.11",
|
"expo": "~56.0.6",
|
||||||
"expo-application": "~56.0.3",
|
"expo-application": "~56.0.3",
|
||||||
"expo-asset": "~56.0.17",
|
"expo-asset": "~56.0.15",
|
||||||
"expo-audio": "~56.0.12",
|
"expo-audio": "~56.0.11",
|
||||||
"expo-background-task": "~56.0.18",
|
"expo-background-task": "~56.0.15",
|
||||||
"expo-blur": "~56.0.3",
|
"expo-blur": "~56.0.3",
|
||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.15",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.7",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.16",
|
||||||
"expo-device": "~56.0.4",
|
"expo-device": "~56.0.4",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.5",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.9",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.12",
|
||||||
"expo-localization": "~56.0.6",
|
"expo-localization": "~56.0.6",
|
||||||
"expo-location": "~56.0.17",
|
"expo-location": "~56.0.14",
|
||||||
"expo-notifications": "~56.0.17",
|
"expo-notifications": "~56.0.14",
|
||||||
"expo-router": "~56.2.10",
|
"expo-router": "~56.2.7",
|
||||||
"expo-screen-orientation": "~56.0.5",
|
"expo-screen-orientation": "~56.0.5",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-sharing": "~56.0.17",
|
"expo-sharing": "~56.0.14",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"expo-system-ui": "~56.0.5",
|
"expo-system-ui": "~56.0.5",
|
||||||
"expo-task-manager": "~56.0.18",
|
"expo-task-manager": "~56.0.15",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
@@ -128,16 +126,14 @@
|
|||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "^18.19.130",
|
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.19.7",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"tsx": "^4.22.4",
|
"typescript": "5.9.3"
|
||||||
"typescript": "6.0.3"
|
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
"doctor": {
|
"doctor": {
|
||||||
@@ -145,7 +141,6 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
"react-native-udp",
|
"react-native-udp",
|
||||||
"react-native-track-player",
|
|
||||||
"@jellyfin/sdk"
|
"@jellyfin/sdk"
|
||||||
],
|
],
|
||||||
"listUnknownPackages": false
|
"listUnknownPackages": false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
const { withPodfile } = require("expo/config-plugins");
|
||||||
|
|
||||||
const PATCH_START = "## >>> runtime-framework headers";
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
const PATCH_END = "## <<< runtime-framework headers";
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
|||||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildPatch(): string {
|
function buildPatch() {
|
||||||
return [
|
return [
|
||||||
PATCH_START,
|
PATCH_START,
|
||||||
" extra_hdrs = [",
|
" extra_hdrs = [",
|
||||||
@@ -91,7 +91,7 @@ function buildPatch(): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
let podfile = config.modResults.contents;
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -125,5 +125,3 @@ end
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withRuntimeFrameworkHeaders;
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
import {
|
const {
|
||||||
type ConfigPlugin,
|
|
||||||
withAndroidColors,
|
withAndroidColors,
|
||||||
withAndroidColorsNight,
|
withAndroidColorsNight,
|
||||||
} from "expo/config-plugins";
|
} = require("expo/config-plugins");
|
||||||
|
|
||||||
interface ColorResourceItem {
|
const withAndroidAlertColors = (config) => {
|
||||||
$: { name: string };
|
const setColor = (colorsList, name, value) => {
|
||||||
_: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|
||||||
const setColor = (
|
|
||||||
colorsList: ColorResourceItem[],
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const existingColor = colorsList.find(
|
const existingColor = colorsList.find(
|
||||||
(item) => item.$ && item.$.name === name,
|
(item) => item.$ && item.$.name === name,
|
||||||
);
|
);
|
||||||
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColors(config, (config) => {
|
config = withAndroidColors(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
const colorsList = colors.resources.color || [];
|
||||||
setColor(colorsList, "colorPrimary", "#000000");
|
setColor(colorsList, "colorPrimary", "#000000");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColorsNight(config, (config) => {
|
config = withAndroidColorsNight(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
const colorsList = colors.resources.color || [];
|
||||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withAndroidAlertColors;
|
module.exports = withAndroidAlertColors;
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
const { withAndroidManifest } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
const _withGoogleCastAndroidManifest = (config) =>
|
||||||
withAndroidManifest(config, async (mod) => {
|
withAndroidManifest(config, async (mod) => {
|
||||||
const mainApplication = mod.modResults.manifest.application?.[0];
|
const mainApplication = mod.modResults.manifest.application[0];
|
||||||
|
|
||||||
if (!mainApplication) {
|
|
||||||
return mod;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
// Initialize activity array if it doesn't exist
|
||||||
if (!mainApplication.activity) {
|
if (!mainApplication.activity) {
|
||||||
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
|||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withGoogleCastAndroidManifest;
|
module.exports = _withGoogleCastAndroidManifest;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs";
|
const { readFileSync, writeFileSync } = require("node:fs");
|
||||||
import { join } from "node:path";
|
const { join } = require("node:path");
|
||||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
const { withDangerousMod } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||||
withDangerousMod(expoConfig, [
|
withDangerousMod(expoConfig, [
|
||||||
"android",
|
"android",
|
||||||
(modConfig) => {
|
(modConfig) => {
|
||||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default withChangeNativeAndroidTextToWhite;
|
module.exports = withChangeNativeAndroidTextToWhite;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
const { withAppBuildGradle } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
module.exports = function withExcludeMedia3Dash(config) {
|
||||||
return withAppBuildGradle(config, (config) => {
|
return withAppBuildGradle(config, (config) => {
|
||||||
const contents = config.modResults.contents;
|
const contents = config.modResults.contents;
|
||||||
|
|
||||||
@@ -32,5 +32,3 @@ configurations.all {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withExcludeMedia3Dash;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user