Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvain
132d378346 ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix
The PR build-status comment had several issues:

- The "Build details & device compatibility" dropdown only rendered once
  artifacts existed, so it was missing for the whole build (the most
  useful time to read it). Always render it now.
- In-progress / queued targets showed an open-ended spinner with no time
  estimate. Pull per-job durations from the latest successful develop
  build and surface them as an ETA (best-effort; dropped on any failure).
- Signed iOS/tvOS job status could be read from the "(Unsigned)" job:
  `.find` + `.includes` matched the unsigned name (which contains the
  signed name as a substring). Prefer an exact name match.
- Signed iOS/tvOS artifact pattern `ios.*phone.*ipa(?!.*unsigned)` also
  matched the unsigned artifact, because "unsigned" precedes "ipa" in the
  artifact names. Anchor a negative lookahead so "unsigned" is excluded
  anywhere in the name.

Also drop a misleading "non-cancelled" log line (the filter keeps
cancelled runs) and factor out a shared duration formatter.
2026-06-16 19:18:14 +02:00
Gauvain
434cb3bd39 ci: ARM Android runners, slimmer APK artifacts, Renovate-pinned tool versions (#1733)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🌐 Translation Sync / sync-translations (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-06-16 17:12:32 +02:00
Gauvain
7a6daa011d ci(build): drop free-disk-space apt-get warning + surface unsigned tvOS in artifact comment (#1732)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-15 20:32:53 +02:00
29 changed files with 607 additions and 890 deletions

10
.github/renovate.json vendored
View File

@@ -30,9 +30,17 @@
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
},
{
"customType": "regex",
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
"datasourceTemplate": "npm",
"depNameTemplate": "bun"
}
],
"customDatasources": {

View File

@@ -18,7 +18,7 @@ jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,21 +184,35 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// Map our build targets to their job display names. Exact name is
// tried first so a signed target never collides with its
// "(Unsigned)" sibling (whose name contains the signed name).
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)'],
'Android TV': ['🤖 Build Android APK (TV)'],
'iOS': ['🍎 Build iOS IPA (Phone)'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
'tvOS': ['🍎 Build tvOS IPA'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
};
// Prefer an exact name match over a substring match so
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
const findJobForTarget = (jobs, jobNames) =>
jobs.find(j => jobNames.some(name => j.name === name)) ||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
// Format a millisecond duration as "Xm Ys".
const fmtDuration = (ms) => {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return `${min}m ${sec}s`;
};
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -229,10 +243,8 @@ jobs:
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
const job = findJobForTarget(jobs.jobs, jobNames);
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -358,6 +370,43 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Pull per-job durations from the latest successful develop build so
// in-progress / queued targets can show a realistic ETA instead of
// an open-ended spinner. Best-effort: any failure just drops the ETA.
let referenceDurations = {};
try {
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build-apps.yml',
branch: 'develop',
status: 'success',
per_page: 1
});
if (devRuns.workflow_runs.length > 0) {
const refRun = devRuns.workflow_runs[0];
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: refRun.id
});
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = findJobForTarget(refJobs.jobs, jobNames);
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
}
}
console.log(`Reference durations from develop run ${refRun.id}:`,
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
} else {
console.log('No successful develop build found for ETA reference');
}
} catch (error) {
console.log('Failed to fetch develop reference durations:', error.message);
}
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -369,9 +418,9 @@ jobs:
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
@@ -387,11 +436,12 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// tvOS builds are temporarily disabled until feat/tv-interface
// is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
// Signed tvOS stays disabled until EAS has tvOS provisioning
// profiles (app + TopShelf targets); non-interactive builds can't
// create them. Unsigned tvOS builds, so it flows through normally.
if (target.name === 'tvOS') {
status = '💤 Disabled';
downloadLink = '*Disabled until feat/tv-interface is merged*';
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
@@ -406,11 +456,9 @@ jobs:
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
durationInfo = ` - ${fmtDuration(durationMs)}`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
@@ -420,10 +468,16 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
downloadLink = '*Build in progress...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
: '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
downloadLink = '*Waiting to start...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
: '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
@@ -444,7 +498,22 @@ jobs:
commentBody += `\n`;
// Show installation instructions if we have any artifacts
// Static rundown of the build optimisations + what each artifact
// installs on. Always shown (even mid-build) so testers know what
// to expect before downloads are ready.
commentBody += `<details>\n`;
commentBody += `<summary>📦 Build details &amp; device compatibility</summary>\n\n`;
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
commentBody += `| Artifact | Architectures | Installs on |\n`;
commentBody += `|---|---|---|\n`;
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
commentBody += `</details>\n\n`;
// Installation instructions only matter once something is downloadable.
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;

View File

@@ -23,7 +23,7 @@ env:
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
@@ -37,7 +37,7 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: true
large-packages: false
docker-images: true
swap-storage: false
@@ -52,31 +52,40 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
@@ -85,12 +94,16 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
# sideload target). Overrides app.json buildArchs for this build only,
# so local `bun run android` (x86_64 emulator) is unaffected.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -106,7 +119,7 @@ jobs:
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
@@ -120,7 +133,7 @@ jobs:
android: false
dotnet: true
haskell: true
large-packages: true
large-packages: false
docker-images: true
swap-storage: false
@@ -135,31 +148,40 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
@@ -168,12 +190,15 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -206,15 +231,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -273,15 +299,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -313,8 +340,10 @@ jobs:
retention-days: 7
build-ios-tv:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build tvOS IPA
@@ -333,15 +362,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -401,15 +431,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |

View File

@@ -13,7 +13,7 @@ concurrency:
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
@@ -29,14 +29,17 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 🛡️ Verify lockfile consistency
run: |

View File

@@ -8,11 +8,14 @@ on:
schedule:
- cron: '24 2 * * *'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write

View File

@@ -10,7 +10,7 @@ on:
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read

View File

@@ -19,7 +19,7 @@ permissions:
jobs:
sync-translations:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
steps:
- name: 📥 Checkout Repository

View File

@@ -15,7 +15,7 @@ jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
issues: write
contents: read
@@ -26,7 +26,8 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs

View File

@@ -15,7 +15,7 @@ jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
pull-requests: write
contents: read
@@ -46,7 +46,7 @@ jobs:
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
@@ -65,8 +65,7 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -78,17 +77,21 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
# without failing the gate (some checks are known-noisy for this setup).
continue-on-error: true
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
@@ -110,12 +113,14 @@ jobs:
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
# renovate: datasource=node-version depName=node versioning=node
node-version: "24.16.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -12,7 +12,7 @@ on:
jobs:
notify:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
@@ -29,7 +29,7 @@ jobs:
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure

View File

@@ -22,8 +22,9 @@ on:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
environment: production
permissions: {}
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
@@ -31,7 +32,7 @@ jobs:
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
strategy:
@@ -72,15 +73,16 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -176,7 +178,7 @@ jobs:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts

View File

@@ -21,7 +21,7 @@ concurrency:
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
@@ -29,19 +29,9 @@ jobs:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
# Trivy's own action caches the vulnerability DB + binary internally
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
# step is needed — it only duplicated the cache.
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:

View File

@@ -20,7 +20,7 @@ permissions:
jobs:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: write
pull-requests: write
@@ -36,7 +36,8 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔢 Populate version dropdown from GitHub releases
id: populate

View File

@@ -36,7 +36,6 @@ import {
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
type SegmentSkipMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
@@ -48,22 +47,6 @@ import {
} from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
const SEGMENT_SKIP_ROWS: {
key:
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview";
labelKey: string;
}[] = [
{ key: "skipIntro", labelKey: "skip_intro" },
{ key: "skipOutro", labelKey: "skip_outro" },
{ key: "skipRecap", labelKey: "skip_recap" },
{ key: "skipCommercial", labelKey: "skip_commercial" },
{ key: "skipPreview", labelKey: "skip_preview" },
];
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -552,30 +535,6 @@ export default function SettingsTV() {
);
}, [inactivityTimeoutOptions, t]);
// Segment skip: same auto/ask/none choice for every segment type.
const segmentSkipModeLabel = (mode: SegmentSkipMode) =>
t(`home.settings.other.segment_skip_${mode}`);
const buildSegmentSkipOptions = (
current: SegmentSkipMode,
): TVOptionItem<SegmentSkipMode>[] => [
{
label: t("home.settings.other.segment_skip_auto"),
value: "auto",
selected: current === "auto",
},
{
label: t("home.settings.other.segment_skip_ask"),
value: "ask",
selected: current === "ask",
},
{
label: t("home.settings.other.segment_skip_none"),
value: "none",
selected: current === "none",
},
];
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -860,30 +819,6 @@ export default function SettingsTV() {
formatValue={(v) => `${v} MB`}
/>
{/* Segment Skip Section */}
<TVSectionHeader
title={t("home.settings.other.segment_skip_settings")}
/>
{SEGMENT_SKIP_ROWS.map((row, index) => {
const current = (settings[row.key] ?? "ask") as SegmentSkipMode;
const rowLabel = t(`home.settings.other.${row.labelKey}`);
return (
<TVSettingsOptionButton
key={row.key}
label={rowLabel}
value={segmentSkipModeLabel(current)}
isFirst={index === 0}
onPress={() =>
showOptions({
title: rowLabel,
options: buildSegmentSkipOptions(current),
onSelect: (value) => updateSettings({ [row.key]: value }),
})
}
/>
);
})}
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton

View File

@@ -1,101 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "expo-router";
import { TFunction } from "i18next";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
type SkipSettingKey =
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview";
const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
{ key: "skipIntro", labelKey: "skip_intro" },
{ key: "skipOutro", labelKey: "skip_outro" },
{ key: "skipRecap", labelKey: "skip_recap" },
{ key: "skipCommercial", labelKey: "skip_commercial" },
{ key: "skipPreview", labelKey: "skip_preview" },
];
const SEGMENT_SKIP_OPTIONS = (
t: TFunction<"translation", undefined>,
): Array<{ label: string; value: SegmentSkipMode }> => [
{ label: t("home.settings.other.segment_skip_auto"), value: "auto" },
{ label: t("home.settings.other.segment_skip_ask"), value: "ask" },
{ label: t("home.settings.other.segment_skip_none"), value: "none" },
];
export default function SegmentSkipPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: t("home.settings.other.segment_skip_settings"),
});
}, [navigation, t]);
const options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
if (!settings) return null;
return (
<View className='px-4'>
<ListGroup>
{SEGMENTS.map(({ key, labelKey }) => {
const current = settings[key];
const locked = pluginSettings?.[key]?.locked ?? false;
const groups = [
{
options: options.map((o) => ({
type: "radio" as const,
label: o.label,
value: o.value,
selected: o.value === current,
disabled: locked,
onPress: () => {
if (locked) return;
updateSettings({ [key]: o.value });
},
})),
},
];
return (
<ListItem
key={key}
title={t(`home.settings.other.${labelKey}`)}
subtitle={t(`home.settings.other.${labelKey}_description`)}
disabled={locked}
>
<PlatformDropdown
groups={groups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${current}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t(`home.settings.other.${labelKey}`)}
/>
</ListItem>
);
})}
</ListGroup>
</View>
);
}

