mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 01:28:27 +01:00
Compare commits
35 Commits
fix/biome-
...
autoskip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85fc77643 | ||
|
|
cf91c4c682 | ||
|
|
1545790528 | ||
|
|
11ec778bd8 | ||
|
|
0c6ef5cbda | ||
|
|
1e14c7ec46 | ||
|
|
c8ddb9a892 | ||
|
|
9ee71a002d | ||
|
|
c950408bdb | ||
|
|
1ac0644a57 | ||
|
|
0aa2dc5924 | ||
|
|
e7f200a114 | ||
|
|
da9afacbf7 | ||
|
|
80fdd579f3 | ||
|
|
f79cf1925d | ||
|
|
8bb0d845a2 | ||
|
|
c7cd8217c9 | ||
|
|
235ba1473f | ||
|
|
284a4e3d41 | ||
|
|
1fd3574520 | ||
|
|
f1188c090a | ||
|
|
1321a5c000 | ||
|
|
52bc5e912d | ||
|
|
7bccafc476 | ||
|
|
8df61838d4 | ||
|
|
55776d887f | ||
|
|
2e7079cb5a | ||
|
|
428455f6a6 | ||
|
|
8c749cdc4d | ||
|
|
7ed0c00ce7 | ||
|
|
222ae69644 | ||
|
|
fec8df37f7 | ||
|
|
0e0e722e1c | ||
|
|
2ce810c191 | ||
|
|
564a593a3a |
75
.github/workflows/artifact-comment.yml
vendored
75
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
// Check if we're running from a fork (more precise detection)
|
||||
@@ -188,6 +188,17 @@ jobs:
|
||||
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({
|
||||
@@ -216,13 +227,6 @@ jobs:
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
// Map job names to our build targets
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
|
||||
};
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
@@ -236,7 +240,9 @@ jobs:
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: job.started_at || latestAppsRun.created_at
|
||||
created_at: job.started_at || latestAppsRun.created_at,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at
|
||||
};
|
||||
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||
} else {
|
||||
@@ -247,22 +253,30 @@ jobs:
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||
// Fallback to workflow-level status
|
||||
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
||||
// Fallback to workflow-level status for every build target.
|
||||
// Keys must match jobMappings / buildTargets statusKey values.
|
||||
const fallbackStatus = {
|
||||
name: latestAppsRun.name,
|
||||
status: latestAppsRun.status,
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at
|
||||
created_at: latestAppsRun.created_at,
|
||||
started_at: latestAppsRun.run_started_at,
|
||||
completed_at: latestAppsRun.updated_at
|
||||
};
|
||||
for (const platform of Object.keys(jobMappings)) {
|
||||
buildStatuses[platform] = fallbackStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect artifacts if any job has completed successfully
|
||||
@@ -353,10 +367,12 @@ jobs:
|
||||
|
||||
// Process each expected build target individually
|
||||
const buildTargets = [
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
|
||||
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/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: '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: '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 }
|
||||
];
|
||||
|
||||
for (const target of buildTargets) {
|
||||
@@ -371,16 +387,31 @@ jobs:
|
||||
let status = '⏳ Pending';
|
||||
let downloadLink = '*Waiting for build...*';
|
||||
|
||||
// Special case for iOS TV - show as disabled
|
||||
if (target.name === 'iOS TV') {
|
||||
// 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') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled for now*';
|
||||
downloadLink = '*Disabled until feat/tv-interface is merged*';
|
||||
} else if (matchingStatus) {
|
||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||
status = '✅ Complete';
|
||||
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
||||
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink})`;
|
||||
|
||||
// Format file size
|
||||
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
|
||||
const sizeInfo = `(${sizeInMB} MB)`;
|
||||
|
||||
// Calculate build duration
|
||||
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`;
|
||||
}
|
||||
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
downloadLink = '*Build failed*';
|
||||
@@ -408,7 +439,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
|
||||
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
|
||||
}
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
226
.github/workflows/build-apps.yml
vendored
226
.github/workflows/build-apps.yml
vendored
@@ -41,12 +41,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
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') }}
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -124,12 +124,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
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') }}
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -195,12 +195,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -216,12 +216,12 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
@@ -259,12 +259,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
@@ -293,73 +293,133 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
# Disabled for now - uncomment when ready to build iOS TV
|
||||
# build-ios-tv:
|
||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
# runs-on: macos-26
|
||||
# name: 🍎 Build iOS IPA (TV)
|
||||
# permissions:
|
||||
# contents: read
|
||||
#
|
||||
# steps:
|
||||
# - name: 📥 Checkout code
|
||||
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# with:
|
||||
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
# fetch-depth: 0
|
||||
# submodules: recursive
|
||||
# show-progress: false
|
||||
#
|
||||
# - name: 🍞 Setup Bun
|
||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
# with:
|
||||
# bun-version: latest
|
||||
#
|
||||
# - name: 💾 Cache Bun dependencies
|
||||
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
# with:
|
||||
# path: ~/.bun/install/cache
|
||||
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-bun-cache
|
||||
#
|
||||
# - name: 📦 Install dependencies and reload submodules
|
||||
# run: |
|
||||
# bun install --frozen-lockfile
|
||||
# bun run submodule-reload
|
||||
#
|
||||
# - name: 🛠️ Generate project files
|
||||
# run: bun run prebuild:tv
|
||||
#
|
||||
# - name: 🔧 Setup Xcode
|
||||
# uses: maxim-lobanov/setup-xcode@v1
|
||||
# with:
|
||||
# xcode-version: '26.0.1'
|
||||
#
|
||||
# - name: 🏗️ Setup EAS
|
||||
# uses: expo/expo-github-action@main
|
||||
# with:
|
||||
# eas-version: latest
|
||||
# token: ${{ secrets.EXPO_TOKEN }}
|
||||
# eas-cache: true
|
||||
#
|
||||
# - name: 🚀 Build iOS app
|
||||
# env:
|
||||
# EXPO_TV: 1
|
||||
# run: eas build -p ios --local --non-interactive
|
||||
#
|
||||
# - name: 📅 Set date tag
|
||||
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
#
|
||||
# - name: 📤 Upload IPA artifact
|
||||
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
# with:
|
||||
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||
# path: build-*.ipa
|
||||
# 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.
|
||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
build-ios-tv-unsigned:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix below.
|
||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||
path: build/*.ipa
|
||||
retention-days: 7
|
||||
|
||||
4
.github/workflows/check-lockfile.yml
vendored
4
.github/workflows/check-lockfile.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -27,13 +27,13 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
12
.github/workflows/linting.yml
vendored
12
.github/workflows/linting.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: high
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
6
.github/workflows/update-issue-form.yml
vendored
6
.github/workflows/update-issue-form.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 🔍 Extract minor version from app.json
|
||||
id: minor
|
||||
uses: actions/github-script@main
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
dry_run: no-push
|
||||
|
||||
- name: 📬 Commit and create pull request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
|
||||
101
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
101
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -15,6 +16,7 @@ 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();
|
||||
|
||||
@@ -248,6 +250,15 @@ 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>
|
||||
);
|
||||
|
||||
@@ -18,11 +18,13 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
showSkipSegmentButton: boolean;
|
||||
skipSegmentButtonText: string;
|
||||
showSkipOutroButton: boolean;
|
||||
skipOutroButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
onSkipSegment: () => void;
|
||||
onSkipOutro: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
@@ -66,11 +68,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
showSkipSegmentButton,
|
||||
skipSegmentButtonText,
|
||||
showSkipOutroButton,
|
||||
skipOutroButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
onSkipSegment,
|
||||
onSkipOutro,
|
||||
nextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
@@ -134,19 +138,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
</View>
|
||||
<View className='flex flex-row space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
showButton={showSkipSegmentButton}
|
||||
onPress={onSkipSegment}
|
||||
buttonText={skipSegmentButtonText}
|
||||
/>
|
||||
{/* 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 */}
|
||||
{/* Outro button defers to "Next Episode" when credits run to the
|
||||
video end and a next episode exists. */}
|
||||
<SkipButton
|
||||
showButton={
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
onPress={onSkipOutro}
|
||||
buttonText={skipOutroButtonText}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
@@ -157,7 +160,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
(showSkipOutroButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -16,17 +24,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 { ticksToMs } from "@/utils/time";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -42,6 +50,9 @@ 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;
|
||||
@@ -110,6 +121,24 @@ 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,
|
||||
@@ -300,27 +329,140 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
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 goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -533,11 +675,13 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
showSkipSegmentButton={showSkipSegmentButton}
|
||||
skipSegmentButtonText={skipSegmentButtonText}
|
||||
showSkipOutroButton={showSkipOutroButton}
|
||||
skipOutroButtonText={skipOutroButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
onSkipSegment={onSkipSegment}
|
||||
onSkipOutro={onSkipOutro}
|
||||
nextItem={nextItem}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
109
hooks/useSegmentSkipper.ts
Normal file
109
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -171,7 +171,11 @@ final class MPVLayerRenderer {
|
||||
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||
// This is better for PiP as subtitles are baked into the video
|
||||
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
|
||||
#if targetEnvironment(simulator)
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
|
||||
#else
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||
#endif
|
||||
|
||||
// Hardware decoding with VideoToolbox
|
||||
// On simulator, use software decoding since VideoToolbox is not available
|
||||
|
||||
15
package.json
15
package.json
@@ -15,6 +15,7 @@
|
||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
||||
"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",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
@@ -33,7 +34,7 @@
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
@@ -70,14 +71,14 @@
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.0.0",
|
||||
"i18next": "^26.0.0",
|
||||
"jotai": "2.16.2",
|
||||
"lodash": "4.17.23",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
@@ -117,16 +118,16 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@react-native-community/cli": "20.1.1",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/react": "19.1.17",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.17.14",
|
||||
"expo-doctor": "1.19.7",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
|
||||
@@ -32,12 +32,6 @@ 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. */
|
||||
@@ -56,6 +50,12 @@ 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,6 +144,12 @@ 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 */
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean;
|
||||
snapPoints?: (string | number)[];
|
||||
@@ -73,6 +76,25 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "android") return;
|
||||
|
||||
const onBackPress = () => {
|
||||
if (isVisible) {
|
||||
hideModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
onBackPress,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [isVisible, hideModal]);
|
||||
|
||||
const value = {
|
||||
showModal,
|
||||
hideModal,
|
||||
|
||||
@@ -24,6 +24,31 @@
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_outro": "Skip Outro",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
@@ -308,6 +333,21 @@
|
||||
"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"
|
||||
},
|
||||
"downloads": {
|
||||
@@ -590,26 +630,6 @@
|
||||
"custom_links": {
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
"no_items_to_display": "No Items to Display",
|
||||
|
||||
@@ -308,7 +308,22 @@
|
||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
"disabled": "Désactivé",
|
||||
"segment_skip_settings": "Saut de segments",
|
||||
"segment_skip_settings_description": "Configurer le saut pour les intros, génériques et autres segments",
|
||||
"skip_intro": "Sauter l'intro",
|
||||
"skip_intro_description": "Action lorsqu'un segment d'intro est détecté",
|
||||
"skip_outro": "Sauter générique / outro",
|
||||
"skip_outro_description": "Action lorsqu'un segment de générique/outro est détecté",
|
||||
"skip_recap": "Sauter le résumé",
|
||||
"skip_recap_description": "Action lorsqu'un segment de résumé est détecté",
|
||||
"skip_commercial": "Sauter la publicité",
|
||||
"skip_commercial_description": "Action lorsqu'un segment publicitaire est détecté",
|
||||
"skip_preview": "Sauter l'aperçu",
|
||||
"skip_preview_description": "Action lorsqu'un segment d'aperçu est détecté",
|
||||
"segment_skip_none": "Aucune",
|
||||
"segment_skip_ask": "Afficher le bouton",
|
||||
"segment_skip_auto": "Saut automatique"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements"
|
||||
@@ -591,6 +606,11 @@
|
||||
"no_links": "Aucuns liens"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Passer l'intro",
|
||||
"skip_outro": "Passer l'outro",
|
||||
"skip_recap": "Passer le résumé",
|
||||
"skip_commercial": "Passer la pub",
|
||||
"skip_preview": "Passer l'aperçu",
|
||||
"error": "Erreur",
|
||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||
|
||||
@@ -134,6 +134,9 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -181,6 +184,12 @@ 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>;
|
||||
@@ -266,6 +275,12 @@ 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: {},
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
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";
|
||||
|
||||
// New Jellyfin 10.11+ Media Segments API types
|
||||
interface MediaSegmentDto {
|
||||
Id: string;
|
||||
ItemId: string;
|
||||
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
StartTicks: number;
|
||||
EndTicks: number;
|
||||
export interface SegmentBuckets {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
recapSegments: MediaTimeSegment[];
|
||||
commercialSegments: MediaTimeSegment[];
|
||||
previewSegments: MediaTimeSegment[];
|
||||
}
|
||||
|
||||
interface MediaSegmentsResponse {
|
||||
Items: MediaSegmentDto[];
|
||||
}
|
||||
|
||||
// Legacy API types (for fallback)
|
||||
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
IntroStart: number;
|
||||
ShowSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
Valid: boolean;
|
||||
}
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: { Start: number; End: number; Valid: boolean };
|
||||
}
|
||||
|
||||
const TICKS_PER_SECOND = 10000000;
|
||||
const TICKS_PER_SECOND = 10_000_000;
|
||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||
|
||||
const emptyBuckets = (): SegmentBuckets => ({
|
||||
introSegments: [],
|
||||
creditSegments: [],
|
||||
recapSegments: [],
|
||||
commercialSegments: [],
|
||||
previewSegments: [],
|
||||
});
|
||||
|
||||
export const useSegments = (
|
||||
itemId: string,
|
||||
@@ -48,7 +42,6 @@ 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],
|
||||
@@ -65,141 +58,110 @@ export const useSegments = (
|
||||
}
|
||||
return fetchAndParseSegments(itemId, api);
|
||||
},
|
||||
enabled: isOffline ? !!downloadedItem : !!api,
|
||||
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSegmentsForItem = (
|
||||
item: DownloadedItem,
|
||||
): {
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} => {
|
||||
return {
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
};
|
||||
};
|
||||
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
|
||||
introSegments: item.introSegments || [],
|
||||
creditSegments: item.creditSegments || [],
|
||||
recapSegments: item.recapSegments || [],
|
||||
commercialSegments: item.commercialSegments || [],
|
||||
previewSegments: item.previewSegments || [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts Jellyfin ticks to seconds
|
||||
*/
|
||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||
|
||||
/**
|
||||
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||
*/
|
||||
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
|
||||
const fetchMediaSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} | null> => {
|
||||
): Promise<SegmentBuckets | null> => {
|
||||
try {
|
||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||
`${api.basePath}/MediaSegments/${itemId}`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
params: {
|
||||
includeSegmentTypes: ["Intro", "Outro"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await getMediaSegmentsApi(api).getItemSegments({
|
||||
itemId,
|
||||
includeSegmentTypes: [
|
||||
MediaSegmentType.Intro,
|
||||
MediaSegmentType.Outro,
|
||||
MediaSegmentType.Recap,
|
||||
MediaSegmentType.Commercial,
|
||||
MediaSegmentType.Preview,
|
||||
],
|
||||
});
|
||||
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
|
||||
response.data.Items.forEach((segment) => {
|
||||
const buckets = emptyBuckets();
|
||||
for (const segment of response.data.Items ?? []) {
|
||||
if (segment.StartTicks == null || segment.EndTicks == null) continue;
|
||||
const timeSegment: MediaTimeSegment = {
|
||||
startTime: ticksToSeconds(segment.StartTicks),
|
||||
endTime: ticksToSeconds(segment.EndTicks),
|
||||
text: segment.Type,
|
||||
text: segment.Type ?? "",
|
||||
};
|
||||
|
||||
switch (segment.Type) {
|
||||
case "Intro":
|
||||
introSegments.push(timeSegment);
|
||||
case MediaSegmentType.Intro:
|
||||
buckets.introSegments.push(timeSegment);
|
||||
break;
|
||||
case "Outro":
|
||||
creditSegments.push(timeSegment);
|
||||
case MediaSegmentType.Outro:
|
||||
buckets.creditSegments.push(timeSegment);
|
||||
break;
|
||||
// Optionally handle other types like Recap, Commercial, Preview
|
||||
default:
|
||||
case MediaSegmentType.Recap:
|
||||
buckets.recapSegments.push(timeSegment);
|
||||
break;
|
||||
case MediaSegmentType.Commercial:
|
||||
buckets.commercialSegments.push(timeSegment);
|
||||
break;
|
||||
case MediaSegmentType.Preview:
|
||||
buckets.previewSegments.push(timeSegment);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
} catch (_error) {
|
||||
// Return null to indicate we should try legacy endpoints
|
||||
return buckets;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches segments using legacy pre-10.11 endpoints
|
||||
*/
|
||||
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
|
||||
const fetchLegacySegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
): Promise<SegmentBuckets> => {
|
||||
const buckets = emptyBuckets();
|
||||
|
||||
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) },
|
||||
),
|
||||
]);
|
||||
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) {
|
||||
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 (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||
buckets.introSegments.push({
|
||||
startTime: introRes.value.data.IntroStart,
|
||||
endTime: introRes.value.data.IntroEnd,
|
||||
text: "Intro",
|
||||
});
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
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;
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
// Try new API first (Jellyfin 10.11+)
|
||||
): Promise<SegmentBuckets> => {
|
||||
const newSegments = await fetchMediaSegments(itemId, api);
|
||||
if (newSegments) {
|
||||
return newSegments;
|
||||
}
|
||||
|
||||
// Fallback to legacy endpoints
|
||||
return fetchLegacySegments(itemId, api);
|
||||
return newSegments ?? fetchLegacySegments(itemId, api);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user