View File

@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
@@ -253,15 +251,6 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
{/* Media Segment Skip Settings */}
<ListItem
title={t("home.settings.other.segment_skip_settings")}
subtitle={t("home.settings.other.segment_skip_settings_description")}
onPress={() => router.push("/settings/segment-skip/page")}
>
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -19,27 +19,10 @@ import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export type TVSkipSegmentType =
| "intro"
| "credits"
| "outro"
| "recap"
| "commercial"
| "preview";
const SEGMENT_LABEL_KEY: Record<TVSkipSegmentType, string> = {
intro: "player.skip_intro",
credits: "player.skip_credits",
outro: "player.skip_outro",
recap: "player.skip_recap",
commercial: "player.skip_commercial",
preview: "player.skip_preview",
};
export interface TVSkipSegmentCardProps {
show: boolean;
onPress: () => void;
type: TVSkipSegmentType;
type: "intro" | "credits";
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
/** Callback ref setter for focus guide destination pattern */
@@ -89,7 +72,8 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
bottom: bottomPosition.value,
}));
const labelText = t(SEGMENT_LABEL_KEY[type]);
const labelText =
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
if (!show) return null;

View File

@@ -34,13 +34,11 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
showSkipSegmentButton: boolean;
skipSegmentButtonText: string;
showSkipOutroButton: boolean;
skipOutroButtonText: string;
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
onSkipSegment: () => void;
onSkipOutro: () => void;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
@@ -88,13 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
showSkipSegmentButton,
skipSegmentButtonText,
showSkipOutroButton,
skipOutroButtonText,
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
onSkipSegment,
onSkipOutro,
skipIntro,
skipCredit,
nextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
@@ -185,18 +181,19 @@ export const BottomControls: FC<BottomControlsProps> = ({
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
<SkipButton
showButton={showSkipSegmentButton}
onPress={onSkipSegment}
buttonText={skipSegmentButtonText}
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
/>
{/* Outro button defers to "Next Episode" when credits run to the
video end and a next episode exists. */}
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={onSkipOutro}
buttonText={skipOutroButtonText}
onPress={skipCredit}
buttonText='Skip Credits'
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
@@ -207,7 +204,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipOutroButton && !hasContentAfterCredits) ||
(showSkipCreditButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}

View File

@@ -4,15 +4,7 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type FC, useCallback, useEffect, useState } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -24,17 +16,17 @@ import Animated, {
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { useSegments } from "@/utils/segments";
import { msToSeconds, ticksToMs } from "@/utils/time";
import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
@@ -51,9 +43,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
// No-op function to avoid creating new references on every render
const noop = () => {};
interface Props {
item: BaseItemDto;
isPlaying: boolean;
@@ -122,24 +111,6 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false);
// Ref to track pending play timeout for cleanup and cancellation
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
const playingRef = useRef(isPlaying);
useEffect(() => {
playingRef.current = isPlaying;
}, [isPlaying]);
// Clean up timeout on unmount
useEffect(() => {
return () => {
if (playTimeoutRef.current) {
clearTimeout(playTimeoutRef.current);
}
};
}, []);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({
item,
@@ -345,140 +316,27 @@ export const Controls: FC<Props> = ({
subtitleIndex: string;
}>();
// Fetch all segments for the current item
const { data: segments } = useSegments(
item.Id ?? "",
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
play,
offline,
downloadedFiles,
api,
downloadedFiles,
);
const currentTimeSeconds = msToSeconds(currentTime);
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
// Segment hook deals in seconds; player API in ms. The 200ms delayed play()
// is a workaround: some seeks otherwise resume from the pre-seek position.
const seekMs = useCallback(
(timeInSeconds: number) => {
if (playTimeoutRef.current) {
clearTimeout(playTimeoutRef.current);
}
seek(timeInSeconds * 1000);
playTimeoutRef.current = setTimeout(() => {
// playingRef avoids a stale closure: re-check current isPlaying.
if (playingRef.current) {
play();
}
playTimeoutRef.current = null;
}, 200);
},
[seek, play],
);
const introSkipper = useSegmentSkipper({
segments: segments?.introSegments || [],
segmentType: "Intro",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const outroSkipper = useSegmentSkipper({
segments: segments?.creditSegments || [],
segmentType: "Outro",
currentTime: currentTimeSeconds,
totalDuration: maxSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const recapSkipper = useSegmentSkipper({
segments: segments?.recapSegments || [],
segmentType: "Recap",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const commercialSkipper = useSegmentSkipper({
segments: segments?.commercialSegments || [],
segmentType: "Commercial",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
const previewSkipper = useSegmentSkipper({
segments: segments?.previewSegments || [],
segmentType: "Preview",
currentTime: currentTimeSeconds,
seek: seekMs,
isPaused: !isPlaying,
});
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
const activeSegment = useMemo(() => {
if (commercialSkipper.currentSegment)
return {
type: "Commercial" as const,
currentSegment: commercialSkipper.currentSegment,
skipSegment: commercialSkipper.skipSegment,
};
if (recapSkipper.currentSegment)
return {
type: "Recap" as const,
currentSegment: recapSkipper.currentSegment,
skipSegment: recapSkipper.skipSegment,
};
if (introSkipper.currentSegment)
return {
type: "Intro" as const,
currentSegment: introSkipper.currentSegment,
skipSegment: introSkipper.skipSegment,
};
if (previewSkipper.currentSegment)
return {
type: "Preview" as const,
currentSegment: previewSkipper.currentSegment,
skipSegment: previewSkipper.skipSegment,
};
if (outroSkipper.currentSegment)
return {
type: "Outro" as const,
currentSegment: outroSkipper.currentSegment,
skipSegment: outroSkipper.skipSegment,
};
return null;
}, [
commercialSkipper.currentSegment,
commercialSkipper.skipSegment,
recapSkipper.currentSegment,
recapSkipper.skipSegment,
introSkipper.currentSegment,
introSkipper.skipSegment,
previewSkipper.currentSegment,
previewSkipper.skipSegment,
outroSkipper.currentSegment,
outroSkipper.skipSegment,
]);
// Outro gets a dedicated button (so it can compose with Next Episode logic);
// every other segment type shares the generic skip button.
const showSkipSegmentButton =
!!activeSegment && activeSegment.type !== "Outro";
const onSkipSegment = activeSegment?.skipSegment ?? noop;
const showSkipOutroButton = activeSegment?.type === "Outro";
const onSkipOutro = outroSkipper.skipSegment;
const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds
: false;
const { t } = useTranslation();
const skipSegmentButtonText = activeSegment
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
: t("player.skip_intro");
const skipOutroButtonText = t("player.skip_outro");
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
play,
offline,
api,
downloadedFiles,
maxMs,
);
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -712,13 +570,11 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipSegmentButton={showSkipSegmentButton}
skipSegmentButtonText={skipSegmentButtonText}
showSkipOutroButton={showSkipOutroButton}
skipOutroButtonText={skipOutroButtonText}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
onSkipSegment={onSkipSegment}
onSkipOutro={onSkipOutro}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}

View File

@@ -38,8 +38,9 @@ import {
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
@@ -50,14 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { useSegments } from "@/utils/segments";
import {
formatTimeString,
msToSeconds,
msToTicks,
secondsToMs,
ticksToMs,
} from "@/utils/time";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
import { useChapterNavigation } from "./hooks/useChapterNavigation";
@@ -105,9 +99,6 @@ interface Props {
const TV_SEEKBAR_HEIGHT = 14;
const TV_AUTO_HIDE_TIMEOUT = 5000;
// Stable no-op so the generic skip card keeps a constant onPress when idle.
const noop = () => {};
// Trickplay bubble positioning constants
const TV_TRICKPLAY_SCALE = 2;
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
@@ -436,139 +427,30 @@ export const Controls: FC<Props> = ({
seek,
});
// Segment skipping (intro + outro/credits) via the unified hook.
// Skip intro/credits hooks
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
const offline = useOfflineMode();
const { data: segments } = useSegments(
item.Id ?? "",
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
_play,
offline,
downloadedFiles,
api,
downloadedFiles,
);
const currentTimeSeconds = msToSeconds(currentTime);
const maxSeconds = msToSeconds(maxMs);
// useSegmentSkipper deals in seconds; the player seek expects ms. The 200ms
// delayed play() mirrors the mobile controls: some seeks otherwise resume
// from the pre-seek position.
const playSegmentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
return () => {
if (playSegmentTimeoutRef.current) {
clearTimeout(playSegmentTimeoutRef.current);
}
};
}, []);
const seekSeconds = useCallback(
(timeInSeconds: number) => {
if (playSegmentTimeoutRef.current) {
clearTimeout(playSegmentTimeoutRef.current);
}
seek(secondsToMs(timeInSeconds));
playSegmentTimeoutRef.current = setTimeout(() => {
_play();
playSegmentTimeoutRef.current = null;
}, 200);
},
[seek, _play],
);
const introSkipper = useSegmentSkipper({
segments: segments?.introSegments ?? [],
segmentType: "Intro",
currentTime: currentTimeSeconds,
seek: seekSeconds,
isPaused: !isPlaying,
});
const outroSkipper = useSegmentSkipper({
segments: segments?.creditSegments ?? [],
segmentType: "Outro",
currentTime: currentTimeSeconds,
totalDuration: maxSeconds,
seek: seekSeconds,
isPaused: !isPlaying,
});
const recapSkipper = useSegmentSkipper({
segments: segments?.recapSegments ?? [],
segmentType: "Recap",
currentTime: currentTimeSeconds,
seek: seekSeconds,
isPaused: !isPlaying,
});
const commercialSkipper = useSegmentSkipper({
segments: segments?.commercialSegments ?? [],
segmentType: "Commercial",
currentTime: currentTimeSeconds,
seek: seekSeconds,
isPaused: !isPlaying,
});
const previewSkipper = useSegmentSkipper({
segments: segments?.previewSegments ?? [],
segmentType: "Preview",
currentTime: currentTimeSeconds,
seek: seekSeconds,
isPaused: !isPlaying,
});
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
// The outro keeps its dedicated card (it composes with the Next Episode
// countdown); the other four share one generic skip card. Including the outro
// here keeps the two cards mutually exclusive.
const activeSegment = useMemo(() => {
if (commercialSkipper.currentSegment)
return {
type: "commercial" as const,
skipSegment: commercialSkipper.skipSegment,
};
if (recapSkipper.currentSegment)
return { type: "recap" as const, skipSegment: recapSkipper.skipSegment };
if (introSkipper.currentSegment)
return { type: "intro" as const, skipSegment: introSkipper.skipSegment };
if (previewSkipper.currentSegment)
return {
type: "preview" as const,
skipSegment: previewSkipper.skipSegment,
};
if (outroSkipper.currentSegment)
return { type: "outro" as const, skipSegment: outroSkipper.skipSegment };
return null;
}, [
commercialSkipper.currentSegment,
commercialSkipper.skipSegment,
recapSkipper.currentSegment,
recapSkipper.skipSegment,
introSkipper.currentSegment,
introSkipper.skipSegment,
previewSkipper.currentSegment,
previewSkipper.skipSegment,
outroSkipper.currentSegment,
outroSkipper.skipSegment,
]);
const isOutroActive = activeSegment?.type === "outro";
// Generic card (intro/recap/commercial/preview).
const showSkipButton = !!activeSegment && !isOutroActive;
const skipActiveSegment = activeSegment?.skipSegment ?? noop;
const activeSegmentType = isOutroActive
? "intro"
: (activeSegment?.type ?? "intro");
// Outro card (composes with the Next Episode countdown).
const showSkipCreditButton = isOutroActive;
const skipCredit = outroSkipper.skipSegment;
const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds
: false;
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
_play,
offline,
api,
downloadedFiles,
max.value,
);
// Countdown logic
const isCountdownActive = useMemo(() => {
@@ -1244,11 +1126,11 @@ export const Controls: FC<Props> = ({
/>
)}
{/* Generic skip card (intro / recap / commercial / preview) */}
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={skipActiveSegment}
type={activeSegmentType}
onPress={skipIntro}
type='intro'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}
hasTVPreferredFocus={!showControls}

View File

@@ -52,7 +52,7 @@
}
},
"production": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -64,7 +64,7 @@
}
},
"production-apk": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -74,7 +74,7 @@
}
},
"production-apk-tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -87,7 +87,7 @@
}
},
"production_tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"env": {

109
hooks/useCreditSkipper.ts Normal file
View File

@@ -0,0 +1,109 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* The player reports time values in milliseconds.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
totalDuration?: number,
) => {
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const totalDurationInSeconds =
totalDuration != null ? msToSeconds(totalDuration) : undefined;
// Regular function (not useCallback) to match useIntroSkipper pattern
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const creditTimestamps = segments?.creditSegments?.[0];
// Determine if there's content after credits (credits don't extend to video end)
// Use a 5-second buffer to account for timing discrepancies
const hasContentAfterCredits = (() => {
if (
!creditTimestamps ||
totalDurationInSeconds == null ||
!Number.isFinite(totalDurationInSeconds)
) {
return false;
}
const creditsEndToVideoEnd =
totalDurationInSeconds - creditTimestamps.endTime;
// If credits end more than 5 seconds before video ends, there's content after
return creditsEndToVideoEnd > 5;
})();
useEffect(() => {
if (creditTimestamps) {
const shouldShow =
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime;
setShowSkipCreditButton(shouldShow);
} else {
// Reset button state when no credit timestamps exist
if (showSkipCreditButton) {
setShowSkipCreditButton(false);
}
}
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;
try {
lightHapticFeedback();
// Calculate the target seek position
let seekTarget = creditTimestamps.endTime;
// If we have total duration, ensure we don't seek past the end of the video.
// Some media sources report credit end times that exceed the actual video duration,
// which causes the player to pause/stop when seeking past the end.
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
// (next episode countdown, etc.) instead of an abrupt pause.
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
seekTarget = Math.max(0, totalDurationInSeconds - 2);
}
wrappedSeek(seekTarget);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
}
}, [
creditTimestamps,
lightHapticFeedback,
wrappedSeek,
play,
totalDurationInSeconds,
]);
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
};

68
hooks/useIntroSkipper.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping intros in a media player.
* MPV player uses milliseconds for time.
*
* @param {number} currentTime - The current playback time in milliseconds.
*/
export const useIntroSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const introTimestamps = segments?.introSegments?.[0];
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[INTRO_SKIPPER] Error skipping intro", error);
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
return { showSkipButton, skipIntro };
};

View File

@@ -1,109 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { MediaTimeSegment } from "@/providers/Downloads/types";
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "./useHaptic";
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
const SEGMENT_TO_SETTING: Record<
SegmentType,
"skipIntro" | "skipOutro" | "skipRecap" | "skipCommercial" | "skipPreview"
> = {
Intro: "skipIntro",
Outro: "skipOutro",
Recap: "skipRecap",
Commercial: "skipCommercial",
Preview: "skipPreview",
};
interface UseSegmentSkipperProps {
segments: MediaTimeSegment[];
segmentType: SegmentType;
currentTime: number;
totalDuration?: number;
seek: (time: number) => void;
isPaused: boolean;
}
interface UseSegmentSkipperReturn {
currentSegment: MediaTimeSegment | null;
skipSegment: (useHaptics?: boolean) => void;
}
/**
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
*/
export const useSegmentSkipper = ({
segments,
segmentType,
currentTime,
totalDuration,
seek,
isPaused,
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
const { settings } = useSettings();
const haptic = useHaptic();
const autoSkipTriggeredRef = useRef<string | null>(null);
const skipMode: SegmentSkipMode =
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
const currentSegment = useMemo(
() =>
segments.find(
(s) => currentTime >= s.startTime && currentTime < s.endTime,
) ?? null,
[segments, currentTime],
);
// Refs let the auto-skip effect avoid re-running when skipSegment/haptic
// identities change (haptic is unstable when disabled).
const seekRef = useRef(seek);
const hapticRef = useRef(haptic);
useEffect(() => {
seekRef.current = seek;
hapticRef.current = haptic;
});
const skipSegment = useCallback(
(useHaptics = true) => {
if (!currentSegment || skipMode === "none") return;
// Outro endTime sometimes exceeds the actual file duration. Keep a 2s
// buffer so the player's natural end-of-video flow (next-episode
// countdown, etc.) still fires instead of stalling at the exact end.
let target = currentSegment.endTime;
if (
segmentType === "Outro" &&
totalDuration != null &&
Number.isFinite(totalDuration) &&
target >= totalDuration
) {
target = Math.max(0, totalDuration - 2);
}
seekRef.current(target);
if (useHaptics) hapticRef.current();
},
[currentSegment, segmentType, totalDuration, skipMode],
);
useEffect(() => {
if (skipMode !== "auto" || isPaused || !currentSegment) {
if (!currentSegment) autoSkipTriggeredRef.current = null;
return;
}
const segmentId = `${currentSegment.startTime}-${currentSegment.endTime}`;
if (autoSkipTriggeredRef.current === segmentId) return;
autoSkipTriggeredRef.current = segmentId;
skipSegment(false);
}, [currentSegment, skipMode, isPaused, skipSegment]);
return {
currentSegment: skipMode === "none" ? null : currentSegment,
skipSegment,
};
};

View File

@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
text: string;
}
export interface Segment {
startTime: number;
endTime: number;
text: string;
}
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
export interface DownloadedItem {
/** The Jellyfin item DTO. */
@@ -50,12 +56,6 @@ export interface DownloadedItem {
introSegments?: MediaTimeSegment[];
/** The credit segments for the item. */
creditSegments?: MediaTimeSegment[];
/** The recap segments for the item. */
recapSegments?: MediaTimeSegment[];
/** The commercial segments for the item. */
commercialSegments?: MediaTimeSegment[];
/** The preview segments for the item. */
previewSegments?: MediaTimeSegment[];
/** The user data for the item. */
userData: UserData;
}
@@ -144,12 +144,6 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[];
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
recapSegments?: MediaTimeSegment[];
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
commercialSegments?: MediaTimeSegment[];
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
previewSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */

View File

@@ -302,7 +302,7 @@ function parseArgs(argv: string[]): BuildOptions {
if (!configArg) {
throw new Error("--configuration requires an argument");
}
options.configuration = (configArg as "Debug" | "Release") || "Debug";
options.configuration = configArg as "Debug" | "Release";
break;
}
case "--device":
@@ -997,10 +997,6 @@ async function waitForSimulatorBoot(
}
} catch {
// Simulator not found or not booted yet, continue polling
if (pollIntervalMs > 1000) {
// Only log if we've been waiting a while to avoid spam
// console.warn("Simulator polling failed, retrying...");
}
}
// Wait before next poll

View File

@@ -304,21 +304,6 @@
"default_playback_speed": "Default playback speed",
"auto_play_next_episode": "Auto-play next episode",
"max_auto_play_episode_count": "Max auto-play episode count",
"segment_skip_settings": "Segment skip settings",
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
"skip_intro": "Skip intro",
"skip_intro_description": "Action when intro segment is detected",
"skip_outro": "Skip outro/credits",
"skip_outro_description": "Action when outro/credits segment is detected",
"skip_recap": "Skip recap",
"skip_recap_description": "Action when recap segment is detected",
"skip_commercial": "Skip commercial",
"skip_commercial_description": "Action when commercial segment is detected",
"skip_preview": "Skip preview",
"skip_preview_description": "Action when preview segment is detected",
"segment_skip_none": "None",
"segment_skip_ask": "Show skip button",
"segment_skip_auto": "Auto skip",
"disabled": "Disabled"
},
"music": {
@@ -644,10 +629,6 @@
"settings": "Settings",
"skip_intro": "Skip intro",
"skip_credits": "Skip credits",
"skip_outro": "Skip outro",
"skip_recap": "Skip recap",
"skip_commercial": "Skip commercial",
"skip_preview": "Skip preview",
"stopPlayback": "Stop playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",

View File

@@ -183,9 +183,6 @@ export enum TVTypographyScale {
ExtraLarge = "extraLarge",
}
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -249,12 +246,6 @@ export type Settings = {
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean;
// Media segment skip preferences
skipIntro: SegmentSkipMode;
skipOutro: SegmentSkipMode;
skipRecap: SegmentSkipMode;
skipCommercial: SegmentSkipMode;
skipPreview: SegmentSkipMode;
// Playback speed settings
defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record<string, number>;
@@ -358,12 +349,6 @@ export const defaultValues: Settings = {
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true,
// Media segment skip defaults
skipIntro: "ask",
skipOutro: "ask",
skipRecap: "ask",
skipCommercial: "ask",
skipPreview: "ask",
// Playback speed defaults
defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {},

View File

@@ -1,40 +1,46 @@
import { Api } from "@jellyfin/sdk";
import { MediaSegmentType } from "@jellyfin/sdk/lib/generated-client/models/media-segment-type";
import { getMediaSegmentsApi } from "@jellyfin/sdk/lib/utils/api/media-segments-api";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
import { getAuthHeaders } from "./jellyfin/jellyfin";
export interface SegmentBuckets {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
// New Jellyfin 10.11+ Media Segments API types
interface MediaSegmentDto {
Id: string;
ItemId: string;
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
StartTicks: number;
EndTicks: number;
}
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
interface MediaSegmentsResponse {
Items: MediaSegmentDto[];
}
// Legacy API types (for fallback)
interface IntroTimestamps {
IntroStart: number;
EpisodeId: string;
HideSkipPromptAt: number;
IntroEnd: number;
IntroStart: number;
ShowSkipPromptAt: number;
Valid: boolean;
}
interface CreditTimestamps {
Credits: { Start: number; End: number; Valid: boolean };
Introduction: {
Start: number;
End: number;
Valid: boolean;
};
Credits: {
Start: number;
End: number;
Valid: boolean;
};
}
const TICKS_PER_SECOND = 10_000_000;
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
const emptyBuckets = (): SegmentBuckets => ({
introSegments: [],
creditSegments: [],
recapSegments: [],
commercialSegments: [],
previewSegments: [],
});
const TICKS_PER_SECOND = 10000000;
export const useSegments = (
itemId: string,
@@ -42,6 +48,7 @@ export const useSegments = (
downloadedFiles: DownloadedItem[] | undefined,
api: Api | null,
) => {
// Memoize the lookup so the array is only traversed when dependencies change
const downloadedItem = React.useMemo(
() => downloadedFiles?.find((d) => d.item.Id === itemId),
[downloadedFiles, itemId],
@@ -58,110 +65,141 @@ export const useSegments = (
}
return fetchAndParseSegments(itemId, api);
},
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
enabled: isOffline ? !!downloadedItem : !!api,
});
};
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
recapSegments: item.recapSegments || [],
commercialSegments: item.commercialSegments || [],
previewSegments: item.previewSegments || [],
});
export const getSegmentsForItem = (
item: DownloadedItem,
): {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
} => {
return {
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
};
};
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
/**
* Converts Jellyfin ticks to seconds
*/
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
/**
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
*/
const fetchMediaSegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets | null> => {
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
} | null> => {
try {
const response = await getMediaSegmentsApi(api).getItemSegments({
itemId,
includeSegmentTypes: [
MediaSegmentType.Intro,
MediaSegmentType.Outro,
MediaSegmentType.Recap,
MediaSegmentType.Commercial,
MediaSegmentType.Preview,
],
});
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
`${api.basePath}/MediaSegments/${itemId}`,
{
headers: getAuthHeaders(api),
params: {
includeSegmentTypes: ["Intro", "Outro"],
},
},
);
const buckets = emptyBuckets();
for (const segment of response.data.Items ?? []) {
if (segment.StartTicks == null || segment.EndTicks == null) continue;
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
response.data.Items.forEach((segment) => {
const timeSegment: MediaTimeSegment = {
startTime: ticksToSeconds(segment.StartTicks),
endTime: ticksToSeconds(segment.EndTicks),
text: segment.Type ?? "",
text: segment.Type,
};
switch (segment.Type) {
case MediaSegmentType.Intro:
buckets.introSegments.push(timeSegment);
case "Intro":
introSegments.push(timeSegment);
break;
case MediaSegmentType.Outro:
buckets.creditSegments.push(timeSegment);
case "Outro":
creditSegments.push(timeSegment);
break;
case MediaSegmentType.Recap:
buckets.recapSegments.push(timeSegment);
break;
case MediaSegmentType.Commercial:
buckets.commercialSegments.push(timeSegment);
break;
case MediaSegmentType.Preview:
buckets.previewSegments.push(timeSegment);
// Optionally handle other types like Recap, Commercial, Preview
default:
break;
}
}
});
return buckets;
} catch {
return { introSegments, creditSegments };
} catch (_error) {
// Return null to indicate we should try legacy endpoints
return null;
}
};
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
/**
* Fetches segments using legacy pre-10.11 endpoints
*/
const fetchLegacySegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets> => {
const buckets = emptyBuckets();
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
}> => {
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
try {
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
buckets.introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
if (
creditRes.status === "fulfilled" &&
creditRes.value.data.Credits.Valid
) {
creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Credits",
});
}
} catch (error) {
console.error("Failed to fetch legacy segments", error);
}
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
buckets.creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Outro",
});
}
return buckets;
return { introSegments, creditSegments };
};
export const fetchAndParseSegments = async (
itemId: string,
api: Api,
): Promise<SegmentBuckets> => {
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
}> => {
// Try new API first (Jellyfin 10.11+)
const newSegments = await fetchMediaSegments(itemId, api);
return newSegments ?? fetchLegacySegments(itemId, api);
if (newSegments) {
return newSegments;
}
// Fallback to legacy endpoints
return fetchLegacySegments(itemId, api);
};